Skip to content

从统一语言到限界上下文:DDD 怎样划出业务边界 ​

在一个活动报名平台里,营销团队说的「报名人数」是点过报名按钮的人,报名团队说的是占用了名额的人,财务说的是已经付款的人。三个团队用同一个词、同一张表、同一个 Registration 类,然后在每次需求评审里争论数字为什么对不上。领域驱动设计(Domain-Driven Design,DDD)的战略部分,解决的就是「同一个词在不同地方是不是同一个意思」。

软件设计到底在设计什么 里讨论过一个 Order 对象四处共用导致变化扩散的问题。本文把这个问题放大到业务层面:从团队的语言出发,识别子域,划出限界上下文,处理上下文之间的翻译,最后回到代码,看聚合如何守住一个上下文内部的规则。案例是一个虚构的活动报名平台,聚合的并发实验在 MySQL 8.4.11 上完成。

一、先说结论 ​

  • 统一语言(Ubiquitous Language)是在一个边界内统一,不是全公司统一:同一个词在不同团队有不同含义很正常,要做的是把边界画出来,而不是强行让所有人用一个定义。
  • 子域决定投入:核心域自己建模、投入最好的人;支撑域够用即可;通用域优先采购或接入现成方案。
  • 限界上下文(Bounded Context)是模型含义的边界:它可以对应一个微服务,也可以只是单体里的一个模块。是否拆成独立部署单元,取决于发布节奏、扩展需求和团队边界。
  • 上下文之间要显式翻译:外部系统的模型经过防腐层(Anti-Corruption Layer)转换后才进入核心,外部字段的变化不会扩散到业务规则里。
  • 聚合是一致性边界:规则写在服务里「先数再插」,200 个并发请求抢 100 个名额,三轮都超卖;让场次作为聚合根守住名额规则后不再超卖,但并发手段仍要按冲突程度选择。

二、统一语言:从歧义开始 ​

DDD 的起点不是画架构图,而是听业务方怎么说话。歧义通常以下面几种形式出现:

信号例子说明了什么
同一个词,不同的数营销的「报名人数」比报名系统多出一截这个词在两处指的是不同的东西
一个类里有大量只在某些场景有意义的字段Registration 同时有 utmSource、seatNo、invoiceTitle几个模型被塞进了一个类
一个状态字段被多个团队各自解释status=2 在营销看来是「已转化」,在财务看来是「待收款」状态机属于不同的上下文
需求评审里反复出现「你说的 X 是指……」「你说的取消,是用户取消还是活动取消?」需要两个不同的词

处理方式是先承认差异,再给每个含义起一个在其边界内无歧义的名字:

团队他们说「报名」时指的是在各自上下文里的名字关心的规则
营销用户点了报名按钮,留下联系方式线索(Lead)渠道来源、转化率
报名用户占用了一个名额报名(Registration)容量、候补、取消与释放名额
财务需要收的一笔钱应收款(Receivable)金额、账期、退款

这些名字随后要进入代码:类名、方法名、事件名都用这套词汇。评审时如果有人说「报名成功后给财务推一条报名」,就应该追问:推的是报名,还是一笔应收款?这类追问就是统一语言在起作用。

三、子域与限界上下文 ​

同一个词「报名」营销:点了「报名」按钮是一条待转化的线索报名:占用一个名额受容量、候补、取消规则约束财务:一笔待收款有金额、账期与退款规则子域与限界上下文报名核心域活动管理支撑域签到支撑域支付通用域:接入通知通用域:接入防腐层会员 → 参会人外部会员中心字段多、语义由对方决定限界上下文是模型含义的边界,不等于必须拆成一个微服务核心域:自己建模、自己写;支撑域:够用即可;通用域:优先用现成方案
图 1 · 「报名」在三个团队嘴里是三个概念;按含义划出限界上下文,核心域投入最多,通用域优先采购或接入;外部系统的模型经过防腐层翻译后才进入核心

3.1 子域:按业务能力划分,按重要性投入 ​

子域(Subdomain)是从业务角度对问题空间的划分。划分的依据是业务能力和变化来源,而不是现有的系统或数据库表。活动报名平台可以分成:

子域类型为什么投入方式
报名与候补核心域名额分配、候补递补、超售控制是平台区别于表单工具的地方自己建模,投入最有经验的人,测试最充分
活动管理支撑域必须有,但只是创建活动、设置场次和容量简单的增删改查即可,不追求精巧
签到支撑域现场扫码核销,规则少够用即可
支付通用域每个业务都需要,已有成熟服务接入支付服务,不自己实现
通知通用域短信、邮件、推送接入现有通知平台

这张表最重要的用途是分配有限的设计精力。把同样的建模精力平均投到每个子域,结果通常是核心规则写得不够好,支撑功能却被过度设计。

3.2 限界上下文:一个模型成立的范围 ​

子域是问题空间的划分,限界上下文是解决方案空间的划分:在这个边界之内,每个词有唯一的含义,每个模型有唯一的定义。理想情况下两者一一对应,但现实里常见的是一个遗留系统横跨多个子域,或者一个子域因为团队分工被拆进两个上下文。

同一个人在不同上下文里是不同的模型:

java
// 报名上下文:关心他占了哪个名额
record Attendee(AttendeeId id, String displayName, Phone phone) {}

// 财务上下文:关心谁付钱、开什么发票
record Payer(PayerId id, String legalName, TaxNumber taxNumber) {}

// 营销上下文:关心他从哪里来
record Lead(LeadId id, Phone phone, Channel source, Instant capturedAt) {}

把三者合并成一个包含所有字段的 User,看起来减少了类的数量,实际上让三个团队的变化都落到同一个类上,这正是 设计对象篇 里共享 Order 的问题。

3.3 限界上下文不等于微服务 ​

限界上下文是模型的边界,微服务是部署的边界。两者可以重合,但不是必须重合:

做法适合的情况代价
单体中的模块(包或 Maven 模块),每个上下文一个团队小、发布节奏一致、上下文边界还在调整需要用 ArchUnit 或模块化手段防止互相直接引用
每个上下文一个服务上下文由不同团队负责,发布节奏不同,扩展需求差别大网络调用、分布式一致性、运维和排障成本
一个服务包含几个关系紧密的上下文上下文之间调用频繁、总是一起变化要在服务内部保持模块边界

边界还没稳定时就拆成服务,之后调整边界就要改接口、迁数据、协调多个团队发布。先在单体里用模块把边界划清楚,等边界被验证过、确实有独立部署的需要时再拆,风险要小得多。站内的 Domain Driven Kit 就是按这种方式在一个工程里组织分层和模块的。

四、上下文之间:显式翻译 ​

4.1 常见的上下文关系 ​

关系含义例子
客户与供应商下游提需求,上游排进计划报名上下文需要活动管理提供场次容量
遵奉者(Conformist)下游直接使用上游模型,不做翻译通知上下文直接使用通知平台的消息格式
防腐层下游写一层翻译,把上游模型转换成自己的模型报名上下文接入公司的会员中心
开放主机服务与发布语言上游提供稳定的协议和文档化的格式,供多个下游使用报名上下文对外发布「报名已确认」事件

选择遵奉者还是防腐层,取决于上游模型是否适合自己的核心规则。通用域通常可以遵奉,核心域接入外部模型时最好有防腐层。

4.2 防腐层长什么样 ​

公司的会员中心返回一个字段很多的对象,会员等级用数字编码,手机号可能带国家码也可能不带:

java
// 防腐层:会员中心的模型只在这里出现
final class MemberTranslator {
    private final MemberCenterClient client;

    Attendee toAttendee(String memberId) {
        MemberDto m = client.getMember(memberId);                  // 外部模型
        return new Attendee(
            new AttendeeId(m.getUid()),
            m.getNickName() != null ? m.getNickName() : m.getRealName(),
            Phone.parse(m.getMobile(), m.getCountryCode()));       // 格式差异在这里消化
    }

    boolean hasPriorityBooking(String memberId) {
        return client.getMember(memberId).getLevel() >= 3;         // 「3 级以上可以优先报名」翻译成业务含义
    }
}

报名上下文里的代码只认识 Attendee 和 hasPriorityBooking,不认识 MemberDto,也不知道等级 3 意味着什么。会员中心改了字段名、调整了等级编码,只需要改这一个类。关注点分离 里的端口与适配器是同一个思路,防腐层就是面向另一个上下文的适配器。

五、回到代码:聚合守住上下文内的规则 ​

战略设计划出边界,战术设计处理边界之内的模型。这一节只讨论其中最容易出问题的一个概念:聚合(Aggregate)。

5.1 名额规则写在哪里 ​

报名上下文的核心规则是「一个场次的报名数不超过容量」。第一种写法把规则放在服务里:

sql
-- A:先数再插(默认隔离级别 REPEATABLE READ)
SELECT capacity FROM event_session WHERE id = ?;
SELECT COUNT(*) FROM registration WHERE session_id = ?;
-- 应用判断 count < capacity
INSERT INTO registration (session_id, attendee) VALUES (?, ?);

第二种写法把场次作为聚合根,所有报名都必须经过它,规则只有这一个入口。聚合根上的规则有几种落地方式:

sql
-- B:乐观锁,按版本号更新,失败重试
UPDATE event_session SET booked = booked + 1, version = version + 1 WHERE id = ? AND version = ?;

-- C:悲观锁,先锁住聚合根再检查
SELECT capacity, booked FROM event_session WHERE id = ? FOR UPDATE;

-- D:条件更新,把不变量写进一条语句
UPDATE event_session SET booked = booked + 1 WHERE id = ? AND booked < capacity;

5.2 实测:200 个请求抢 100 个名额 ​

A 先数再插规则在服务里成功 103–122:超卖B 聚合根 + 版本号冲突后重试 50 次成功 53–55:约 145 个放弃C 锁住聚合根SELECT … FOR UPDATE成功 100,171–211msD 条件更新booked < capacity成功 100,144–186ms名额 100B 的冲突次数每轮 8,500 次以上:一致性没有被破坏,但热点聚合上乐观锁几乎只剩重试
图 2 · 规则写在服务里「先数再插」会超卖;把场次作为聚合根后,规则只有一个入口,但并发手段仍要按冲突程度选:热点行上乐观锁大量放弃,悲观锁和条件更新都正好 100 个(MySQL 8.4.11,每种写法 3 轮)
text
第 1 轮
  A 查询后插入:成功 103,实际行数 103
  B 版本号:成功 55,满员 0,放弃 145,冲突 8509 次,行数 55,1199ms
  C FOR UPDATE:成功 100,满员 100,行数 100,211ms
  D 条件更新:成功 100,满员 100,行数 100,186ms
第 2 轮
  A 查询后插入:成功 103,实际行数 103
  B 版本号:成功 53,满员 0,放弃 147,冲突 8635 次,行数 53,1216ms
  ...
第 3 轮
  A 查询后插入:成功 122,实际行数 122
  B 版本号:成功 53,满员 0,放弃 147,冲突 8585 次,行数 53,1021ms
  C FOR UPDATE:成功 100,满员 100,行数 100,181ms
  D 条件更新:成功 100,满员 100,行数 100,144ms

四种写法的结果说明了三件事:

  1. A 会超卖:COUNT(*) 是快照读,多个事务同时看到不足 100,都认为还有名额。超卖多少取决于请求交错得多紧:这三轮是 103—122,复测时有一轮 200 个请求全部「成功」。规则写在服务里,数据库里没有任何一处在守这条规则。锁定读能不能避免这个问题,取决于锁住了哪些范围,见 InnoDB 行锁的范围。
  2. B 守住了规则,但在热点上几乎不可用:一致性没有被破坏,但 200 个请求同时竞争一行,每轮 8,500 次以上的冲突,约 145 个请求重试 50 次后放弃,名额反而没有卖完。乐观锁适合冲突少的聚合,比如修改活动的描述。
  3. C 和 D 都正好 100 个:D 把不变量直接写成条件,一条语句完成检查和修改,最简单,也最快。当规则复杂到一条 SQL 写不下时,用 C 先锁住聚合根,在内存里执行规则。

聚合的作用是确定规则由谁守、修改从哪里进;并发控制的方式是另一个决定,要按冲突程度选择。

5.3 聚合的几条设计规则 ​

  • 一个聚合守住一组必须同时成立的规则:名额和已报名数必须一致,所以它们在同一个聚合里。报名和支付状态不需要在同一个事务里一致,所以不放在一起。
  • 一个事务只修改一个聚合:报名成功后,发通知、生成应收款通过领域事件异步完成,它们失败了不应该让报名回滚。
  • 聚合之间用标识引用:Registration 持有 SessionId,而不是持有整个 EventSession 对象。
  • 聚合要小:把一个活动的所有场次、所有报名都放进一个聚合,每次报名都要加载和锁住整个活动,并发冲突会像实验 B 一样集中爆发。

战术设计中的其他概念,在这个案例里分别对应:

概念在报名上下文中的例子
实体EventSession、Registration:有标识,状态会变化
值对象Phone、Capacity、TimeSlot:没有标识,按值比较,不可变
领域事件RegistrationConfirmed:通知、财务上下文订阅
仓储EventSessionRepository:按聚合存取,不按表
领域服务候补递补:涉及多个报名的排序规则,不属于单个实体
应用服务接收请求、加载聚合、调用领域方法、保存、发布事件,不包含业务规则

六、常见误区 ​

  • 「DDD 就是分层加一堆 Entity、Repository 类」:战术模式是表层,没有统一语言和上下文边界,分层之后的代码仍然是一个共享模型。
  • 「一个限界上下文就是一个微服务」:上下文是模型边界,部署方式按团队、发布节奏和扩展需求另外决定。
  • 「实体就是数据库表」:实体按业务标识和生命周期定义,一个聚合可能对应多张表,一张宽表也可能拆成几个上下文里的不同模型。
  • 「全公司统一一套术语」:统一语言只在一个上下文内统一,跨上下文要做的是翻译,而不是消除差异。
  • 「用了聚合就不会有并发问题」:聚合只明确了规则的归属,热点聚合上的并发控制仍需要实测后选择。
  • 「所有业务都值得做 DDD」:以增删改查为主、规则很少的支撑域,简单的分层就够了。

小结 ​

DDD 的战略设计,是先听清楚业务方用词的差异,再按含义画出边界:子域决定在哪里投入,限界上下文决定模型在哪里成立,上下文之间显式翻译。回到代码时,聚合把一组必须同时成立的规则收拢到一个入口,而用什么锁、怎样重试,要按冲突程度实测后决定。判断边界划得好不好,可以看一个需求变化会波及几个上下文:大多数变化只落在一个上下文里,边界就是合适的。

本文只是战略设计的入口。从事件风暴、领域建模、实体与值对象、聚合,到分层、持久化、上下文集成、CQRS 与遗留改造的完整实践链,见 领域驱动设计专题;其中 聚合边界 接着本文第五节,讨论边界画小、画大分别会发生什么。


配套实验

可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权