领域模型怎样落到数据库:映射、重建与继承
领域对象强调封装和始终合法,ORM 和数据库需要可读写的字段。为了持久化方便公开 setter,模型的保护就失效了;为了「纯领域」忽略查询、索引和事务的成本,设计又跑不起来。
一个场次聚合保存到数据库,再读回来,看起来只是两次转换。实际做下来会遇到几个具体问题:重建时要不要再走一遍创建规则?新规则「容量至少 10」上线后,历史上容量为 5 的场次还能不能加载?读回来的聚合会不会又登记一遍「报名已确认」事件?给一个有 1000 人的场次加一个人,要写几行?票种有继承关系时,三种映射方式在 MySQL 里分别是什么样?
本文用手写 JDBC 的仓储在 MySQL 8.4.11 上逐一验证。不用 ORM,是为了让每一次读写都看得见;结论同样适用于 JPA 或 MyBatis,只是这些细节被框架藏了起来。
一、先说结论
- 领域模型、持久化模型、查询模型各司其职:聚合用来守护规则,持久化对象用来适配表结构,报表查询用单独的读模型。
- 重建不是创建:从数据库恢复历史状态时,不执行创建规则,也不登记事件。实测新规则上线后,历史场次经
restore重建成功、事件 0 个;按创建路径重放则被拒绝,或者多登记 7 个事件。 - 版本号是聚合的并发语义:两个请求从同一版本加载,后提交的
UPDATE ... WHERE version = ?影响 0 行,整笔事务(包括子表写入)回滚。 - 子表按差异保存:有 1000 个参会人的场次新增 1 人,按差异保存写 2 行,先删后插写 2004 行。
- 继承映射按约束和演进选择:9 万张票的规模下,三种映射的查询耗时差别不大;真正的差别在于数据库能替你守住哪些约束。
二、三种模型
| 模型 | 服务于 | 形状 | 谁能改它 |
|---|---|---|---|
| 领域模型 | 执行命令、守护规则 | 聚合根 + 实体 + 值对象,构造即合法 | 只有聚合自己的行为方法 |
| 持久化模型 | 映射表结构 | 与表对应的记录,字段公开,可以为任何值 | 仓储与转换器 |
| 查询模型 | 列表、看板、报表 | 为某个页面准备好的扁平结构 | 投影或查询 SQL |
简单的聚合可以直接映射:字段和列一一对应,ORM 用反射读写私有字段即可。聚合一旦包含子集合、值对象和版本号,就值得写一个显式的转换器,由仓储在保存和加载时调用。分层 一文用往返检查验证过这种转换器:漏掉一张子表,不会报错,只会丢数据。
实验中的场次聚合对应两张表:
CREATE TABLE session (id VARCHAR(16) PRIMARY KEY, capacity INT NOT NULL, version BIGINT NOT NULL);
CREATE TABLE session_attendee (
session_id VARCHAR(16) NOT NULL, attendee_id VARCHAR(32) NOT NULL, phone CHAR(11) NOT NULL,
status ENUM('CONFIRMED', 'WAITLISTED') NOT NULL, position INT NOT NULL,
PRIMARY KEY (session_id, attendee_id));容量 5、已确认 5 人、候补 2 人的场次保存后,用新连接重建,业务状态(容量、两个名单及其顺序)与保存前一致。
三、重建不是创建
聚合有两条进入内存的路径:
/** 创建:执行创建规则,登记事件。 */
static Session open(String id, int capacity) {
if (capacity < minimumCapacity) throw new IllegalArgumentException("容量至少为 " + minimumCapacity);
return new Session(id, capacity, 0);
}
/** 重建:恢复已经发生过的状态,不执行创建规则,不登记事件。 */
static Session restore(String id, int capacity, long version, List<Attendee> confirmed, List<Attendee> waitlist) { ... }为什么要分开?假设业务上线了一条新规则:新建场次的容量至少为 10。历史上已经有一个容量为 5 的场次,里面有 7 个报名:
经 restore 重建:成功,待发布事件 0 个
经 open 创建:抛出「容量至少为 10」
放宽规则后按 register 逐个重放:登记事件 7 个创建规则约束的是将来的对象,历史数据是已经发生的事实,不应该因为规则变化而加载失败。按行为重放更糟:7 个「报名已确认」事件会被再次交给下游,计费上下文可能为同一批人再生成一遍应收。
这也是为什么持久化对象不能直接调用领域对象的公开构造函数或 setter 来「填充」状态。重建需要一个专门的入口:静态工厂 restore、包级可见的构造函数,或者 ORM 通过反射直接写字段。选哪一种取决于框架,原则是一样的:重建绕过规则,创建经过规则,两条路径不能混用。
四、版本号
聚合的版本号在每次保存时加 1,保存条件是版本没有被别人推进:
UPDATE session SET version = version + 1 WHERE id = ? AND version = ?两个请求都从版本 0 加载同一个场次,各自往候补里加一个人:
A 写入 2 行(根表版本 + 1 行子表)并提交
B 的 UPDATE 影响 0 行,抛出 ConcurrentModification,回滚
最终版本 1,候补 [a6, a7, x]两个细节:版本检查放在根表上,子表的任何修改都必须先通过根表的版本检查,否则 B 的子表写入会绕过冲突检测;B 回滚的是整个事务,包括它已经插入的子表行。
在热点聚合上,乐观锁会产生大量冲突,那时要换成悲观锁或条件更新,对比数据见 从统一语言到限界上下文。
DDK 的做法:聚合的版本号映射到持久化对象的 @Version 字段,由 MyBatis-Plus 的乐观锁插件拼上版本条件;通用仓储检查影响行数,为 0 时抛出 ConcurrentUpdateException 且不发布事件,见 DDK 通用仓储。
五、子表怎样保存
给一个已有 1000 个参会人的场次新增 1 人,两种保存方式:
按差异保存(只插入新增的行):写入 2 行,约 1ms
整体替换(先删后插):写入 2004 行(根表 1 行、删除 1001 行、插入 1002 行),约 60ms整体替换实现最简单:不需要知道哪些行变了,把子表清空再全部写回即可。它的代价随子集合大小线性增长,而且会让子表行的主键、创建时间、审计字段在每次保存时都变化。按差异保存需要知道加载之后发生了什么变化,也就是变更跟踪:自己在聚合里记录新增、删除和修改,或者依赖 ORM 的脏检查。
子集合很大时,还要回头看聚合边界:一个场次有几万个报名,每次报名都加载并保存整个集合,说明这个集合也许不该完整地放在聚合里,只保留计数、用唯一索引兜底重复,是 聚合边界 里讨论过的另一种选择。
六、值对象怎样存
值对象没有自己的身份,存储方式有三种,都是设计判断,这里没有做对比实验:
| 方式 | 适合 | 注意 |
|---|---|---|
拆成列(fee_amount、fee_currency) | 单个值、需要按它查询或建索引 | 最常用;列名加前缀避免冲突 |
| JSON 列 | 结构复杂、很少按内部字段查询 | 要按内部字段查询时需要生成列加索引 |
| 独立的子表 | 值对象的集合 | 子表没有业务身份,整体替换通常可以接受 |
无论哪种方式,读回来时都要重新走值对象的构造函数,让规范化和校验再执行一遍。如果历史数据里有不合法的值(例如早期没有校验的手机号),这一步会失败,需要先清洗数据,或者为历史数据单独提供一个宽松的解析入口。
七、继承的三种映射
报名平台有三种票:免费票没有价格,付费票有价格,团体票有价格和最少人数。领域建模 一文讨论过要不要用继承;如果确实用了,存储有三种方式:
- 单表:所有票种放在一张表里,价格和最少人数可为空,用
type区分,用CHECK约束补回每个子类型的必填列; - 每个具体类一张表:免费票、付费票、团体票各一张表,每张表的列都是非空的;
- 父表 + 子表:公共列放父表,付费和团体各有一张子表,按主键关联。
每种票 3 万张,共 9 万张票,两个查询:
| 单表 | 每个具体类一张表 | 父表 + 子表 | |
|---|---|---|---|
| 场次 42 的全部票种(300 行) | 一次索引查找,约 0.4ms | 3 个分支的 UNION ALL(Append),约 0.4ms | 两层左连接,约 0.6ms |
| 价格高于 450 元的付费票(3018 行) | (type, price) 索引范围扫描,约 2.5ms | 只查付费票表,约 2.2ms | 子表索引扫描再回父表取名称,约 2.4ms |
| 数据 + 索引 | 9.5 MB | 10.6 MB | 11.6 MB |
| 付费票缺少价格 | CHECK 约束拒绝 | 列非空,天然拒绝 | 父表插入 type='PAID'、不插子表,成功 |
| 同一个 id 出现在两种票里 | 主键拒绝 | 两张表各插入一次,成功 | 主键拒绝 |
在这个规模下,查询耗时的差别不足以决定选择。更有意义的是最后两行:
- 单表把约束写进
CHECK,数据库能守住「付费票必须有价格」。MySQL 从 8.0.16 起执行CHECK约束,更早的版本会解析但忽略它。 - 每个具体类一张表,每张表的约束最严格,但跨表的 id 唯一性没人管,按 id 查一张票不知道去哪张表找;多态查询要写 UNION。
- 父表 + 子表最接近类的结构,但数据库不保证「付费票一定有子表行」,需要应用在同一个事务里写两张表;每次读取完整的票都要连接。
还要考虑变化:新增一种票,单表加列(可能要改 CHECK),具体类表加一张表并修改所有多态查询,父子表加一张子表。子类型之间差异越大、越独立变化,越倾向于分开存;差异只是几个可空字段时,单表加 CHECK 往往最省事。
八、外键
外键不是信仰问题。单库、强一致、数据量适中时,外键能以很低的成本守住引用完整性,实验中父表 + 子表的映射就用了外键。以下几种情况,团队常常选择不建外键:
- 数据已经或即将分库,外键无法跨库;
- 大表在线变更、数据迁移时,外键会让操作顺序和锁的范围变得复杂;
- 高并发写入时,外键检查带来额外的锁。
不建外键时,要有替代手段:应用层在同一事务里校验,定期对账找出孤儿数据,并且有人负责清理。「虚拟外键」如果只是一句约定,孤儿数据迟早会出现。
九、常见误区
- 「为了 ORM 方便,给领域对象加上 public setter」:任何代码都可以绕过规则修改状态;重建应该走专门的入口。
- 「重建就是按顺序重新调用一遍业务方法」:历史状态不一定满足今天的创建规则,重放还会重复登记事件。
- 「聚合就对应一张表」:场次聚合就对应两张表;也可能几个值对象被压进同一张表的几列里。
- 「继承映射选最快的那种」:规模不大时差别很小,先看约束和变化方向。
- 「查询也走仓储,保证一致」:仓储加载完整聚合,用来做列表和报表代价很高,见 CQRS。
小结
领域模型落到数据库时,要守住三件事:重建和创建是两条路径,重建不执行创建规则、不登记事件;版本号放在根上,子表的修改也要经过它的冲突检测;子表按差异保存,集合过大时回头审视聚合边界。继承映射按数据库能替你守住的约束和将来的变化方向选择。下一篇讨论上下文之间怎样集成:契约、防腐层与可靠事件。
配套实验
- codesphere-labs/ddd/persistence-rehydration:保存—重建往返、重建不是创建、版本号冲突、子表的两种保存方式、票种继承的三种映射(结果、执行计划、空间与约束),MySQL 8.4.11 容器(验证记录)
参考资料
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》第 6 章(工厂与重建、仓储)
- Martin Fowler,《Patterns of Enterprise Application Architecture》:Single Table Inheritance、Class Table Inheritance、Concrete Table Inheritance
- MySQL 8.4 Reference Manual:CHECK Constraints
- MySQL 8.4 Reference Manual:FOREIGN KEY Constraints