微服务讨论里最容易吵起来的问题,往往不是“用 Nacos 还是 Consul”,而是“这个模块到底该不该单独拆一个服务”。拆得粗,团队抱怨耦合、发布互相阻塞;拆得细,分布式事务、缓存一致性、链路排查的成本又一起涌上来。最后大家发现,真正决定架构好不好维护的,不是服务数量,而是边界画在了哪里。
需要说明信息边界:本次运营提供的站内参考资料为空,因此本文不引用具体社区帖子,只围绕主题关键词涉及的通用工程机制展开,文中场景均为示意性描述,不代表任何真实团队的数据或结论。 在这个前提下,本文想回答一个具体问题:当“拆”与“不拆”都能跑通业务时,我们应该用什么标准做判断?
先给结论:边界跟着“必须原子一致的数据”走
一句话结论:服务边界应优先按“必须原子一致的数据集合”划分,而不是按业务名词、数据表或团队人数划分。 换句话说,先问“哪些写操作必须在同一个事务里成功或失败”,答案圈出来的那块数据,通常就是一个服务的合理内核。
这个结论有明确的适用边界。它适用于以事务性写入为主、对数据正确性敏感的系统,比如订单、库存、账务、额度。对于读多写少、天然可最终一致的场景(内容检索、推荐、报表),按查询模型拆分反而更划算,此时一致性边界可以放宽,用事件和读模型来兜。
核心判断:拆分前先画“写依赖图”,凡是需要跨图强一致的边,都是未来的分布式事务成本;能通过合并服务消掉的边,比任何补偿框架都便宜。
问题是怎么形成的:三种常见的边界误判
第一种是按名词拆。看到“用户”“订单”“商品”“支付”这些词,就一个词一个服务。问题在于,名词是组织语言的方式,不是数据约束的方式。下单时扣库存、写订单、加积分,如果被拆到三个服务,一次用户点击就变成一条跨三个进程的写链路,任何一环超时都要面对“钱扣了但订单没生成”这类问题。
第二种是按表拆。谁的表谁负责,听起来很干净,但表之间的关系恰恰是事务关系。一个典型的坑是把“账户余额”和“流水明细”拆到两个服务:余额更新成功、流水写入失败,对账时就会出现无法自解释的差额。反过来,如果这两张表本来就必须同生共死,它们从一开始就应该待在同一个服务、同一个本地事务里。
第三种是按团队拆,也就是常说的康威定律式拆分。它解决了发布协作问题,却可能制造数据问题:团队边界稳定,但数据依赖是动态的。组织调整一次,服务边界就要跟着动一次,这种架构的长期维护成本往往被低估。
| 拆分依据 | 短期收益 | 长期代价 | 更适合的场景 |
|---|---|---|---|
| 业务名词 | 命名直观,容易讲清楚 | 跨服务写链路多,补偿逻辑复杂 | 读多写少、弱一致 |
| 数据表归属 | 职责清晰,权限好管 | 事务被切断,对账困难 | 数据本身低耦合 |
| 团队组织 | 发布独立,协作顺畅 | 组织一变架构就晃 | 边界相对稳定的中台 |
| 一致性边界 | 事务简单,故障面小 | 单服务体积偏大 | 交易、账务、库存 |
机制拆解:一致性成本到底从哪来
跨服务写操作的成本,本质上是“无法回滚”带来的。单库本地事务靠数据库的原子性和隔离级别兜底,失败就整体回滚;一旦跨进程,就没有一个共同的回滚点,只能靠补偿、重试、幂等和对账把状态慢慢拉回一致。这不是框架能力问题,而是分布式系统的基本约束。
流程图
渲染中...
上图里每多一个节点,就多一类“部分成功”状态需要处理。于是你会被迫补上三样东西:幂等(同一请求重复到达不能重复扣款)、补偿(失败后怎么冲正)、可观测性(出问题时能不能定位到哪一步)。这三样东西本身不难,难的是它们要在每条跨服务链路上都做对,而且要在业务迭代中持续维护。
再看缓存。把热点数据放进 Redis 或 Caffeine 之后,一致性问题从“数据库事务”变成了“缓存与库的时序”。本地缓存尤其要注意:多实例部署时,每个 JVM 各有一份副本,更新只能靠过期时间或广播失效,期间读到旧值是常态。这不是 bug,而是选择本地缓存时必须接受的前提。
方案与取舍:先比“能不能不拆”,再比框架
真正有效的选型顺序是:先看能不能通过合并服务把跨服务事务变成本地事务;如果不能,再在“最终一致”和“强一致”之间做选择。很多团队直接跳到选 Seata 还是选消息队列,跳过了最便宜的那一步。
| 方案 | 一致性 | 实现成本 | 主要风险 | 适用场景 |
|---|---|---|---|---|
| 合并服务 + 本地事务 | 强一致 | 最低 | 服务体积变大 | 写操作强耦合的核心域 |
| 本地消息表 / Outbox + MQ | 最终一致 | 中 | 重复消费、消息积压 | 通知、积分、统计 |
| Seata AT | 较强一致 | 中 | 全局锁、回滚依赖 SQL | 短事务、低冲突 |
| TCC | 较强一致 | 高 | 三个接口都要幂等 | 资金、额度类 |
| Saga | 最终一致 | 高 | 补偿逻辑要写全 | 长流程、跨多服务 |
这张表最值得记住的一行是第一行。能用“把两个服务合并”解决的问题,不要用分布式事务解决,因为前者是一次性的重构成本,后者是长期的运行与维护成本。第二值得记住的是:Saga 和 TCC 的难点不在框架接入,而在于你有没有能力把每个补偿动作写正确、写幂等、并且在补偿失败时有人工兜底通道。
以 Kafka、RocketMQ 这类消息中间件做最终一致时,真正的关键点是“业务写库”和“消息发出”这两件事不能分裂。常见做法是把消息先写进业务库的 outbox 表,和业务数据同一个本地事务提交,再由独立投递进程扫描发送。这样做的代价是多一张表和一个投递器,收益是把“消息丢失”从不可控变成可对账。
SQL
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_key VARCHAR(64) NOT NULL COMMENT '业务幂等键',
topic VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待投递 1已投递',
retry_count INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_event_key (event_key)
);
实践建议:把拆分做成一次可验证的推演
与其在评审会上争论,不如把拆分做成一次能落地的推演。下面这份清单可以按顺序执行,每一步都产出可检查的中间物,而不是停留在感觉层面。
- 列出所有写操作,标注每次写涉及的表,形成“写依赖图”。
- 标出必须原子一致的写集合,这些集合是服务的内核,不要切开。
- 对剩下的跨集合调用,明确它是“必须立刻一致”还是“可以秒级/分钟级一致”。
- 为每条跨服务链路定义幂等键,并写进接口契约与数据库唯一索引。
- 给每条链路配一条可观测信号:耗时、失败率、积压量、对账差异数。
- 预设回滚或降级方案,明确什么情况下关掉异步链路保主流程。
判断标准也很直接:如果一次跨服务调用失败后,你能用一句话说清“数据现在处于什么状态、由谁负责修复”,这条链路就是可接受的;如果说不清,说明边界画错了,或者补偿逻辑还没设计完。
分阶段落地会更稳。第一阶段只做“合并与收敛”,把强耦合的写操作收回同一个服务,先把本地事务用好;第二阶段再对真正需要解耦的链路引入事件和 Outbox,配合重试与死信队列;第三阶段才考虑 Seata 这类强一致框架,而且优先用在冲突低、事务短的场景。顺序反过来,通常意味着你在用最贵的工具解决最便宜的问题。
总结与讨论
回到最初的问题:微服务该拆多细?答案不是一个数字,而是一条判断规则——先找一致性边界,再谈服务粒度;能用本地事务解决的,不要用分布式事务解决;每一条跨服务链路都要能说清失败后的状态归属。 这三句话,比任何一张技术选型表都更能决定系统三年后的可维护性。
同时要承认,边界不是一次画对的。业务在变,今天可以最终一致的链路,明天可能变成强一致要求;今天的合并服务,也可能因为团队和流量增长必须再拆。所以更现实的做法是:把边界当成需要持续验证的假设,用对账差异、补偿次数、链路失败率这些指标去检验它,而不是一次性设计完就锁死。
留一个开放问题:在你的系统里,有没有哪条跨服务链路,其实只要把两个服务合并就能彻底消掉补偿逻辑?当初拆开它的理由,是数据约束,还是组织结构?欢迎在评论区聊聊你的判断依据。