Skip to content

领域模型怎样落到数据库:映射、重建与继承 ​

领域对象强调封装和始终合法,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 用反射读写私有字段即可。聚合一旦包含子集合、值对象和版本号,就值得写一个显式的转换器,由仓储在保存和加载时调用。分层 一文用往返检查验证过这种转换器:漏掉一张子表,不会报错,只会丢数据。

实验中的场次聚合对应两张表:

sql
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 人的场次保存后,用新连接重建,业务状态(容量、两个名单及其顺序)与保存前一致。

三、重建不是创建 ​

聚合有两条进入内存的路径:

java
/** 创建:执行创建规则,登记事件。 */
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 个报名:

text
经 restore 重建:成功,待发布事件 0 个
经 open 创建:抛出「容量至少为 10」
放宽规则后按 register 逐个重放:登记事件 7 个

创建规则约束的是将来的对象,历史数据是已经发生的事实,不应该因为规则变化而加载失败。按行为重放更糟:7 个「报名已确认」事件会被再次交给下游,计费上下文可能为同一批人再生成一遍应收。

新建场次命令Session.open校验创建规则 · 登记事件容量 5 < 10:拒绝数据库中的行容量 5、7 个报名Session.restore不校验创建规则 · 不登记事件重建成功,待发布事件 0 个按 register 逐个重放:再登记 7 个「报名已确认」,下游可能重复生成应收
图 1 · 新规则「容量至少 10」上线后,历史场次(容量 5)经 restore 重建成功、事件 0 个;经 open 创建被拒绝;按 register 重放会再登记 7 个事件

这也是为什么持久化对象不能直接调用领域对象的公开构造函数或 setter 来「填充」状态。重建需要一个专门的入口:静态工厂 restore、包级可见的构造函数,或者 ORM 通过反射直接写字段。选哪一种取决于框架,原则是一样的:重建绕过规则,创建经过规则,两条路径不能混用。

四、版本号 ​

聚合的版本号在每次保存时加 1,保存条件是版本没有被别人推进:

sql
UPDATE session SET version = version + 1 WHERE id = ? AND version = ?

两个请求都从版本 0 加载同一个场次,各自往候补里加一个人:

text
A 写入 2 行(根表版本 + 1 行子表)并提交
B 的 UPDATE 影响 0 行,抛出 ConcurrentModification,回滚
最终版本 1,候补 [a6, a7, x]

两个细节:版本检查放在根表上,子表的任何修改都必须先通过根表的版本检查,否则 B 的子表写入会绕过冲突检测;B 回滚的是整个事务,包括它已经插入的子表行。

在热点聚合上,乐观锁会产生大量冲突,那时要换成悲观锁或条件更新,对比数据见 从统一语言到限界上下文。

DDK 的做法:聚合的版本号映射到持久化对象的 @Version 字段,由 MyBatis-Plus 的乐观锁插件拼上版本条件;通用仓储检查影响行数,为 0 时抛出 ConcurrentUpdateException 且不发布事件,见 DDK 通用仓储。

五、子表怎样保存 ​

给一个已有 1000 个参会人的场次新增 1 人,两种保存方式:

text
按差异保存(只插入新增的行):写入 2 行,约 1ms
整体替换(先删后插):写入 2004 行(根表 1 行、删除 1001 行、插入 1002 行),约 60ms

整体替换实现最简单:不需要知道哪些行变了,把子表清空再全部写回即可。它的代价随子集合大小线性增长,而且会让子表行的主键、创建时间、审计字段在每次保存时都变化。按差异保存需要知道加载之后发生了什么变化,也就是变更跟踪:自己在聚合里记录新增、删除和修改,或者依赖 ORM 的脏检查。

子集合很大时,还要回头看聚合边界:一个场次有几万个报名,每次报名都加载并保存整个集合,说明这个集合也许不该完整地放在聚合里,只保留计数、用唯一索引兜底重复,是 聚合边界 里讨论过的另一种选择。

六、值对象怎样存 ​

值对象没有自己的身份,存储方式有三种,都是设计判断,这里没有做对比实验:

方式适合注意
拆成列(fee_amount、fee_currency)单个值、需要按它查询或建索引最常用;列名加前缀避免冲突
JSON 列结构复杂、很少按内部字段查询要按内部字段查询时需要生成列加索引
独立的子表值对象的集合子表没有业务身份,整体替换通常可以接受

无论哪种方式,读回来时都要重新走值对象的构造函数,让规范化和校验再执行一遍。如果历史数据里有不合法的值(例如早期没有校验的手机号),这一步会失败,需要先清洗数据,或者为历史数据单独提供一个宽松的解析入口。

七、继承的三种映射 ​

报名平台有三种票:免费票没有价格,付费票有价格,团体票有价格和最少人数。领域建模 一文讨论过要不要用继承;如果确实用了,存储有三种方式:

  • 单表:所有票种放在一张表里,价格和最少人数可为空,用 type 区分,用 CHECK 约束补回每个子类型的必填列;
  • 每个具体类一张表:免费票、付费票、团体票各一张表,每张表的列都是非空的;
  • 父表 + 子表:公共列放父表,付费和团体各有一张子表,按主键关联。

每种票 3 万张,共 9 万张票,两个查询:

单表每个具体类一张表父表 + 子表
场次 42 的全部票种(300 行)一次索引查找,约 0.4ms3 个分支的 UNION ALL(Append),约 0.4ms两层左连接,约 0.6ms
价格高于 450 元的付费票(3018 行)(type, price) 索引范围扫描,约 2.5ms只查付费票表,约 2.2ms子表索引扫描再回父表取名称,约 2.4ms
数据 + 索引9.5 MB10.6 MB11.6 MB
付费票缺少价格CHECK 约束拒绝列非空,天然拒绝父表插入 type='PAID'、不插子表,成功
同一个 id 出现在两种票里主键拒绝两张表各插入一次,成功主键拒绝
单表st_ticketprice、min_size 可空 + CHECK多态查询:一次索引查找缺价格的付费票:拒绝重复 id:主键拒绝每个具体类一张表ct_free / ct_paid / ct_group每张表的列都非空多态查询:UNION ALL缺价格:列非空,拒绝重复 id:两张表都成功父表 + 子表jt_ticket + 两张子表按主键关联多态查询:两层左连接只有父行的付费票:成功重复 id:主键拒绝
图 2 · 9 万张票的规模下三种映射的查询耗时都在 0.4—2.5ms;差别在约束:单表用 CHECK 拒绝缺价格的付费票,每个具体类一张表允许跨表重复 id,父表加子表允许只有父行的付费票

在这个规模下,查询耗时的差别不足以决定选择。更有意义的是最后两行:

  • 单表把约束写进 CHECK,数据库能守住「付费票必须有价格」。MySQL 从 8.0.16 起执行 CHECK 约束,更早的版本会解析但忽略它。
  • 每个具体类一张表,每张表的约束最严格,但跨表的 id 唯一性没人管,按 id 查一张票不知道去哪张表找;多态查询要写 UNION。
  • 父表 + 子表最接近类的结构,但数据库不保证「付费票一定有子表行」,需要应用在同一个事务里写两张表;每次读取完整的票都要连接。

还要考虑变化:新增一种票,单表加列(可能要改 CHECK),具体类表加一张表并修改所有多态查询,父子表加一张子表。子类型之间差异越大、越独立变化,越倾向于分开存;差异只是几个可空字段时,单表加 CHECK 往往最省事。

八、外键 ​

外键不是信仰问题。单库、强一致、数据量适中时,外键能以很低的成本守住引用完整性,实验中父表 + 子表的映射就用了外键。以下几种情况,团队常常选择不建外键:

  • 数据已经或即将分库,外键无法跨库;
  • 大表在线变更、数据迁移时,外键会让操作顺序和锁的范围变得复杂;
  • 高并发写入时,外键检查带来额外的锁。

不建外键时,要有替代手段:应用层在同一事务里校验,定期对账找出孤儿数据,并且有人负责清理。「虚拟外键」如果只是一句约定,孤儿数据迟早会出现。

九、常见误区 ​

  • 「为了 ORM 方便,给领域对象加上 public setter」:任何代码都可以绕过规则修改状态;重建应该走专门的入口。
  • 「重建就是按顺序重新调用一遍业务方法」:历史状态不一定满足今天的创建规则,重放还会重复登记事件。
  • 「聚合就对应一张表」:场次聚合就对应两张表;也可能几个值对象被压进同一张表的几列里。
  • 「继承映射选最快的那种」:规模不大时差别很小,先看约束和变化方向。
  • 「查询也走仓储,保证一致」:仓储加载完整聚合,用来做列表和报表代价很高,见 CQRS。

小结 ​

领域模型落到数据库时,要守住三件事:重建和创建是两条路径,重建不执行创建规则、不登记事件;版本号放在根上,子表的修改也要经过它的冲突检测;子表按差异保存,集合过大时回头审视聚合边界。继承映射按数据库能替你守住的约束和将来的变化方向选择。下一篇讨论上下文之间怎样集成:契约、防腐层与可靠事件。


配套实验

参考资料

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