"按微服务拆开之后,发布反而更麻烦了"——这句话在拆完一年内的团队里出现频率很高。原本一次发布、一次回归,变成三个服务各自发版、接口要对齐、联调要排期。问题不在于"微服务错了",而在于拆分被当成了一次性动作,而不是一笔有前提条件的交易。前提不成立时,拆分只是把单体内部的耦合换成了跨网络的耦合,成本还多了一层。
下面拆两件事:拆之前要验的前提,和拆之后该不该合回去。
1. 拆分收益的四个前提
| 前提 | 检验问法 | 不成立的后果 |
|---|---|---|
| 独立变更频率 | 这块代码的改动是否自成节奏 | 每次需求都要跨服务改,发布次数×N |
| 独立扩缩需求 | 资源消耗是否与别人差一个量级 | 拆完仍同比例扩容,省不下机器 |
| 故障隔离价值 | 它挂了是否必须不影响别人 | 隔离无收益,只增加调用链长度 |
| 团队自治 | 是否真有独立团队、独立 backlog | 组织没拆,服务先拆,协调成本翻倍 |
四条缺一不可,原因很直接:前三条决定"拆了有没有收益",第四条决定"拆了谁来承担协调成本"。少了第四条,收益会被同一个团队的沟通开销吃掉;少了第一条,服务边界会随每次需求被反复跨越,最后变成分布式单体。
2. 合并的信号
| 信号 | 观测方式 | 含义 |
|---|---|---|
| 改一个需求要动三个服务 | 需求单里关联的服务数 | 边界与业务能力不匹配 |
| 联调成为常态 | 每次发版的联调工时占比 | 接口契约不稳 |
| 共享库版本地狱 | 升级一个库要协调多个服务 | 代码边界假拆分 |
| 事务被迫跨服务化 | 出现补偿、对账、人工修数 | 数据边界不存在 |
| 两个服务总是同时发布 | 发布记录的时间相关性 | 事实上是一个部署单元 |
"总是同时发布"是最容易被忽略的合并信号:它说明两个服务的变更节奏完全耦合,独立发布的收益为零,成本却实打实存在。
3. 拆与合不是对称操作
| 维度 | 拆 | 合 |
|---|---|---|
| 数据 | 需要切分、迁移、双写 | 需要合并表、去重、冲突裁决 |
| 接口 | 新增契约,可先粗后细 | 契约要下线,调用方要改 |
| 组织 | 可以先拆团队再拆服务 | 团队已独立,合并意味着汇报线变化 |
| 心理 | 被视为进步 | 被视为回退,没人愿意提 |
拆容易是因为它只增加接口、不减少承诺;合困难是因为它必须撤回承诺——接口一旦被外部依赖,下线就要走废弃流程。加上组织惯性(团队已按服务划分,合并等于承认决策失误),合并的决策成本远高于技术成本。
结论:拆之前要按"能不能合回来"设计,包括数据能否回灌、接口是否有版本、调用方是否可控。
4. 数据边界才是真正的边界
-- 反例:两张表被两个服务写,边界只存在于 PPT 里
-- order-service: UPDATE orders SET status='paid'
-- pay-service: UPDATE orders SET status='paid', paid_at=NOW()
-- 正例:一张表一个属主,另一方通过接口或事件获知
-- pay-service 只写 payments,然后发 OrderPaid 事件
| 判定 | 说明 |
|---|---|
| 单表单写者 | 真正的数据边界 |
| 单表多写者 | 拆分未完成,只是换了部署形态 |
| 共享视图/只读账号 | 可接受,但要定义读的语义与延迟 |
| 跨库 JOIN | 边界已破,需在应用层重组 |
判据简单:能不能在不改代码的情况下,把这张表的属主换给另一个服务。不能,说明耦合在数据层。
5. 共享代码库与共享数据库
| 共享物 | 影响 | 处理 |
|---|---|---|
| 共享工具库(无状态) | 升级需协调,但语义安全 | 语义化版本 + 允许落后一版 |
| 共享领域模型库 | 一个服务的字段变更传染全部 | 拆成各自模型 + 转换层 |
| 共享数据库 | 无法独立变更 schema | 按属主分库,跨库走接口 |
// 反例:领域对象直接跨服务复用,A 加字段 B 就得跟着发版
import com.acme.order.OrderEntity; // 来自另一个服务的 jar
// 正例:各自模型 + 显式转换,契约在接口层
OrderView view = OrderView.from(orderDto);
共享代码库不必然坏,坏的是共享可变的状态(模型、常量、连接)。
6. 接口粒度的取舍
| 粒度 | 调用次数 | 变更成本 | 适用 |
|---|---|---|---|
| 极细(每字段一接口) | 调用风暴,N+1 | 低,但要改很多处 | 极少使用 |
| 按用例(聚合接口) | 一次往返 | 中,改动要兼容 | 主流选择 |
| 极粗(整页数据) | 一次往返,传输大 | 高,任何字段变动都改契约 | 前端聚合层 |
细接口的代价:一次页面渲染 12 次远程调用,P99 = 单次 P99 × 12 的尾部叠加
粗接口的代价:任一字段变化都要改契约,调用方全体回归
判据:按"一次业务决策需要的数据"定粒度,而不是按数据库表定。表的粒度是存储视角,接口的粒度是使用视角。
7. 分布式事务的替代
| 方案 | 一致性 | 复杂度 | 适用 |
|---|---|---|---|
| 发件箱(Outbox) | 最终一致,不丢事件 | 中,需中继进程 | 跨服务状态传播 |
| 状态机 + 补偿 | 最终一致,可见中间态 | 中高,需定义每个失败分支 | 长流程、有明确阶段 |
| 对账 | 事后一致 | 低,需定时任务与差异处理 | 金额类,必须有兜底 |
| 两阶段提交 | 强一致 | 高,阻塞与协调者单点 | 极少,且多在同一系统内 |
-- 发件箱:业务写与事件写在同一个本地事务里
BEGIN;
INSERT INTO payments(id, order_id, amount, status) VALUES (?, ?, ?, 'SUCCESS');
INSERT INTO outbox(id, topic, payload, created_at) VALUES (?, 'OrderPaid', ?, NOW());
COMMIT;
-- 独立中继进程轮询 outbox 投递,成功后标记,失败重试
关键点:一致性不是靠一个更强的协议换来的,而是靠"可重试 + 幂等 + 对账"三件套兜住的。
8. 逐步重构的路径
| 阶段 | 动作 | 判断是否继续 |
|---|---|---|
| 1 模块化 | 同一进程内按包/模块划边界,禁止跨模块直接访问 | 模块内改动是否已能独立 |
| 2 绞杀者 | 新逻辑走新服务,旧路径保留,流量按比例切 | 新旧一致性是否可控 |
| 3 数据切分 | 新服务拥有自己的库,旧库通过同步过渡 | 双写差异是否可观测 |
| 4 下线旧路径 | 观察窗口结束后删除 | 回滚是否还有意义 |
# 绞杀者:按路径与比例分流,回滚只需改权重
upstream legacy { server 10.0.0.10:8080; }
upstream modern { server 10.0.0.11:8080; }
split_clients "${remote_addr}" $backend {
10% modern;
* legacy;
}
模块化这一步最常被跳过,但它决定了后面能不能"只搬不改":模块边界清晰时,拆服务是搬目录;边界不清时,拆服务是重写。
9. 回滚设计
// 拆分开关:读旧写双,或读双比对,出问题一键回旧
if (flags.isOn("order.read.new")) return newClient.get(id);
return legacyClient.get(id);
| 回滚级别 | 手段 | 生效时间 | 前提 |
|---|---|---|---|
| 流量回退 | 权重/开关 | 秒级 | 旧路径仍在线 |
| 数据回灌 | 反向同步 | 分钟到小时 | 双向映射还在 |
| 代码回退 | 回滚发布 | 分钟级 | 契约未破坏 |
| 结构回退 | 合并库表 | 天级 | 几乎等于重做 |
判据:拆分方案里若没有"旧路径继续可写"的阶段,就没有真正的回滚。只有读回退而没有写回退,等于把数据留在了新结构里。
10. 康威定律的实际含义
服务边界 ≈ 沟通边界
两个团队要天天开会才能改一个接口 → 这个接口会被拆成两个
一个团队维护三个服务 → 这三个服务会逐渐长成同一个样子(共享库、共享表)
实际含义不是"照着组织图画架构",而是组织变更会在一到两个季度内改写架构。所以拆服务前先问:这个边界对应的团队是否存在、是否有独立的决策权。不存在时,先调整模块与负责人,比先拆服务便宜。
11. 度量
| 指标 | 采集方式 | 健康信号 |
|---|---|---|
| 变更周期 | 需求提出到上线的时间 | 拆分后应缩短,未缩短即失败 |
| 部署频率 | 各服务发布次数 | 独立发布比例上升 |
| 跨服务改动比例 | 需求单关联服务数 / 总需求 | 持续下降 |
| 变更失败率 | 回滚次数 / 发布次数 | 不因拆分而上升 |
| 联调工时占比 | 迭代工时统计 | 下降或持平 |
# 粗口径:近 90 天各服务的提交分布,看是否真的独立演进
git log --since="90 days ago" --format='%H' --name-only \
| awk 'NF' | cut -d/ -f1 | sort | uniq -c | sort -rn
度量要在拆之前留基线,否则拆完无法判断收益,只能靠感觉争论。
12. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 发布必须一起发 | 接口无版本,契约同步演进 | 加版本或加兼容字段 |
| 循环调用 | 边界按"技术层"而非业务能力划 | 上移或合并成一个服务 |
| 数据对不上 | 双写未覆盖失败路径 | 加对账任务,补齐差异 |
| 延迟突然变高 | 细接口导致调用叠加 | 聚合接口或批量接口 |
| 回滚后数据错乱 | 只回滚了读,没回滚写 | 保留反向同步通道 |
| 共享库升级阻塞 | 领域模型跨服务复用 | 拆模型,加转换层 |
13. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 团队小、变更频率低 | 不拆,先模块化 | 协调成本由同一批人重复支付 |
| 单库性能足够 | 不拆,先加索引与读写分离 | 拆分不解决慢查询 |
| 代码质量差 | 不靠拆分解决 | 拆完只是把烂代码分布到网络上 |
| 业务边界还在剧烈变化 | 延迟拆,保持模块化 | 边界没定型时拆,注定要重拆 |
| 已有跨服务事务缠身 | 先合并再重划 | 拆合顺序错了,越拆越乱 |
一句话收尾:拆分是把组织成本换成技术成本,合并是把技术成本换回组织成本。哪边更便宜,取决于团队规模与变更节奏,而不是架构图好不好看。
14. 自测
- 拆分收益的四个前提里,为什么"团队自治"缺一不可?
- "两个服务总是同时发布"为什么是合并信号?
- 拆与合为什么不对称?不对称带来的设计约束是什么?
- 如何判断一张表是否已经构成真正的数据边界?
- 只有读回退、没有写回退的拆分,回滚会留下什么问题?
- 拆分前为什么要先留度量基线?