边界画在哪里
一句话总结
边界不是从名词中产生,而是从语言中产生。如果两个团队使用同一个词,却表达不同含义,那里就是边界。
为什么需要了解这些
“把系统拆成订单服务、商品服务、会员服务吧。”这是会议室里最常见的一句话,也是最常失败的设计。这种方式不过是把数据库表名直接提升为服务名称。结果可以预见:创建一笔订单,需要分别调用商品服务和会员服务;为了一个页面,三个服务始终必须一起行动。独立发布能力并没有出现。
领域驱动设计提出的视角不同。边界不是来自数据种类,而是来自围绕这些数据进行交流的人所使用的语言。
工作原理
以“商品”这个词为例。对销售团队而言,商品意味着价格、折扣和是否展示;对物流团队而言,商品意味着体积、重量和储存温度;对结算团队而言,商品意味着手续费率和税务代码。如果把这三种模型合并到一张商品表中,每次因物流需求新增字段,销售团队的代码也必须重新发布。
这里就是限界上下文。每个上下文拥有自己的商品模型,上下文之间只通过标识符(例如 SKU)连接。跨越上下文边界时转换模型,就是前面介绍过的防腐层。
实际工作中,可以通过三种方式寻找边界候选。第一,同一个词的含义发生分歧的位置。第二,真正需要事务的范围——必须位于同一事务中的数据,应留在同一服务内。第三,变更频率——每周变化的部分与一年只变化一次的部分,很可能属于不同服务。
生产现场中的常见情况
边界错误的信号,首先出现在会议而不是代码中。如果发布一个功能总是需要协调两个团队的日程,说明边界划错了。纠正方式通常不是继续拆分,而是把两个服务重新合并。合并决策远少于拆分决策,却更经常是正确答案。
还有一点:微服务中的“公共库”是一种安静的耦合。如果把公共 DTO 放在同一个仓库,升级该仓库版本时,所有服务都必须一起行动。更安全的方式不是共享代码,而是通过模式文档(OpenAPI、protobuf)共享契约。
寻找边界的实际流程
“从语言中寻找”虽然正确,却不容易执行。实际现场有一套可用流程。
事件风暴。 把领域专家与开发人员集中到同一个房间,在墙上用过去时贴出“已经发生的事情”:订单已受理、支付已批准、库存已扣减、配送已开始。随后按时间顺序排列事件,并标记谁会对该事件作出反应。反应聚集成块的位置,就是边界候选。
计算耦合程度。 画出候选边界后,统计真实代码中的调用次数。
| 指标 | 理想值 | 数值过差意味着什么 |
|---|---|---|
| 绘制一个页面所需的服务数 | 1~2 | 3 个以上说明边界错误 |
| 发布一个功能所涉及的团队数 | 1 | 2 个以上说明边界跨越团队 |
| 服务间同步调用深度 | 1~2 | 3 层以上会导致故障级联 |
从单体开始。 一开始就拆分通常会划错,因为此时还不了解领域。先在同一仓库中通过模块划定边界(模块化单体),当边界持续六个月都没有变化,再把模块拆成服务。错误的模块边界半天就能移动,错误的服务边界却可能需要数月修正。
不应拆分的时候
与正确划定边界同样重要的是,决定不拆分也是设计的一部分。满足以下条件时,拆成服务几乎总是得不偿失。
- 只有一个团队。 微服务的收益在于团队能够独立发布。只有一个团队时,没有这项收益,只会留下运维负担。
- 需要强事务。 如果库存扣减与订单创建必须同时成功或同时失败,拆分后就必须亲自实现 Saga 和补偿事务,其复杂度会超过收益。
- 仍不了解领域。 新产品的最初六个月,边界几乎每周都在变化。
还必须计算运维成本。每增加一个服务,就会分别增加一个仓库、一套 CI、一条发布流水线、一块仪表板、一组告警和一份值班文档。拥有五个服务的系统,不是代码量增加五倍,而是运维表面积增加五倍。
数据跨越边界时
一旦划出边界,立刻会遇到“那要怎么做 Join”的问题。订单列表需要显示商品名称,但商品属于另一个服务。答案有三种,每一种代价不同。
- 调用后获取。 最简单,但商品服务故障时,订单列表也会故障。一个页面的可用性变成两个服务可用性的乘积(99.9% × 99.9% = 99.8%)。
- 按需要复制。 把商品名称以下单时的值记录在订单中。实际上,这在领域上也更正确——即使商品名称之后改变,历史订单中的名称也应保持不变。
- 建立只读视图。 订阅事件并维护只读表(CQRS)。最灵活,但必须承担最终一致性和重建流程。
第二种方式成为正确答案的情况,比想象中更多。先确定一个值应被视为**“引用”,还是“当时的事实”**,答案自然会出现。
下一次测验要确认什么
本模块只介绍概念。紧接着的测验会检查边界判断标准;下一模块则会亲手验证,如此拆分的服务在真正通信时需要付出什么代价。