同一个用例先通过合同查询拿到对象,又通过其他业务路径拿到相同主键的对象,两份对象可能各自修改。数据库只有一行,内存里却有两份状态,后保存的旧值可能覆盖先保存的新值。企业应用架构06:Repository、Gateway与查询对象 解决了查询表达,本篇把对象身份与一组数据库写入放到明确的工作单元内。

实例身份与数据库身份是两件事

Identity Map 记录一个业务事务中已经加载的对象,使相同身份再次出现时返回已有实例。Fowler 的目录条目强调避免同一对象加载两次并被不一致地更新。它首先约束当前工作范围中的对象实例,不能由此推导跨线程、跨进程只有一个对象。

Unit of Work 记录业务事务中需要写回的变化,并协调写入。两者经常协作:身份表避免同一工作单元出现多个受管实例,工作单元知道哪些受管实例变了。一个缓存 Map 加一个名字叫 commit 的方法仍不够,提交是否操作同一个数据库事务必须实际检查。

本章 Work 持有一个 JDBC 连接、按租户与合同身份索引的身份表,以及待保存对象集合。Managed 是当前工作单元管理的合同实例。合同头部仍使用上一章的不可变 Contract,明细使用不可变 Line 列表;修改行为替换值,再把实例登记为待保存。

flowchart LR
  U[一个租赁用例] --> W[Work:一个 JDBC 连接]
  W --> I[身份表:租户和合同身份]
  I --> M[唯一受管实例 Managed]
  M --> H[合同头部值]
  M --> L[明细不可变列表]
  M --> D[待保存集合]
  D --> F[flush:合同与明细 SQL]
  F --> C[一次 commit]

租户必须进入身份键。若只按合同号索引,同一工作单元加载两个租户下相同合同号时可能错误共享实例。当前样本只用一个租户,但实现的键仍明确包含两部分;这属于身份约定,不能用“当前数据刚好不重复”替代。

先重现没有身份表的覆盖

实验先直接调用 Mapper 两次,两个返回对象值相等,引用却不同。第一次写入把金额改成 40.00,第二次使用旧对象保存原来的 25.00,随后提交。新连接读回 25.00,说明第一次修改确实被后一次覆盖。

这个反例在同一线程、同一数据事务内就能出现,不需要并发调度。问题来自两个内存表示各自携带不同的新旧状态。用数据库锁包住整个事务也不能自动合并这些对象修改,调用顺序仍会决定最后写入哪一个值。

加入 Work 后,第一次 find 加载合同和明细,执行两条 SELECT;第二次 find 返回同一引用,不增加查询次数。断言同时检查引用相同和计数不变,避免把“重新查询后值碰巧一样”误认为身份表生效。

修改通过受管对象的 replaceLines 完成。输入必须非空,单行金额非负;合同总额由明细求和生成,并保持合同币种。样本把明细改成 25.00 租金与 5.00 费用,总额为 30.00。这里的费用只是测试明细,没有引入新的实际租赁收费规则。

写入顺序与提交所有权

提交时,Work 遍历待保存实例,更新合同头部,删除旧明细,再插入新的明细,全部成功后调用一次数据库 commit。Mapper 和对象行为都不自行提交,因此合同头部与明细可以处在同一事务中。

删除重建明细是本实验的简化选择。它减少了比较新增、删除与修改行的代码,但可能增加写入量,并且不适合被其他聚合引用的子行身份。当前明细只在合同内部用行号定位,没有公开独立仓储,也没有跨合同引用。

sequenceDiagram
  participant U as 用例
  participant W as Work
  participant D as H2事务
  U->>W: 修改合同与明细
  U->>W: commit
  W->>D: UPDATE合同头部
  W->>D: DELETE旧明细
  W->>D: INSERT新明细
  alt 全部成功
    W->>D: commit一次
  else 中途约束失败
    D-->>W: SQLException
    W->>D: rollback一次
  end

正常路径实际提交一次,随后关闭当前连接,打开新的工作单元读取。新对象引用与之前不同,但金额为 30.00,明细恰好两行。这同时证明数据已经提交,以及身份表范围没有扩散成全局单例。

身份表应与工作单元一起结束。若把它作为长期缓存复用,旧对象可能在数据库已经更新后仍被返回;若跨请求共享可变实例,还会引入线程安全问题。本例没有实现二级缓存,也没有给工作单元定义跨线程使用方式。

在真正写入之后注入失败

失败场景先加载已提交的合同,把状态改成取消,并把明细总额改成 99.00。flush 先完成合同更新、旧明细删除和合法明细插入,之后故意插入负金额子行,让数据库 CHECK 报错。故障发生前已有真实写入,才有条件检查它们是否一起回滚。

H2 返回 SQLSTATE 23513,Work 捕获异常后调用 rollback。计数断言要求提交次数为零、回滚次数为一。再开新连接,合同仍为 RESERVED、金额仍为 30.00,明细仍是原来的 25.00 与 5.00,两种状态都没有留下局部变更。

只观察 catch 分支执行了不够,因为异常可能发生在任何写入之前,也可能只撤销最后一条语句。这里保留每条实际 SQL、受影响行数和新连接读回结果,使失败点与回滚范围能够对应起来。

数据库回滚不等于 Java 对象自动恢复。失败时受管实例内仍可能保留 99.00 和取消状态,本章把整个工作单元标记为结束,禁止继续修改或提交。需要重试时必须重新加载,不能拿失败后的对象继续假定它代表数据库当前事实。

脱离工作单元的对象怎样处理

实验保留第一次工作单元中的 Managed 引用,进入新工作单元后调用 attach。实现检查对象的 owner 和身份表中的引用,确认它属于别的工作单元后拒绝,不产生提交。旧工作单元已经结束,继续对旧对象调用 cancel 也会失败。

这是一条明确而保守的生命周期策略。其他框架可能支持合并脱管对象,但合并需要解决哪些字段被用户修改、原版本是什么、数据库是否已有变化。不能只把旧对象放进新身份表,就声称完成了安全的重新附着。

读取值与写入资格也需要区分。调用者可以检查已经持有的不可变 Contract 值,或把它作为展示快照;它不因此获得新事务的更新权。受管实例的方法负责检查拥有者状态,返回的值对象没有隐藏数据库会话。

当前 Work 只登记显式行为造成的修改,不扫描任意字段,也不自动检测通过反射进行的变更。它没有实现新建对象登记、聚合删除、级联规则和通用对象图遍历。有限职责使本章可以逐条核对 SQL 顺序,但不能冒充完整 ORM 会话。

身份表仍不能阻止并发覆盖

两个独立工作单元各自拥有一个正确的受管实例,仍可能同时读取旧版本并各自提交。当前 UPDATE 只按租户和合同身份定位,没有版本条件,所以跨事务丢失更新尚未解决。身份映射与数据库并发控制覆盖的是不同问题。

READ_COMMITTED 也不会自动提供整个用例的固定快照。当前合同与明细的两次读取由测试按固定顺序安排,没有并发写入。若另一个事务在两次读取之间提交,可能需要更强的一致性协议或聚合版本校验;本章没有把顺序测试推广为该保证。

资源关闭时,尚未结束的工作单元会回滚其事务,再关闭连接。只读工作单元也遵守这一规则,日志中的回滚不等于发生了业务失败。判断成功数据提交次数时,应检查实际写工作单元的连接编号,不能把整个测试所有连接的计数混加。

让失败结果可以检查

测试中的计数在实际 JDBC commit 或 rollback 返回后增加,不在调用前预先登记成功。因此日志里的提交次数表示驱动调用已正常返回;遇到连接中断时,这仍不能替代数据库侧查询结果。当前故障是本机可确定的约束错误,事务结果没有进入网络层的不确定状态。

待保存集合以受管实例登记,相同实例修改两次不会被当作两个对象重复 flush。输入明细复制为不可变列表,外部持有原列表也不能在提交前悄悄增加一行。对象生命周期、可变别名和事务提交分别受到明确约束,不能只靠一个最终金额断言覆盖全部行为。

本章追加的工作单元没有接入原应用所有用例。它复用既有值类型和 SQL Mapper,展示合同与明细的提交边界;全局请求回放、维修变化与成功后事件发布仍遵守原基线各自的范围,尚未迁入这个数据库事务。

运行与证据

准备此前冻结的 JDK 21、H2 与 schema-v1,执行:

1
bash examples/enterprise-application-architecture/labs/07/run.sh
本章实验包 包含前置源码。正常入口执行十三项断言,涵盖重复实例覆盖、同会话身份、新事务读回、中途失败回滚和跨工作单元拒绝。原始日志及退出码位于 evidence/07,SQL 来自实际 JDBC 调用。

数据保存到临时 H2 文件,读取使用新连接,编译保持严格警告检查。实验没有测试进程重启、数据库网络断开或不确定提交结果;这些故障也不能由一次显式 rollback 成功推导。

参考资料