微服务拆分:按业务能力还是按领域

同一个词在不同团队含义不同,是拆分的起点。「订单」在交易团队眼里是一份支付契约,在履约团队眼里是一张待拣货的包裹,在财务团队眼里是一笔待结算的收入。它们塞进同一张表、同一套代码之后,任何一次改动都要三方会签。这里只回答一件事:边界按业务能力(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 章):

  1. 先划模块:在单体内部把边界立起来,跨模块调用改成显式接口,禁止直接访问别人的表;
  2. 再分数据:给每个模块独立 schema 或独立库账号,只授权自己的表,堵死跨模块 join;
  3. 挑软柿子先拆:通知、搜索、报表、鉴权这类读多写少、不参与核心事务的模块;
  4. 再拆写路径:双写 → 影子读比对 → 切读 → 停写旧路径,每一步都留回退开关;
  5. 最后拆共享表:这是最难的一步,通常需要事件加对账来兜底(第 14 章);
  6. 每拆一步先补可观测:链路追踪、指标与告警跟上,否则故障时你连是哪个服务慢都说不清(第 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 章)。

小结:按业务能力找候选边界,按领域模型校验语言一致性,最后用事务边界、数据边界、团队边界这三条硬约束决定粒度。三条全过的才叫服务;必须同时发布的、共享一张表的、没人负责的,都还是模块。拆分的正确顺序是先拆数据、后拆进程,先拆读路径、后拆写路径,每一步都留回退开关。

笔记加载中…