领域模型怎样落到数据库:映射、重建与继承
领域对象强调封装和始终合法,ORM 和数据库需要可读写的字段。为了持久化方便公开 setter,模型的保护就失效了;为了「纯领域」忽略查询、索引和事务的成本,设计又跑不起来。
一个场次聚合保存到数据库,再读回来,看起来只是两次转换。实际做下来会遇到几个具体问题:重建时要不要再走一遍创建规则?新规则「容量至少 10」上线后,历史上容量为 5 的场次还能不能加载?读回来的聚合会不会又登记一遍「报名已确认」事件?给一个有 1000 人的场次加一个人,要写几行?票种有继承关系时,三种映射方式在 MySQL 里分别是什么样?
总的原则是三种模型各司其职:聚合用来守护规则,持久化对象用来适配表结构,报表查询用单独的读模型,见 CQRS。本文用手写 JDBC 的仓储在 MySQL 8.4.11 上逐一验证。不用 ORM,是为了让每一次读写都看得见;结论同样适用于 JPA 或 MyBatis,只是这些细节被框架藏了起来。
一、三种模型
| 模型 | 服务于 | 形状 | 谁能改它 |
|---|---|---|---|
| 领域模型 | 执行命令、守护规则 | 聚合根 + 实体 + 值对象,构造即合法 | 只有聚合自己的行为方法 |
| 持久化模型 | 映射表结构 | 与表对应的记录,字段公开,可以为任何值 | 仓储与转换器 |
| 查询模型 | 列表、看板、报表 | 为某个页面准备好的扁平结构 | 投影或查询 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 通用仓储。
版本号放在根上,前提是每一条写路径都会改到根。只往子表里插一行、只改一个子项时,根这一行没有变化,ORM 也就不会递增版本号:Hibernate 7.4 里往 mappedBy 的集合加一项,场次的版本号前后都是 1,两个并发的报名都能提交。只改子项的写路径要显式递增根的版本号(修改根上的汇总字段,或用强制递增的锁模式),实测与做法见领域模型里的集合,背后是一次查询。
保存聚合的接口还要说清它是完整替换还是部分修改:完整替换按差异重写全部子项,调用方必须先加载完整的聚合;部分修改只写变化的子项,调用方可以只带上要改的部分,但每个部分修改的方法都要自己经过版本检查。两种契约混用(有的调用方传完整对象、有的只传变化)时,最常见的结果是缺少的子项被当成「已删除」。
四、子表怎样保存
给一个已有 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 往往最省事。
别的表要引用一个多态的目标时,(type, id) 形式的引用加不了外键;共同父表分配身份的做法与实测,见子类型、类型字段还是组合。
七、外键
外键不是信仰问题。单库、强一致、数据量适中时,外键能以很低的成本守住引用完整性,实验中父表 + 子表的映射就用了外键。以下几种情况,团队常常选择不建外键:
- 数据已经或即将分库,外键无法跨库;
- 大表在线变更、数据迁移时,外键会让操作顺序和锁的范围变得复杂;
- 高并发写入时,外键检查带来额外的锁。
不建外键时,要有替代手段:应用层在同一事务里校验,定期对账找出孤儿数据,并且有人负责清理。「虚拟外键」如果只是一句约定,孤儿数据迟早会出现。
下一篇讨论上下文之间怎样集成:契约、防腐层与可靠事件。
配套实验
- 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