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帮你问“数据错了谁来兜”。三个问题问完,大部分设计上的犹豫基本就消散了。至于安全、学习、节约,它们不是额外的目标,而是你把这三个问题答对之后自然拿到的结果。