企业应用架构08:聚合映射、继承与Lazy Load
加载合同列表时,页面需要金额,却不一定需要每行费用明细。把明细延迟加载可以减少一次未使用的数据读取,也可能在遍历时变成一百次查询。企业应用架构07:Identity Map与Unit of Work 已把合同和明细放进同一提交范围,本篇继续检查映射、加载时机与实际 SQL 数量。
对象结构不必逐个对应表
Money 由金额和币种组成,Period 由开始与结束时间组成,它们已经作为合同表里的多列保存。这符合 Embedded Value 的处理方式:值对象仍保留在代码中,数据库不必为了每一个小对象创建独立主键和独立表。
合同明细需要多行,本例使用 contract_line 子表,由合同映射过程一起加载和保存,接近 Dependent Mapping。明细没有独立业务仓储,身份只在合同内通过行号表达。这个选择取决于当前生命周期,不能由“一对多”自动推导任何子对象都属于同一聚合。
Complete 表示已加载完成的合同与明细,构造时检查明细非空,明细金额之和等于合同总额。列表复制为不可变值。这个检查补充了数据库约束:外键只保证明细引用存在的合同,单行非负 CHECK 不会自动计算跨行总额。
flowchart LR
A[Complete 合同聚合] --> H[合同头部]
A --> L[明细列表]
H --> V[Money与Period值对象]
V --> T[合同表内嵌金额、币种、起止列]
L --> C[明细子表]
T --- C
A --> I[总额等于明细之和]
当前明细删除重建沿用上一章工作单元。输入变更先通过 replaceLines 生成新总额,再在同一数据事务里保存头部和子行;读取则通过 Complete 检查组装结果。这种写入和读取对称性,能让绕过聚合的写入在读回时暴露。
延迟加载绑定了一个生命周期
Lazy 对象先持有合同头部和当前 SQL 会话,第一次 complete 才查询明细并构造完整聚合。已经完成加载的结果会缓存,重复访问不会再次查询。这里采用显式方法,没有字节码增强、透明代理或 ORM 框架行为。
未加载明细时关闭会话,后续 complete 明确抛异常,并且不会尝试临时打开新连接。临时开连接看似方便,却可能在不同事务快照下取得明细,还会让普通属性访问产生难以预期的资源操作。本例把失败留在调用者可见的位置。
已经通过批量读取形成的 Complete 是不可变值,连接关闭后仍可读取。实验分别验证“未加载对象会失败”和“完整结果仍可用”,避免把所有脱离会话的对象一概判为非法。重要的是它是否还需要执行数据库操作。
加载边界也应该反映在应用接口中。展示合同摘要可以只返回头部投影;展示明细的用例应在服务结束前取得完整结果。把带会话的 Lazy 对象直接返回给任意调用者,会把数据库生命周期扩散到界面层。
用一、十、一百份合同测量查询数
数据集固定为一百份合同,每份两行明细,总额均为 25.00。每个规模使用新的连接,先执行一次合同列表查询,再逐份触发明细加载。查询计数在 JDBC executeQuery 路径增加,不根据对象数量模拟日志。
结果分别为二、十一和一百零一次 SELECT,也就是一次列表查询加每份合同一次明细查询。数据量为一时,这个问题不明显;规模增加后,查询次数随合同数一起增长。该轨迹是本实验真实执行的 N+1。
| 合同数 | 逐份延迟加载 | 批量读取 |
|---|---|---|
| 1 | 2 | 2 |
| 10 | 11 | 2 |
| 100 | 101 | 2 |
批量版本先查目标合同,再一次查询这些合同的全部子行,按合同身份分组组装。它没有用一个大连接结果重复传输所有头部,也没有逐个补查缺失明细。每个规模都比较完整聚合值与延迟版本相等,确认减少查询时没有丢掉业务数据。
sequenceDiagram
participant U as 列表用例
participant L as 延迟方案
participant B as 批量方案
participant D as H2
U->>L: 读取N份完整合同
L->>D: 查询合同头部
loop 每份合同
L->>D: 查询该合同明细
end
U->>B: 读取相同N份完整合同
B->>D: 查询合同头部
B->>D: 一次查询目标合同全部明细
B-->>U: 分组并验证聚合
两条 SQL 不是普遍最佳值。批量读取会提前取得所有明细,并增加一次结果集大小;只需要摘要时,它可能做了多余工作。这里测量的是已知用例要求全部明细时的查询次数,没有测试网络延迟、内存峰值或生产数据库执行计划。
两个 SELECT 使用 READ_COMMITTED,并非自动得到跨语句一致快照。如果并发事务在头部和明细查询之间提交变化,仍可能观察到不同时间的数据。本次数据在测量阶段不变,计数正确不能替代并发一致性验证。
绕过聚合修改子行会怎样
实验直接用 SQL 把 C001 第一行金额改成 99.00 并提交,合同总额仍是 25.00。所有单行非负与外键约束都满足,数据库允许这次写入;随后完整聚合加载检测到总额不一致并失败。
这个反例说明读取校验是检测手段,并没有阻止坏状态先进入数据库。修复通过上一章 Work 的 replaceLines 执行,把两行明细恢复为 20.00 与 5.00,并同步写入总额。新连接再次读取,结果与破坏前的完整聚合相等。
若实际系统必须禁止任何写入来源破坏总额,需要进一步限制写权限或设计数据库侧约束方案。当前实验拥有直接 SQL 权限,因此不声称聚合方法不可绕过。代码中的所有权、数据库权限和数据库约束是不同层次的措施。
再比较两种继承映射
为了观察继承映射,另建一个隔离的费用教学层次:Fixed 保存固定金额,Daily 保存日费率和天数。它们都能计算总额,但没有接入现有租赁定价规则;这避免为演示继承临时修改已经冻结的小时报价契约。
单表方案使用 single_fee,kind 区分类型,固定金额与日费率、天数分别占列。CHECK 约束要求 FIXED 只具有固定金额,DAILY 具有合法费率和正天数。读完整层次只需一张表,但部分列对另一类型没有意义。
类表方案使用 fee_base 保存共同身份、类型和币种,fixed_fee 与 daily_fee 保存各自字段。读取时连接基础表与两个子表,再按类型构造对象。共有字段没有重复保存,组装需要额外处理子表存在性和类型一致性。
两种方案各写入同样的两份费用对象,再用新连接读回,比较具体子类型、字段和计算总额。这里比较的是实际 SQL 往返,不是仅画两套表结构。实例仍由 Java 的封闭类型层次表达,表里的 kind 只负责持久化辨别。
单表负例插入类型与字段形状不匹配的数据,实际触发 CHECK 23513。类表负例只插入 DAILY 基础行而不写子行,外键不能阻止这种方向的缺失,Mapper 检查到缺少子类型后失败并回滚。该异常来自映射校验,不能冒称数据库外键已经检测完整性。
Concrete Table Inheritance 可以给每个具体类型一张包含共同字段的表,本章没有实现它,也没有给它编造查询次数。它会改变共同字段重复和多态查询的组织方式,是否合适取决于查询和演化需求,不能按表数最少直接选赢家。
映射策略与业务边界分别演化
更换单表与类表布局,可以尽量保持费用对象接口不变,但数据迁移、旧行兼容和约束同步仍是具体工作。本章在临时数据库中创建两套新表,没有进行既有生产数据的在线迁移,也没有比较索引或存储成本。
将来明细若获得独立生命周期,当前从属映射可能需要调整;若只新增一个展示字段,则未必需要改变聚合。应该先确认业务引用关系,再讨论表和加载方式。把数据库连接结果机械地当作领域聚合,会让存储便利性替代领域约束。
计数要绑定查询范围
每个规模的计数从新的 SQL 会话开始,数据准备和建表使用其他连接。表中的数字只计算读取完整合同所需的 SELECT,没有把初始化的一百次插入混进去。完整日志仍保留准备语句,读者可以按连接编号区分阶段,不能只截取一个总数就当作优化证据。
批量子查询限定同一租户和相同数量的合同身份,并按合同号、行号排序。分组时如果缺少某份合同的明细,构造 Complete 会因空明细失败,不会默默丢掉这份合同。比较完整值也包含金额、币种和区间,不只比较合同数量。
当前所有费用明细沿用合同币种,没有单行跨币种换算。继承示例的费用对象各自包含币种,是独立的教学数据集。两部分都有金额字段,但不能因此把费用类型映射实验直接解释为已经支持混合币种合同。
运行与证据
在此前相同工具环境中执行:
1 | |






