领域模型里的集合,背后是一次查询
场次聚合有一个方法
register():先检查registrations.size() < capacity,再把新报名加进集合。代码和领域模型的图一模一样,单元测试也很干净。上线后,热门场次的报名接口越来越慢;后来又发现有一个场次超卖了。
在内存里,List 的 size() 是读一个字段,add() 是往数组里放一个引用。领域模型用集合表达「一个场次有很多报名」,这在概念上完全正确。但当这个集合由 ORM 映射到数据库时,每一个看起来很便宜的操作都可能变成一次查询,甚至是一次加载上千行的查询;更隐蔽的是,有些修改不会触发你以为会触发的并发控制。
本文用 Hibernate 7.4.13 与 PostgreSQL 18,在一个 1000 人报名的场次上,逐个统计集合操作执行的 SQL 条数和加载的对象数。实验见文末。
一、size() 检查名额:为了一个数字加载了 1000 个对象
void registerBySize(String attendee) {
if (registrations.size() >= capacity) throw new IllegalStateException("名额已满");
registrations.add(new Registration(this, attendee));
}1000 人的场次,聚合方法里用 registrations.size() 检查名额后报名:SQL 3 条,加载实体 1001 个
用查询回答「报了多少人」:SQL 1 条,加载实体 0 个懒加载的集合在第一次被访问时整体初始化:size() 触发了一条查询,把 1000 条报名全部读出来、建成 1000 个对象放进会话,只为了得到一个数字。报名越多越慢,而且这些对象会一直留在会话里,直到事务结束。
规则本身(「报名人数不超过名额」)没有错,错在规则依赖的数据是用最昂贵的方式取得的。有三种改法:
- 计数器:场次上维护
registeredCount,报名时检查并加一。集合不必加载。 - 查询端口:仓储提供
countRegistrations(sessionId),应用服务查出来传给聚合,或聚合通过一个接口去问。 - 数据库约束兜底:唯一索引防止重复报名,计数器配合条件更新(
UPDATE … SET count = count + 1 WHERE count < capacity)防止超卖。
改用场次上的计数器检查名额:SQL 4 条,加载实体 1 个多出来的一条是更新场次(计数器加一、版本号加一),换来的是不再加载集合。
二、只改子项时,根的版本号不变
不检查名额,只往 List(bag)里加一项:SQL 2 条,加载实体 1 个
场次的 @Version:只往 mappedBy 的集合里加一项前后 1 → 1;同时修改计数器前后 1 → 2往 mappedBy 的 List 里加一项,Hibernate 没有加载集合:它知道这是一个没有顺序、允许重复的 bag,新元素直接排队插入即可。看起来是最高效的做法。
问题在第二行。集合由 Registration 一侧的外键维护(mappedBy),场次这一行本身没有任何字段改变,所以场次的版本号没有递增。乐观锁靠版本号发现并发修改:两个请求同时读到场次、各自往集合里加一个报名,提交时都不会冲突。开头那个超卖就是这样来的:size() 的检查在两个事务里都通过了,两次插入都成功了,场次的版本号一次也没变。
两条写路径放在一起对比:
mappedBy 的集合里加一项,场次的 @Version 前后都是 1;同时修改计数器时变成 2。并发部分是示意:版本号不变,乐观锁就没有可比较的东西,两次 size() 检查都通过,两条报名都写入聚合的意义是「对聚合内任何部分的修改都经过根,并由根保证规则」。用版本号做并发控制时,这条原则落到存储上就是:只改子项的写路径,也必须让根的版本号变化。做法有几种:
- 让聚合方法同时修改根上的字段(计数器、最后修改时间),像上面的
registerByCounter。 - 用
LockModeType.OPTIMISTIC_FORCE_INCREMENT加载根,强制递增版本号。 - 在仓储里把「子项写入」和「根版本号检查与递增」放进同一个事务,作为保存聚合的唯一路径。
无论哪一种,都要有一个并发测试:两个事务读到同一个版本,各自只修改子项,断言只有一个提交成功。只修改子项的路径最容易在评审时被漏掉,因为它在单线程下完全正确。版本号的其他细节见持久化与重建。
三、Set 和 List 的行为不同
往 Set 映射的候补里加一项:SQL 3 条,加载实体 1001 个同样是往集合里加一项,Set 加载了整个集合。原因在语义上:Set 承诺不重复,判断新元素是否已经存在,需要先知道已有的元素,所以必须初始化。List 映射成 bag 时不承诺唯一,也就不需要加载。
这个差别会反过来影响建模选择。如果业务规则是「同一个人不能重复候补」,用 Set 表达很自然,但每次加入都会加载全部候补;更好的做法是让数据库的唯一索引守住这条规则,集合只是一种读取视图。集合类型选的不只是数据结构,还有 ORM 在每次修改时要做多少工作。Set 依赖元素的 equals 和 hashCode,实体的相等规则见实体与值对象。
四、遍历多个聚合的集合:N+1
查出 50 个小场次,逐个读 registrations.size():SQL 51 条,加载实体 200 个
同样的遍历,查询时 join fetch 集合:SQL 1 条,加载实体 200 个一个列表页显示每个场次的报名人数,代码写成「查出场次,再逐个读集合」,就是 1 条查询加 50 条集合查询。加载的对象数一样,差别在往返次数。join fetch、@BatchSize 或者实体图都能把它合并。
但这种场景更根本的问题是:列表页不需要聚合。它要的是一个投影(场次名、报名人数),直接用查询返回 DTO,既不加载报名对象,也不需要考虑抓取策略。用聚合去服务列表查询,是让一个为「保证修改时的规则」设计的结构去做它不擅长的事。读写分开的程度怎样选,见 CQRS 不必过度设计。
五、清空集合:逐行删除
orphanRemoval 的集合 clear()(1005 项):SQL 1007 条,加载实体 1006 个
50 个小场次的报名用一条批量删除:SQL 1 条,加载实体 0 个orphanRemoval 让「从集合里移除」等于「删除这个对象」,这在领域语言上很贴切:报名从场次里移除,报名就不存在了。代价是 clear() 要先加载全部元素,再一条一条地删。开启 JDBC 批处理能减少往返,但仍然要先加载、逐个处理。
批量删除快得多,但它绕过了会话:已经加载到内存里的对象不知道自己被删了,实体上的生命周期回调和级联也不会执行。所以批量操作适合那些不需要领域规则参与的场景(清理过期数据、取消整个活动后删除报名),并且执行后不要再使用同一个会话里的旧对象。
六、怎样在模型里表达「很多」
集合字段不是唯一的选择。按「这个关系在修改时需要多少数据」来选:
| 情况 | 模型里的表达 | 理由 |
|---|---|---|
| 子项少且有上限,规则需要看到全部子项(订单行) | 聚合里的集合 | 加载全部是必要的,也不贵 |
| 子项多,规则只需要一个汇总值(名额) | 根上的计数器或汇总字段 + 数据库约束 | 不加载集合;修改根即递增版本号 |
| 子项多,规则需要查询(是否已报名) | 仓储的查询方法 / 唯一索引 | 用数据库做它擅长的事 |
| 子项多,有自己的生命周期和规则(报名本身可以支付、取消) | 子项是独立的聚合,用 id 引用场次 | 修改报名不必锁住场次 |
| 列表、统计 | 查询投影 | 不经过聚合 |
最后一类常被忽略:报名人数多到不能整体加载时,往往说明报名本身就是一个聚合,场次只保留它需要守的那条规则(名额)所需的最少数据。边界的推导见聚合边界。
七、测试要证明什么
- 查询次数:对关键用例断言 SQL 条数或加载实体数的上限(Hibernate 统计、数据源代理)。这类回归在功能测试里看不出来。
- 并发下的规则:两个事务同时报名最后一个名额,只有一个成功;只修改子项的路径同样要覆盖。
- 集合语义:重复加入、移除后再加入,在
Set与 bag 上的结果是否符合业务规则。
八、检查清单
- 聚合方法里每一次访问集合,都知道它会触发什么查询。
- 名额、余额这类汇总规则用计数器或查询,不用
size()。 - 只修改子项的写路径也会递增根的版本号,并有并发测试。
- 需要唯一性时让数据库约束守住,不靠
Set的加载。 - 列表与统计用投影,不遍历聚合的集合。
- 批量删除绕过会话和回调,只用在不需要领域规则的场景。
配套实验
- codesphere-labs/ddd/collections-behind-queries:Hibernate 7.4.13、PostgreSQL 18.6、JDK 25.0.4.1:size() 检查名额与计数器、往 bag 与 Set 里加一项、根版本号、N+1 与 join fetch、orphanRemoval 的 clear 与批量删除(验证记录)
参考资料