单体应用变慢、发布排队、一个模块的缺陷拖垮整站,这三件事常常同时出现,于是团队开始讨论微服务。但真正的问题很少是“要不要微服务”,而是“按什么顺序拆、用什么信号判断该拆、拆完以后靠什么兜底”。把这三个问题回答清楚,微服务才是一项工程决策;回答不清楚,它就只是一次昂贵的技术风格迁移。
拆分不是免费的重构。它把进程内的函数调用换成网络调用,把编译期就能发现的类型错误换成运行时的接口约定问题,把一次数据库事务换成若干次不可回滚的尝试。理解这笔账,才知道第一刀应该切在哪里。
信息边界:本次运营未附带具体站内讨论样本,因此下文不引用任何具体帖子,也不虚构社区案例;涉及社区现象的表述均为一般性归纳,具体机制与取舍读者可自行对照站内案例验证。
先给结论
优先拆“变更频率差异最大、故障影响面最需要隔离、且能清晰划定数据所有权”的模块;不要按 controller、service、dao 这类技术分层去拆,也不要为了“看起来像微服务”而拆。
核心判断:拆分的收益来自独立变更和独立故障域,而不是来自服务数量。如果两个模块永远一起改、一起发布、共享同一批表,把它们拆开只会多出一次网络调用和一整套新的故障点。
这个结论有明确的适用边界。团队规模小、发布频率低、单库压力尚未成为瓶颈时,模块化单体往往更划算:用多模块工程和依赖约束把边界划清楚,成本远低于维护一套注册中心、网关和链路追踪。反过来,当某个模块的扩容需求、合规要求或故障隔离要求已经无法在同一个进程里满足时,继续“忍着不拆”也会付出代价。
判断“变更频率差异”不能凭感觉,要看可验证的材料:近半年各模块的提交记录、发布单、故障归属记录。如果两个模块在发布单上几乎从不分离出现,那它们大概率还不该被拆开。
拆分真正付出的三笔账
数据所有权:从一次 JOIN 变成两次远程调用
最典型的场景是订单详情页。在单体里,一条 SQL 关联用户表、商品表、优惠券表就能拼出页面;拆成订单服务、用户服务、商品服务之后,要么并行发起多次调用再聚合,要么在订单库里冗余一份商品快照。前者带来延迟叠加和部分失败的处理,后者要面对快照过期和字段口径不一致。
更隐蔽的问题是“谁拥有这张表”。一张表只要有多个写入方,约束、索引和事务边界就会互相打架。拆分前必须回答:这张表的唯一写入方是谁,其他服务只能读还是只能通过接口拿数据。回答不了这个问题,就不具备拆分的条件。
故障传播:同步调用链是最长的雪球
假设 A 调用 B、B 调用 C、C 调用 D,每一层可用性都是 99.9%,串起来大约是 99.6%。这还没算上重试放大:如果每一层在超时后重试两次,一次入口请求在最坏情况下会放大成八次下游请求,本来只是慢,最后变成被打垮。
所以拆分必须和超时预算、重试上限、熔断降级一起做。Sentinel、Resilience4j 这类组件解决的不是“要不要熔断”,而是“熔断之后返回什么”:是返回兜底数据、返回降级提示,还是快速失败让上游自己处理。这个问题在单体里根本不存在,拆分之后必须提前回答。
排障:从一次 grep 到一条完整链路
单体里排查问题,看一个线程栈、翻一个日志文件基本就能定位。拆开之后,一次请求横跨四五个进程,日志散落在不同机器上,没有统一的 traceId 就只能靠时间戳猜。SkyWalking、Jaeger、Zipkin 这类链路追踪,配合集中式日志,是拆分的前置条件而不是后续优化。
先拆后补可观测性,团队会经历一段“黑盒期”:线上报错但不知道卡在哪一跳,只能逐个服务加日志再发版。这段时间的排障成本,往往比拆分本身省下的时间还多。
该不该拆:五个可验证的信号
| 信号 | 怎么验证 | 反例 |
|---|---|---|
| 变更频率差异 | 近半年提交记录与发布单 | 两个模块总是一起发版 |
| 负载特征差异 | CPU、内存、IO 的 profile 数据 | 只是代码行数多,资源曲线一致 |
| 故障影响面 | 故障归属与波及业务清单 | 故障本来就只影响一个页面 |
| 数据所有权 | 是否存在只被单一模块写入的表组 | 多服务共写同一批表 |
| 合规与隔离 | 审计、脱敏、权限的硬性要求 | 只是“感觉支付应该独立” |
按技术分层拆分是最常见的误区:把所有 controller 放进一个服务、所有 service 放进另一个服务。这样做服务数量上去了,但每一次业务变更仍然要同时改好几个服务,独立变更的收益为零,网络调用和部署复杂度却实打实增加了。
另一个误区是用拆服务来解决团队沟通问题。康威定律确实说明系统结构会向组织结构靠拢,但服务边界不是组织问题的解药:如果两个小组职责本来就重叠,拆成两个服务只会让接口约定变成跨团队谈判,问题从代码里挪到了会议里。
拆分顺序:先修跑道,再起飞
流程图
渲染中...
这个顺序不是偏好,而是由风险决定的。可观测性必须先行,因为拆开之后你会失去单机视角;限流熔断必须先行,因为调用链变长后雪崩概率上升;只读服务优先,因为它不改数据、回滚成本低,天然适合当拆分的灰度试验田。
落地时常用绞杀者模式:新服务先上线,在网关层按路由把 1%、10%、50% 的流量逐步切过去,观察错误率和 P99 延迟,异常就切回。API 网关在这里承担的是流量开关的角色,Spring Cloud Gateway、Nginx、Envoy 都能做,关键是切流比例和回滚动作要提前演练,而不是出事时临时改配置。
数据一致性:三种做法与代价
| 方案 | 一致性 | 主要代价 | 适用场景 |
|---|---|---|---|
| 共享库 + 本地事务 | 强一致 | 服务耦合,拆分收益打折 | 过渡期、边界尚未清晰 |
| Seata 类分布式事务 | 较强 | 补偿逻辑与运维成本 | 少量必须原子完成的写操作 |
| 本地消息表或事务消息 | 最终一致 | 需要幂等与对账 | 订单、库存、通知等主链路 |
选择的关键是先划出“必须强一致的最小集合”。绝大多数跨服务写操作并不需要分布式事务,只需要最终一致加上可核对的对账机制。把所有跨服务调用都塞进一个全局事务,结果是每个服务都被事务框架绑住,性能和维护成本一起上升。
最终一致方案必须配两样东西:幂等键和对账任务。幂等键可以用分布式 ID 生成,也可以用业务唯一索引兜底;状态机幂等则要求“已支付”状态不能被重复推进。支付回调、消息重投、用户重复点击,本质上都是同一个问题:同一次业务意图被执行了多次,系统要能识别出来。
不拆的选择:模块化单体
模块化单体不是“落后的妥协”,而是一种有明确边界的工程选择。Java 侧可以用多模块工程加架构测试工具约束包之间的依赖方向,Go 侧可以用 internal 包限制可见性,Python 侧可以用分层包加导入检查。核心是让越界依赖在构建期就失败,而不是靠代码评审时提醒。
什么情况下应该“拆了再合”?如果两个服务长期同步发版、共享同一批表、故障总是同时出现,那它们实际上还是一个模块,只是被网络隔开了。合回去不是倒退,而是承认此前的拆分信号判断错了。判断标准很简单:过去半年里,这两个服务有没有过一次真正独立的发布。
实践建议
- 统计近半年各模块的变更频率、发布单和故障归属,形成拆分候选清单
- 为所有入口请求注入
traceId,日志集中检索,先补齐可观测性 - 为每个跨服务调用设置超时、重试上限和熔断策略,明确降级返回值
- 逐表确认唯一写入方,把“多服务共写”的表列为拆分阻塞项
- 第一个候选服务优先选只读或边缘服务,用网关按比例灰度切流
- 为跨服务写操作设计幂等键,并上线对账任务与告警
- 每次切流前准备好回滚预案和观测指标:错误率、P99、消息积压
这套清单不适用于一种情况:业务本身还在高速试错期,领域边界一周一变。此时更该做的是把单体内部的模块边界划清楚,等变更频率稳定下来再谈拆分,否则拆出来的服务边界会跟着业务一起反复重画。
总结与讨论
第一,拆分的判断依据是可验证的信号,不是技术潮流:变更频率、负载特征、故障影响面、数据所有权,四个维度里至少有两个成立才值得动手。第二,拆分顺序比拆分本身更重要:可观测性和限流熔断是前置条件,只读服务是最安全的试验田。第三,数据一致性要划出最小强一致集合,其余交给幂等、消息和对账。
最后一个问题留给读者:你们团队判断一个模块该不该拆,用的是哪个可验证的信号?有没有拆完之后又合回去的例子,当时的触发点是什么?