首页 / 资讯中心 / 文章详情

单一职责、第一性原理与ACID:边界与一致性实践

单一职责、第一性原理与ACID:边界与一致性实践 ★ FEATURED ARTICLE
1. 三个原则放一起看它们其实在管三件不同的事前阵子帮一个团队做代码评审一个八百多行的服务类里同时塞着参数校验、库存扣减、消息推送和报表落库。有人提了一句“这不违反单一职责原则吗”旁边立刻有人接“拆太细也麻烦”。这种对话我听过太多次——原则大家都背得出来难的是判断什么时候该用、用到什么粒度。后来又聊到技术选型有人张口就是“业界都这么干”我问了一句“把这个方案拆到最底层它到底在解决哪个物理约束”会议室安静了几秒。再往后聊到数据一致性又绕回ACID原则那一套老话题。这三个东西经常被放在一起讲但它们压根不在一个层面上。单一职责原则管的是边界怎么划第一性原则管的是问题怎么拆ACID原则管的是数据怎么才算数。我习惯把第一性原理写成第一性原则指的是同一个东西都是从最基本的事实出发去推导而不是从别人的结论出发去类比。而安全、学习、节约这三个词恰好是评价这三条原则有没有用对的三个尺子边界划歪了安全漏洞就从缝里钻进来问题拆错了学习成本会翻倍方案选重了钱和人力都在白烧。这篇东西适合谁看写过业务代码、被需求变更折腾过、被线上数据不一致坑过的人看了会有共鸣。刚入行的也能看但我不会把三个原则讲成教科书定义而是讲它们在真实项目里长什么样、什么时候会骗你、怎么用才不亏。1.1 它们各自负责哪一段别混着用单一职责原则最容易被误读成“一个类只能有一个方法”或者“一个函数只干一件事”。真按这个标准写代码会碎成一地渣。它原本想说的是一个模块应该只有一个引起它变化的原因。注意是“变化的原因”不是“功能的个数”。一个类里放了十个方法如果这十个方法都随着同一条业务规则变那它依然符合单一职责反过来一个类只有两个方法一个随营销规则变、一个随财务规则变那它就该拆。第一性原则的用法完全不同。它不关心模块怎么分它关心的是你凭什么相信这个结论。别人说要用消息队列削峰你先别急着抄先问峰值到底多高、持续多久、下游能扛多少、丢了能不能补。把这些最基本的事实摆出来方案往往是推出来的不是选出来的。ACID原则则是另一套语言。原子性说的是要么全成要么全不成一致性说的是约束不能被破坏隔离性说的是并发之间别互相偷看持久性说的是提交了就别丢。这四个字在单库事务里是数据库替你兜底一旦跨服务、跨库、跨存储兜底的人就变成你自己了。注意这三条原则不是并列关系也不能互相替代。用单一职责去解释分布式事务或者用第一性原则去论证“这个类该不该拆”都是拿锤子拧螺丝。1.2 安全、学习、节约三个很土但很好用的尺子我评价一个设计好不好习惯用三把尺子量。安全这把尺子量的是出错的时候损失会不会被限制在一个小范围里。职责边界清楚一个模块被攻破或者出 bug影响面就是可控的事务边界清楚数据不会出现“扣了库存没生成订单”这种脏状态。学习这把尺子量的是一个新同事接手需要在大脑里同时装下多少东西。拆得太粗他得读懂八百行才知道改哪拆得太细他得跳二十个文件才能串起一条链路。这两个极端都费脑子。节约这把尺子最容易被理解成“少写代码”其实不是。真正省的是变更成本——需求改一次你要动几个文件、跑几轮回归、惊动几个团队。代码行数少但耦合在一起改一行炸一片那是最贵的。这三把尺子经常打架。拆细了安全性和可学习性上去了但调用链变长运行时的开销和排查成本上去了事务范围开大了一致性稳了但锁竞争和响应时间上去了。所以下面每一节我都会把这三把尺子摆出来讲清楚我在具体场景里是怎么权衡的。2. 单一职责原则边界划在“变化的原因”上不是划在代码行数上2.1 判断职责该不该拆我只看三个信号第一个信号是变更频率不同步。两个逻辑写在同一个类里一个每周改三次一个半年不动那它们就该分开。因为高频变更的部分会不断把低频部分拖进回归测试范围改一次测一片这就是在烧钱。第二个信号是依赖方向不一致。比如订单服务里同时依赖了库存客户端和短信客户端前者是核心链路后者是通知链路。短信通道换了供应商凭什么要让订单核心逻辑跟着发版这种依赖方向不同的东西捆在一起早晚出事。第三个信号是失败影响不对等。参数校验失败应该立刻返回库存扣减失败要回滚发短信失败顶多重试。三种失败的严重程度差了一个量级却写在同一个 try 块里最后只能用一个笼统的异常把真实原因盖掉排查的时候全靠猜。反过来什么情况下不该拆我见过把“计算订单金额”拆成“计算商品金额”“计算运费”“计算优惠”“汇总”四个类每个类二十行然后一个 Orchestrator 类把它们串起来。看着很规整实际改一个优惠规则要动四个文件。这就是典型的拆过头——它们共享同一套变化原因本就不该分家。2.2 一个订单模块的拆分实例拿一个常见的下单流程举例。最初版本长这样public class OrderService { public OrderResult createOrder(CreateOrderRequest req) { // 1. 参数校验 // 2. 查询商品与价格 // 3. 计算优惠 // 4. 扣减库存 // 5. 生成订单落库 // 6. 发送通知 // 7. 写操作日志 return result; } }这个类的问题不是“方法太多”而是它同时被五条不同的业务线牵着走商品团队改价格策略、营销团队改优惠规则、仓储团队改扣减方式、运营团队改通知模板、风控团队要加日志字段。任何一条线动一下这个类就得发一次版。我的拆法是按“变化原因”切而不是按“步骤”切拆分出的模块变化原因依赖方向参数校验器接口协议变更只依赖请求对象价格与优惠计算器营销规则变更依赖商品与优惠配置库存扣减器仓储策略变更依赖库存服务订单持久化表结构变更依赖数据库通知发送器渠道与模板变更依赖消息通道拆完之后营销规则一周改三次也只影响计算器那一个模块回归范围从“整个下单链路”缩到“金额计算单测”。这就是节约——省的不是代码量是每次变更要跑的测试和要冒的风险。2.3 安全视角职责边界就是信任边界这一点很多人没意识到。单一职责划出来的边界天然就是权限和信任的边界。参数校验器是唯一接触外部原始输入的地方那它就应该承担全部的白名单校验、长度限制、类型收敛后面的计算器和持久化层可以假定输入已经是干净的。这样一来攻击面就被压缩在一个很小的模块里审计的时候只看一个文件就够了。反过来如果校验逻辑散落在五个地方每个地方都做一半那攻击者只要找到那个漏做校验的入口就行。我见过最典型的情况是接口层校验了手机号格式内部服务直接拿这个字段拼 SQL中间某个内部调用绕过接口层注入就进去了。职责没划清安全边界就是虚的。注意内部服务之间调用不要默认“上游一定校验过”。信任边界要显式声明最好在模块入口处再收一次成本和风险比起来可以忽略。2.4 学习与节约拆分的收益要看“谁在读”拆分对学习的影响是双向的。对改这个模块的人范围小了看得快对要理解整条链路的人跳转多了串起来费劲。所以我的做法是核心链路上保留一个薄薄的编排层把顺序和事务边界写清楚二三十行一眼能看完具体逻辑藏到各自模块里。新人先读编排层建立全局观再按需下钻。这个编排层不承载业务规则只承载流程所以它也不会频繁变更。节约这块要算笔账。假设一个八百行的类每次需求变更平均要读两百行、跑三十分钟回归。拆成五个模块后平均每次只读五十行、跑十分钟回归。按一周五次变更算一年省下来的时间是很可观的。但如果拆成二十个模块光是找文件、理依赖就要花十五分钟那就拆亏了。拆分的粒度应该停在“变更影响范围刚好收敛”的位置再往下拆就是负收益。3. 第一性原则把“别人都这么干”还原成几条硬约束3.1 拆到不能再拆剩下的才是真约束用第一性原则分析问题我的做法是先列一张“事实清单”把观点、习惯、行业惯例全部剔掉只留可验证的量。举个技术选型的例子某个内部系统要选缓存方案讨论的时候有人说“上分布式缓存”理由是“数据量大”。我把问题拆开数据总量多少算下来热数据大概两个 GB。读写的比例是多少读远大于写大概二十比一。能容忍多长时间的脏数据业务方说最多三秒。单机内存够不够够而且有富余。挂了会不会影响主流程会降级但能接受。拆完这几条结论其实很清楚两个 GB 的热数据单机内存完全放得下读多写少容忍三秒延迟。那最合适的方案可能就是本机缓存加一个短周期的失效通知而不是引入一套分布式缓存集群。后者要多维护一套组件、多一条网络链路、多一堆一致性问题的排查成本。这就是第一性原则的价值它把“选型”变成了“推导”。推导出来的方案未必是最时髦的但每一条都能对得上一个具体约束出了问题也知道该改哪一条。3.2 伪第一性从结论反推理由的陷阱有一种很常见的假拆解我管它叫伪第一性。表现形式是先有了想要的结论再倒着编几条“基本事实”去支撑它。比如先决定要用某个新技术然后说“我们的场景需要高吞吐”“我们的团队需要技术升级”这些话说出来都没法验证纯属自我说服。识别方法很简单看这些“事实”能不能被证伪。“需要高吞吐”不能证伪因为没给数字“峰值每秒八千次写入持续四十秒”可以证伪去压测一下就知道了。所以在拆解阶段凡是不能落到具体数字或者具体行为上的描述我都会打回去重写。还有一种更隐蔽的伪第一性把“业界最佳实践”当成基本事实。最佳实践是别人在别人的约束下推出来的结论你的约束不一样结论就不该一样。抄之前先问一句他那边的约束是什么我这边一样吗3.3 和单一职责原则的互相校正这两个原则配合起来用效果最好。第一性原则负责判断这件事该不该存在单一职责负责判断它该放在哪。举个例子。某系统要做实时风控拦截最初的方案是在下单接口里同步调风控服务。用第一性原则拆风控的判定需要多长的数据窗口答案是近三十天的行为数据这个计算本身就是异步的同步调用只能拿到一个预计算的结果。那同步调用的意义是什么只是为了“拦截”这个动作发生在下单链路上。再往下拆拦截必须在多长时间内生效业务方说可以接受延迟几秒。拆到这里结论就变了不需要同步强依赖改成异步判定加事后拦截链路稳定性反而更好。然后进入单一职责环节风控判定逻辑独立成一个模块下单链路只保留一个“查询拦截状态”的薄接口两者通过状态表解耦。第一性原则砍掉了不必要的耦合单一职责原则把剩下的部分摆到了正确的位置。3.4 学习视角拆解过程本身就是最好的文档我带新人的时候有个习惯让他把某个模块的设计用第一性原则重推一遍写在注释或者设计文档里。这个过程特别值钱因为推导过程记录了当时为什么排除了其他方案。半年后有人想改这块看到的不只是“现在长这样”还有“当年试过那样因为某某约束被否了”。这种文档比任何架构图都耐用。架构图会过期推导记录里的约束条件往往几年都不变。而且写推导的过程会倒逼作者把模糊的地方想清楚很多设计缺陷是在写“为什么不用另一个方案”的时候才暴露的。4. ACID原则事务边界不是数据库的私事4.1 四个字母背后的真实代价ACID这四个字在单库事务里是免费的数据库引擎替你扛了。但免费是有前提的所有操作在同一个连接、同一个库、同一套存储引擎里。一旦跨出去代价就来了。原子性要靠回滚回滚要靠补偿补偿就要考虑幂等和失败重试。一致性要靠约束跨库之后约束没法由数据库统一执行只能靠应用层校验加对账。隔离性要靠锁或者多版本跨服务之后锁的粒度、持有时间、死锁检测全得自己设计。持久性要靠落盘跨存储之后要确认每一段都真的落了而不是“调用了就算成功”。我在实际项目里的经验是能在一个事务里解决的事绝不要拆到两个服务里。很多人为了“解耦”把一个完整事务拆成两个服务调用结果一致性靠消息、补偿靠定时任务、排查靠日志对齐最后维护成本比原来高好几倍。解耦的前提是这两段逻辑本来就没有原子性要求而不是为了架构好看硬拆。4.2 隔离级别的选择从业务损失倒推隔离级别这个参数很多人是抄默认值。我习惯从“错了会损失什么”倒推。隔离级别可能出现的现象适合的场景主要代价读未提交读到别人没提交的数据几乎不用数据不可信读已提交同一事务内两次读结果不同大部分查询类业务不可重复读可重复读范围查询可能多出或少掉行账务、库存核对幻读、间隙锁串行化并发度极低极少数强一致场景吞吐骤降倒推的逻辑是这样的如果业务能接受“同一笔对账两次读到的金额略有差异”那读已提交就够了如果这个差异会导致对账不平、需要人工介入那就得往上提。往上提的代价是锁竞争锁竞争会影响响应时间响应时间又会影响用户体验这条链要算清楚。我见过一个反例某查询接口为了“绝对准确”用了串行化结果高峰期大量请求排队超时率飙升最后靠加机器硬扛。其实那个场景读已提交完全够用误差在业务容忍范围内。隔离级别不是越高越安全是越高越贵贵到一定程度会反过来制造新的不安全——超时和雪崩也是事故。4.3 分布式场景下的妥协与补偿跨服务之后严格意义上的ACID基本做不到能做的是“最终一致加可对账”。我的做法分三步。第一步把强一致的范围压到最小。比如下单场景库存扣减和订单创建必须原子那就让它们待在同一个库的同一个事务里通知、积分、报表这些可以异步走消息允许延迟。第二步给每一段异步都设计补偿路径。消息可能丢那就加本地消息表消费可能重复那就做幂等键补偿可能失败那就进死信队列人工兜底。这几样东西听着土但真出事的时候能救命。第三步留一条对账通道。每天跑一次全量或增量核对把不一致的数据捞出来。对账不是为了实时修是为了知道“现在到底有没有问题”没有这条通道你连自己是不是安全的都不知道。-- 本地消息表的典型结构保证业务写入和消息发送在同一事务 CREATE TABLE local_message ( id BIGINT PRIMARY KEY, biz_type VARCHAR(32) NOT NULL, biz_id VARCHAR(64) NOT NULL, payload TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待发送 1已发送 2已消费 retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_biz (biz_type, biz_id) );这张表的关键在于唯一键。业务写入和消息记录在同一个事务里提交提交成功就意味着“消息一定会被发出去”发送失败靠定时任务扫表重试。代价是多一次写库收益是消息不会凭空消失。4.4 安全视角事务边界也是权限边界这点经常被忽略。事务边界划在哪里往往决定了哪个服务能直接改数据。如果一个服务的事务范围覆盖了库存表那它就有库存表的写权限如果它只是发消息那它只需要消息队列的写权限。权限最小化的前提是事务边界清晰。我见过一个反面案例某个后台服务为了“方便”拿到了核心库的写权限平时只读偶尔手工修数据。后来这个服务的凭据泄露攻击者直接改了账务数据。如果当初把它的权限限制在只读加一个受控的修复接口损失会小得多。这不是安全团队的锅是设计阶段事务边界没划清楚导致的。5. 三条原则合流安全、学习、节约的落地清单5.1 安全把风险关进职责边界里安全这件事靠的不是加多少层防护而是每一个边界都清楚地知道自己该拒绝什么。单一职责给出模块边界ACID给出数据边界第一性原则帮你识别真正的风险源在哪。我一直用一个很朴素的方法做检查画出数据从进入到落库的完整路径在每一个跨边界的地方标上“这里校验什么、拒绝什么、失败后怎么处理”。标不出来的地方就是漏洞。这个方法不需要什么安全工具一张纸一支笔就能做但比很多扫描报告管用。另外我在事务设计上有一条硬规矩凡是涉及金额、库存、额度的写操作必须能回答“这条数据是怎么变成现在这样的”。要么有操作流水要么有版本号要么有对账记录。回答不了就意味着出了问题无法追溯这在安全上等同于没有防护。5.2 学习把知识切成能独立验证的小块学习效率高不高很大程度上取决于知识块切得好不好。这跟单一职责是一个道理一个知识块应该只有一个“为什么”你能独立验证它才算真的学会了。比如学一个框架别一上来就通读文档。先用第一性原则问这个框架解决的核心问题是什么在没有它的时候人们怎么做它的方案为什么更优。拆出这三条再去读对应章节效率完全不一样。读完一小块就动手验证一下能跑通再往下走。这个“小块加验证”的节奏和我拆模块的节奏是一样的。带团队的时候我也用这个方法。新人接手一个模块我不给全量文档给三个问题这个模块的输入输出是什么它依赖谁、谁依赖它它在什么情况下会失败。这三个问题答完基本就能上手改代码了。剩下的细节在改的过程中自然补齐比通读一遍文档记得牢。5.3 节约省的是变更成本不是代码行数最后说说省。很多人把节约理解成“少写代码”“少加组件”我觉得方向偏了。真正该省的是每次变更要付出的总代价包括读代码的时间、跑测试的时间、跨团队沟通的时间、出事故的风险成本。按这个标准重新看前面几个决策拆模块省的是回归范围虽然多写了几个文件但总代价下降了。拒绝引入分布式缓存省的是运维和排查成本虽然本机缓存方案听起来不够“高级”。把异步链路加上本地消息表和对账多写了代码但省下了“数据不一致时通宵排查”的成本。所以节约不是一味做减法是把成本花在能被复用的地方。清晰的边界、可追溯的流水、能对账的通道这些前期投入会在后面每一次变更里持续回本。反过来为了省事把逻辑堆在一起省的是今天的两小时赔的是未来两年的维护费。6. 常见问题与排查实录6.1 职责拆分上的典型误判现象常见误判我的处理方式类行数超过五百直接拆成多个类先看变更原因同源的不拆一个方法调了五个服务认定违反单一职责看这些调用是否共享同一失败处理策略是则可保留编排层工具类越来越多认为拆分到位了检查是否有工具类只被一个模块使用是则收回去接口参数只有一个对象认为设计过重看这个对象是否会被多个入口复用会则合理第一行那个误判我踩过。早年看到大类就拆拆完发现几个类永远一起改提交记录里总是成对出现。后来学乖了先看 git 提交历史如果两个文件的修改总是同进同出那它们本来就该在一起。6.2 第一性推导卡壳怎么办卡壳通常卡在两个地方。一个是事实拿不到比如不知道真实峰值、不知道下游容量。这时候不要硬推去做一次压测或者埋点采样拿到数字再推。凭感觉估出来的“大概几千 QPS”推出来的方案一定是错的。另一个是约束之间互相矛盾比如既要强一致又要低延迟还要低成本。这时候要做的是排序问清楚哪个是硬约束、哪个可以让步。排序的过程往往比推导本身更重要因为很多争论本质上是大家对优先级的理解不一样。6.3 事务相关的坑我踩过的几个第一个坑是在事务里做远程调用。事务持有锁的时间被网络耗时撑大并发一上来就大面积等待。正确做法是把远程调用挪到事务外面先调再开事务或者先开事务再异步调。第二个坑是以为消息发送成功就万事大吉。消息发出去和消息被消费是两回事中间可能丢、可能重复、可能顺序乱。所以消费端必须幂等这个不能省。第三个坑是补偿逻辑没做幂等。回滚操作被执行两次库存加回去了两次账就错了。补偿和正向操作一样都要有唯一键或者状态机保护。注意任何跨边界的数据操作都要问三个问题——失败了怎么办、重复执行会怎样、怎么知道它到底成没成。这三个问题答不上来就不要上线。6.4 一个真实排查过程的还原某次线上出现少量订单金额和明细对不上比例大概是万分之一。第一反应是并发问题查了隔离级别是可重复读理论上不该出现。用第一性原则重新拆金额是汇总算出来的还是逐项累加的答案是逐项累加。累加过程中有没有可能读到中间状态有因为明细是先插入再汇总两步不在同一个事务里。再看代码果然明细插入用了批量提交汇总在另一段逻辑里做。批量提交到一半失败重试时只补了失败的批次汇总却按全量算了一遍。问题定位到之后改法是让明细和汇总在同一个事务里或者汇总改成基于明细的实时计算不再单独存一份。这个案例里单一职责告诉我们明细和汇总该不该分它们共享同一套一致性要求不该分ACID告诉我们边界该划在哪同一个事务第一性原则帮我们定位到“两步不在同一事务”这个根因。三条原则各司其职缺一条都得多绕几圈。我个人在实际操作中的体会是原则这东西的价值不在于背得熟而在于遇到具体问题时能立刻反应过来该用哪一条。单一职责帮你问“这东西为什么会变”第一性原则帮你问“这个结论凭什么成立”ACID帮你问“数据错了谁来兜”。三个问题问完大部分设计上的犹豫基本就消散了。至于安全、学习、节约它们不是额外的目标而是你把这三个问题答对之后自然拿到的结果。
阅读完成 · 觉得有帮助?
咨询建站