很多团队做微服务改造时,注意力集中在「怎么拆」:按领域拆、按团队拆、按变更频率拆,画完一张漂亮的服务依赖图,感觉架构升级已经完成。上线半年后回头看,却发现一个尴尬的事实:核心链路的 P99 延迟变高了,故障排查要跨四五个仓库翻日志,一个原本改三行代码的需求,现在要动两个服务、发两次版、还要考虑中间状态怎么回滚。拆分的收益是有的——独立发布、故障隔离、团队边界清晰——但成本也被严重低估了。
这篇文章想回答一个具体问题:微服务拆分之后,多出来的那部分成本到底是什么,能不能在拆之前就估算出来、在设计里偿还掉? 我把这部分成本统称为「一致性税」。它不是某个框架的锅,也不是引入 Seata 或 Kafka 就能自动消失的东西,而是分布式系统里数据从「一份、可原子修改」变成「多份、需要协调」之后必然产生的开销。
需要说明信息边界:本次任务提供的站内参考资料为空,因此下文不转述任何具体社区帖子,分析基于主题范围内通用的工程经验与公开技术常识,示例场景为示意性构造,不指向特定公司或系统。
先给结论
拆分本身不产生复杂度,数据所有权的分裂才产生复杂度。 一个服务如果只是把代码挪了个目录,仍然读写同一张表、同一个库,它带来的只有部署上的便利,没有真正的一致性成本;而一旦某个服务开始独占一份数据、其他服务只能通过接口或事件去改它,一致性税就开始计息了。
核心判断:微服务改造的正确顺序是「先算清一致性成本,再决定拆到哪一层」,而不是「先拆完,出问题了再上分布式事务框架补」。
这个结论有明确的适用边界。对于读多写少、业务规则简单、团队规模小的系统,模块化单体往往比微服务更划算;对于写链路本身就要跨多个业务主体、且对实时性要求极高的场景(比如资金账务),强一致方案依然不可替代,最终一致只能作为外围补充。判断标准不是「别人都拆了」,而是这条链路上一旦出现中间态,业务能不能接受、能接受多久。
一致性税是怎么产生的
在单体应用里,一致性几乎是免费的。订单表和库存表在同一个库,一次 BEGIN ... COMMIT 就能保证「扣库存」和「建订单」要么都成功、要么都失败,开发者甚至不需要意识到事务的存在。数据库引擎替你处理了隔离级别、锁、回滚日志这些复杂问题,代价只是本地磁盘 IO 和行锁竞争。
拆分之后,同一个业务动作被切成若干次远程调用。以典型的「下单扣库存」为例:订单服务创建订单,库存服务预占库存,支付服务做预授权。这三步分别在三个进程、三个数据库里完成,任何一步失败,前面成功的步骤都必须被撤销。而远程调用天然会超时——超时意味着「不知道对方到底做没做」,这比明确的失败更难处理,因为你既不能盲目重试,也不能直接放弃。
流程图
渲染中...
这张图里每一根箭头都是一次可能失败的网络调用。假设单个服务在高峰期有 99.9% 的可用性,一条串行依赖 5 个服务的链路,整体成功率大致是:
看起来只掉了 0.5 个百分点,但这是在每个服务都达到三个九的前提下。现实中很多内部服务的实际可用性远低于这个数字,链路上再叠加一次重试放大,尾部延迟会迅速恶化。这就是第一笔账:可用性与延迟的乘法效应。服务拆得越细,单次业务请求经过的跳数越多,尾部延迟越难控制。
第二笔账是可观测性。单体里一个异常栈就能定位问题,拆分后同一个请求散落在多个服务的日志里,没有统一的 trace id 就几乎无法排查。很多团队是在出过几次「订单显示已支付但库存没扣」的事故之后,才回头补链路追踪和日志规范。
第三笔账来自缓存和读模型。为了扛住读流量,通常会加 Redis 缓存、加 Elasticsearch 搜索索引,于是同一份业务数据出现了数据库、缓存、索引三个副本。副本越多,需要协调的写入点越多,原本只存在于服务之间的一致性问题,现在下沉到了服务和它自己的缓存之间。
三种偿还方式,代价完全不同
面对跨服务一致性,业界大致有三类做法,它们的取舍差异非常大,不能混着用。
| 方案 | 优点 | 代价 | 适用场景 |
|---|---|---|---|
| 强一致两阶段(Seata AT / TCC) | 业务语义直观,失败即回滚 | 全局锁、性能损耗、协调者单点 | 资金、库存等强约束短事务 |
| 可靠消息 + 最终一致 | 吞吐高、服务解耦、可削峰 | 需要本地消息表、幂等、重复消费处理 | 通知、积分、统计、异步下游 |
| Saga / 事件驱动补偿 | 长事务友好、无长期锁 | 补偿逻辑复杂、中间态对外可见 | 跨多主体的长流程 |
Seata 的 AT 模式对业务侵入小,它通过解析 SQL 生成前后镜像,在本地事务提交时记录 undo log,回滚时反向补偿。但它依然需要全局锁来保证写隔离,在高并发写同一行数据的场景下,全局锁竞争会明显拖慢吞吐。TCC 把控制权交给业务,性能更好,但空回滚、悬挂、幂等这三个坑几乎每个团队都会踩一遍:Try 阶段超时但实际到达了,Cancel 先于 Try 执行,或者 Cancel 被重复调用。这些不是框架 bug,而是分布式协调本身的固有难点。
相比之下,可靠消息 + 最终一致是大多数业务系统的默认选择。它的核心思路是:本地业务操作和「待发送消息」写在同一个本地事务里,再由后台任务投递消息,下游消费后自行保证幂等。RocketMQ 的事务消息、以及基于本地消息表的自研方案都属于这一类。代价是从「提交成功即一致」变成了「提交成功后若干毫秒到若干秒才一致」,这个时间窗口必须被业务方明确接受——如果产品经理不能接受「下单后 2 秒内积分还没到账」,那这个方案就不该被选中。
幂等与顺序:最容易被低估的部分
选了最终一致,就等于把「同一条消息可能被消费多次、可能乱序到达」写进了系统契约。很多线上事故不是出在事务框架上,而是出在下游没做幂等:重复消费导致库存被扣两次,或者旧状态的更新覆盖了新状态。
幂等的实现方式通常有三种:业务唯一键 + 数据库唯一索引、独立的去重表、以及基于状态机的条件更新。第一种最可靠,因为它把并发控制交给了数据库。下面这段代码的思路是:先用唯一索引抢占处理权,抢不到说明是重复请求,直接返回首次执行的结果,而不是抛错。
Java
try {
jdbc.update(INSERT_SQL, bizKey, Status.PROCESSING);
} catch (DuplicateKeyException e) {
return loadResult(bizKey);
}
这里有个容易忽略的细节:返回首次结果而不是返回「重复请求」错误,否则调用方会误判为失败并继续重试,形成重试风暴。另外,去重记录需要设置合理的过期时间,否则表会无限膨胀。
顺序问题同样隐蔽。Kafka 只保证单分区内有序,如果生产端按用户 id 哈希分区,同一用户的事件能保序;如果用了随机分区键,同一订单的「创建」和「取消」事件完全可能倒序到达。RocketMQ 提供了顺序消息,但代价是消费端串行处理、吞吐受限。更通用的兜底做法是在消费端用版本号或状态机做条件更新,让乱序到达的旧事件自然失效,而不是依赖消息中间件替你保序。
缓存与读模型:第二笔一致性税
即使服务之间的一致性处理干净了,服务内部还有一层:数据库和缓存之间。经典的「先更新数据库再删除缓存」在绝大多数场景下够用,但在「读请求回填缓存」和「写请求删缓存」并发时,仍可能出现旧值被写回。延迟双删、订阅 binlog 异步失效(类似 Canal 这类工具)都是常见加固手段,本质都是用时间窗口换确定性。
更进一步是 CQRS 式的读写分离:写模型只保证业务规则,读模型(搜索索引、聚合视图)通过事件异步构建。好处是读写各自优化,坏处是读模型永远滞后于写模型。用户刚下单就去搜索自己的订单却搜不到,这不是 bug,是架构决定的延迟。解决办法往往不在技术层,而在产品层——比如详情页读主库、列表页才走索引,或者给用户一个「数据同步中」的明确反馈。
落地建议:分阶段偿还,而不是一次性重构
第一阶段是先做模块化单体。在不拆进程的前提下,把代码按领域边界分包、明确每张表只有一个模块可以写、模块之间只通过接口调用。这一步几乎零风险,却能提前暴露「哪些边界其实是共享数据」的问题——很多看似独立的两块业务,拆开之后才发现共用同一张配置表。
第二阶段是先拆读、再拆写;先拆无状态、再拆有状态。把查询、报表、搜索这类只读链路先独立出去,风险最低、收益最直接;写链路涉及事务边界,留到边界稳定之后再动。
第三阶段是把可观测性当成基础设施而不是事后补丁。统一 trace id 贯穿网关、服务、消息消费,日志按 trace 聚合,关键链路埋上成功率与延迟指标。没有这层能力,最终一致方案出问题时你连「不一致发生了多久」都说不清。
拆分前可以用这份清单自查:
- 这条链路上如果出现 3 秒中间态,业务方是否书面确认可接受
- 每个服务是否独占其数据,没有跨库 JOIN、没有共享表
- 所有写接口是否都有幂等键,且唯一索引已建
- 消息消费失败是否有重试上限与死信队列,而不是无限重试
- 是否有跨服务的 trace id 与统一的错误码规范
- 缓存失效策略是否明确,是否存在「删缓存失败」的兜底路径
如果上面有几项答不上来,那大概率不是「该不该拆」的问题,而是「还没准备好拆」。
总结与讨论
回到最初的问题:微服务拆完为什么反而更慢?因为拆分把原本由数据库免费提供的原子性,变成了需要应用层显式设计、显式补偿、显式观测的东西。这笔一致性税无法消灭,只能选择用哪种方式偿还——强一致方案用性能和锁来换,最终一致方案用时间窗口和幂等复杂度来换。
三条可以直接带走的结论:第一,拆分决策应该由数据所有权驱动,而不是由代码目录驱动;第二,选最终一致之前,先和业务方确认中间态的时间上限,这是产品决策不是技术决策;第三,幂等、顺序、可观测性这三件事,必须在第一个跨服务写链路之前就位,而不是等事故来提醒。
最后留一个值得讨论的问题:在你们的系统里,那个「业务方勉强接受的中间态时间窗口」到底是多少秒?是拍脑袋定的,还是根据真实的下游处理耗时分布算出来的?欢迎在评论区聊聊你们是怎么把这个问题谈清楚的。