「什么时候该把单体拆成服务」这个问题,我听过两种极端答案:一种是"能拆就拆,微服务是先进生产力",另一种是"能不拆就不拆,分布式是万恶之源"。都不对——拆与不拆不是价值观问题,是成本问题。这篇讲我自己实际走过的顺序,以及每一步的触发信号。
一、先承认:模块化单体已经能解决大部分问题
我现在手上这个站点(tools + platform + IM)其实就是三个独立进程,但每一个内部都是模块化单体:按业务能力分包,包之间只通过明确的接口调用,没有跨包直接读别人的表。
模块化单体 + 单库事务的好处很实在:一次业务操作一个事务,不用补偿;重构时 IDE 能一把改干净;本地起一个进程就能调试全链路。
所以"要不要拆"的第一个判断不是技术,而是这三条里有没有一条成立:
| 触发信号 | 说明 |
|---|---|
| 部署耦合造成实际损失 | 改 A 模块必须跟着发 B,一周发三次、每次都要回归全量 |
| 容量需求差异悬殊 | 某个模块要 32 核,其它模块 2 核就够,只能一起扩 |
| 团队边界已经形成 | 两个小组各改各的,代码评审互相卡、合并天天冲突 |
这三条都不成立时,拆微服务带来的复杂度(网络、分布式事务、链路排查、发布编排)几乎一定大于收益。
二、如果确定要拆,顺序很关键
我的实际顺序是:先按数据所有权划边界 → 再定接口 → 再动数据 → 最后拆进程。很多人反过来,先拆进程,结果两个服务共用一个库,成了"分布式单体"。
第一步:找出数据所有权(不是模块,是数据)
做法很土但有效:把每张表列出来,标注"哪个业务是它唯一的写方"。凡是出现两个写方的表,就是未来的战场——要么合并业务,要么把表拆开。
第二步:先定接口,且用"调用方视角"定
// 不要暴露数据,暴露能力
type OrderService interface {
Create(ctx, req CreateReq) (OrderID, error)
Get(ctx, id OrderID) (*Order, error) // 返回 DTO,不是数据库行
}
这一步的产出物应该是接口文档 + 错误码 + 幂等约定,而不是代码。接口一旦定下来,后面拆进程就是换个实现的事。
第三步:动数据——共享表怎么退出
这是整个拆分里最花时间的一步,我的做法是四段:
① 双写:新服务开始写自己的表,老逻辑继续写老表(或反之),保证两边都有数据
② 回填:把历史数据按主键分批搬到新表,可中断续跑,有校验(条数 + 抽样比对)
③ 切读:把读路径一个接口一个接口切过去,用开关控制,可随时切回
④ 停写:观察一个发布周期后,停掉老表的写入,最后再考虑删表
每一步都可以停下来,也都能回滚——"能停在中间"是这套顺序最大的价值。
第四步:拆进程
到这一步反而是最简单的:把已经按接口隔离的模块编译成独立二进制,配上网关路由和服务发现。
我的经验是:如果前两步做扎实了,第四步通常一天就能完成;如果前面偷懒了,第四步会变成几个月的"边拆边修"。
三、拆完之后新增的四个成本,得提前认
| 成本 | 具体表现 | 缓解手段 |
|---|---|---|
| 事务不再免费 | 跨服务操作要靠 Saga/Outbox 才能保证最终一致 | 尽量把"必须一起成功"的操作放在同一个服务里 |
| 排查链路变长 | 一个请求跨 4 个服务,日志散在四处 | traceId 全链路透传 + 统一日志字段 |
| 发布要编排 | 改一个接口影响两个服务,得考虑兼容窗口 | 接口只做兼容性变更;灰度 + 双版本并行 |
| 本地跑不起来 | 起 5 个进程才能调一次全链路 | 提供 make dev 一键起依赖;关键路径保留集成测试 |
四、一个具体的反例(我踩过)
我曾经把一个"用户服务"拆出来,理由是"用户逻辑应该独立"。结果:订单要查用户、消息要查用户、后台要查用户,每个服务都直连用户库读表——共享数据库的分布式单体。
后来把它合回去了,改成"每个服务只存自己需要的用户快照(id + 昵称 + 头像)",通过事件同步。这才真正解耦:用户服务挂了,聊天还能发(只是昵称暂时是旧的)。
这个反例的判据是:拆完之后,如果 A 服务必须实时读 B 的表才能工作,那它就没真正独立,只是把复杂度从代码里搬到了网络上。
五、我现在的默认策略
- 默认不拆,先把单体模块化,把包边界和数据边界划清楚;
- 按业务能力拆,不按技术分层拆(不要 user-dao / user-api / user-service 这种拆法,它只会让一次改动跨三个部署单元);
- 每个服务必须拥有自己的数据——判断标准是"这个服务的表,除了它自己没有别的写方";
- 拆的动作永远从数据开始,进程最后动;
- 拆完立刻把可观测性和发布流程补齐,否则你会用双倍的排查时间把省下的部署时间吃回去。
小结
拆分本身不难,难的是在没有明确收益的时候忍住不拆,以及在真正需要拆的时候按正确的顺序拆。我现在的判断很简单:出现"部署耦合损失 / 容量差异 / 团队边界"这三条里的任意一条,才考虑动手;一旦动手,就按"数据所有权 → 接口 → 数据迁移 → 进程"的顺序走,每一步都留好回滚。