微服务拆分:按业务能力还是按领域
同一个词在不同团队含义不同,是拆分的起点。「订单」在交易团队眼里是一份支付契约,在履约团队眼里是一张待拣货的包裹,在财务团队眼里是一笔待结算的收入。它们塞进同一张表、同一套代码之后,任何一次改动都要三方会签。这里只回答一件事:边界按业务能力(Business Capability)切,还是按领域模型(Domain Model)切,以及切到什么粒度为止。
结论先给
两者不是二选一,而是先后两道工序:业务能力用来找候选边界(谁在对外承担什么职责),领域模型用来校验边界质量(同一个上下文里的语言是否自洽)。识别出候选之后,能不能真的成为独立服务,只看三条约束:事务边界、数据边界、团队边界。三条都过才是服务;只过一条,那是模块(模块化单体见第 08 章)。
两种拆法的差别
| 维度 | 按业务能力拆 | 按领域模型拆 |
|---|---|---|
| 切分依据 | 组织职责、业务流程、对外提供的价值动作 | 语言一致性、模型一致性、聚合不变量 |
| 识别手段 | 业务流程梳理、岗位职责、对外接口清单 | 事件风暴(Event Storming)、统一语言(Ubiquitous Language) |
| 边界稳定性 | 组织架构一调整就晃 | 业务语义稳定,抗组织变动 |
| 典型产出 | 订单服务、库存服务、支付服务 | 交易上下文、履约上下文、账务上下文 |
| 典型错误 | 按技术分层拆:controller 服务、dao 服务 | 按名词拆:每个实体一个服务 |
| 起步手感 | 快,能和现有团队对齐 | 慢,要先做一轮语言对齐 |
一句话记法:业务能力回答「拆成几块、谁负责」,领域模型回答「这块里面的词是不是只有一个意思」。只做前者,容易拆出「订单服务」和「交易服务」这种职责重叠的孪生服务;只做后者,容易拆出团队里没人认领的边界。
粒度怎么定:三条边界
1)事务边界:必须原子的写留在一起。 问一个问题:这个操作失败一半,业务能不能接受、能不能补偿?不能接受又补不了的,就是同一个服务。
- 必须同服务的:同一资源的并发扣减(库存数量与占用计数)、流水与对账凭证、状态机与它的当前状态;
- 可以跨服务的:写完之后的通知、积分、报表、搜索索引、推荐特征。
2)数据边界:一个服务独占一份数据。 边界错了的第一个征兆就是「必须 join 才能出结果」。如果两个服务要连同一个库做联表查询,那不是查询问题,是边界画错了(数据所有权的三条规则见第 10 章)。
3)团队边界:一个服务由一个 2~8 人小组端到端负责。 服务数超过团队数,就会出现「公共维护组」——所有服务都得排队等他们改,这比单体更慢。反向也成立:一个小组同时背 6 个服务的 oncall,最后谁都不敢改(组织与架构的对应关系见第 20 章)。
三条边界的交集才是服务:
事务边界 ──────┐
(能否原子) │
├──▶ 独立服务:独占数据 + 独立发布 + 独立扩缩容
数据边界 ──────┤
(是否独占) │
│
团队边界 ──────┘
(有人负责)
该拆的信号
不要因为「微服务先进」而拆。出现下面几条再动手:
- 发布互相阻塞:改 A 模块必须联合回归 B、C 模块,一次上线要拉三个团队;
- 扩缩容需求差一个数量级:搜索/报表要 40 个实例,订单写路径 4 个就够,捆在一起只能一起扩;
- 故障域需要隔离:某个模块的内存泄漏或慢查询把整个应用拖垮;
- 资源画像不同:CPU 密集的推理任务和 IO 密集的事务服务抢同一批机器;
- 合规要求:某些数据必须独立存储、独立审计。
反例:分布式单体(Distributed Monolith)
最贵的错误不是不拆,而是拆成了「分布式单体」:进程分开了,耦合一点没少,反而多了网络故障。
| 症状 | 具体表现 | 后果 |
|---|---|---|
| 共享数据库 | 多个服务读写同一批表 | 改表要同步发版,无法独立演进 |
| 共享模型库 | 抽一个 common/model 包给所有服务依赖 | 改一个字段全量重建、全量发布 |
| 长同步调用链 | A→B→C→D→E 全同步,一层失败全断 | 可用性相乘下降,延迟叠加 |
| 发布必须按顺序 | 上线脚本要求「先发 B 再发 A」 | 回滚只能整体回滚,等于单体 |
| 分布式事务包住全链路 | 用协调器把 5 个服务绑在一次事务里 | 比单体更脆弱,还多了协调器这个单点 |
自查一句话:如果这几个服务必须同时发布,它们本来是一个服务;如果它们共享一张表,它们本来也是一个服务。
迁移顺序
拆分是一次不可逆的数据迁移,顺序错了会同时失去单体的简单和微服务的独立(绞杀者模式的完整流程见第 18 章):
- 先划模块:在单体内部把边界立起来,跨模块调用改成显式接口,禁止直接访问别人的表;
- 再分数据:给每个模块独立 schema 或独立库账号,只授权自己的表,堵死跨模块 join;
- 挑软柿子先拆:通知、搜索、报表、鉴权这类读多写少、不参与核心事务的模块;
- 再拆写路径:双写 → 影子读比对 → 切读 → 停写旧路径,每一步都留回退开关;
- 最后拆共享表:这是最难的一步,通常需要事件加对账来兜底(第 14 章);
- 每拆一步先补可观测:链路追踪、指标与告警跟上,否则故障时你连是哪个服务慢都说不清(第 17 章)。
边界接口要显式,且只传标识与必要快照:
// 跨服务的边界契约:传入 ID 与本次操作必需的数据,返回引用而非实体
type InventoryService interface {
// Reserve 预占库存:幂等键由调用方生成,重复调用返回同一结果
Reserve(ctx context.Context, req *ReserveReq) (*ReserveResult, error)
}
type ReserveReq struct {
IdempotentKey string // 调用方生成,用于重试去重
OrderID int64 // 只传标识,不传整个订单实体
SkuID int64
Quantity int32
}
type ReserveResult struct {
ReservationID string // 后续确认或取消时的凭据
ExpireAt int64 // 预占过期时间,避免悬挂占用
}
反面写法是让接口接收 *model.Order(共享实体):调用方改一个字段,被调方就得跟着重建,等于把两个服务在编译期绑死。
拆错了怎么收场
- 两个服务总是一起改:合并回去,别硬撑,合并比维护一个伪边界便宜;
- 边界总是漏数据:检查是不是把「读模型」当成了服务,读侧更适合冗余副本而不是新服务;
- 事务拆不动:说明这一步本来就不该拆,退回模块化单体不丢人(第 08 章)。
小结:按业务能力找候选边界,按领域模型校验语言一致性,最后用事务边界、数据边界、团队边界这三条硬约束决定粒度。三条全过的才叫服务;必须同时发布的、共享一张表的、没人负责的,都还是模块。拆分的正确顺序是先拆数据、后拆进程,先拆读路径、后拆写路径,每一步都留回退开关。