11.1 工作单元

跟踪一次业务事务中受影响的对象,统一协调写出顺序、变更合并和并发冲突。

学习目标

  • 能解释工作单元如何在一次业务事务内收集新增、修改、删除,并在提交时保持写入顺序
  • 能修改一个订单持久化流程,让变更登记、身份映射和失败回滚的责任边界清晰可测
  • 能回答:提交过程中更新失败时,为什么不能留下已插入订单而未更新客户的半成品状态?

为什么 11.1 工作单元 值得单独学习

先想象一次订单结算:页面让订单、客户和库存连续发生变化,最后却只成功写入其中一部分。读者看到的可能只是一个错误提示,但系统已经留下了无法解释的半成品。这个章节要解决的就是“谁负责把一组相关变化一起送达”的问题。

没有这样的统一边界,每个对象都可能在自己的时刻保存,调用者也无法知道哪些变化已经落地。结果是重试会重复写入,失败会留下脏数据,审核日志又很难还原当时到底写了什么。

本页以 2024 年中文版公开目录限定 11.1 工作单元 的范围,并依据 Martin Fowler 的作者图书页和模式目录独立重写。它不复现原书正文、插图或代码;目录只决定“要讲什么”,这里的案例、实验、判断题和答案均为本课程原创。

先建立直觉:把一笔业务装进同一个信封

先猜一猜:如果订单已经写入,但客户积分更新失败,系统应该把订单留下,还是让整次结算回到开始前?答案取决于是否存在一个能看见整组变化、最后一次性裁决结果的边界。

这个边界像一个待寄出的信封:业务代码先把要新增、要修改和要删除的内容放进去,过程中可以继续检查规则;只有在信封封口时,所有内容才一起交给存储系统。信封没有封好,就不应把其中一张纸单独寄走。

核心概念:从变化集合到一次提交

什么是工作单元

是围绕一次业务操作建立的暂存边界。它不代替领域对象作业务决定,而是记录哪些对象将被持久化、怎样排序、何时提交,以及失败时怎样回退。这样,调用者不必在每个对象旁边重复编排保存顺序。

它的最小接口通常包含三个登记动作和一个提交动作:登记新对象、登记已修改对象、登记待删除对象,最后执行 commit()。登记动作只改变内存中的待写集合;提交动作才打开写入阶段。把这两个阶段分开,才能在提交前检查重复身份、缺失关联和版本冲突。

变更登记不是自动侦测

是工作单元能够可靠工作的前提。显式登记适合边界清楚的代码:registerNew(order) 表示插入,registerDirty(customer) 表示更新,registerRemoved(oldItem) 表示删除。若框架选择自动侦测,也必须能解释它如何发现对象变化,以及发现失败时怎样报警。

三个集合不只是实现细节,它们表达了写入意图。新增对象通常要先获得身份,修改对象需要确认版本,删除对象要处理被其他记录引用的关系。把三类变化混在一个“待保存列表”里,会让顺序和失败责任重新变得含糊。

原子提交与失败边界

把提交阶段变成一个不可分割的结果:全部写入成功,或全部回滚。它依赖底层事务能力,但工作单元仍然负责把对象变化组织成一个可执行计划,并在错误处停止后续写入。

“原子”不等于“永远不会失败”。数据库约束、网络断开、版本检查或外部服务都可能让提交失败;模式的价值在于失败发生时,调用者仍能知道哪些变化属于同一组,并能让存储层回到提交前的状态。若底层不支持回滚,就必须把限制写进选择记录,而不能假装工作单元已经提供了原子性。

生命周期中的三个协作者

身份映射让同一对象只出现一次

解决的是对象访问的一致性,而不是提交顺序。一次结算中,如果订单被加载两次,工作单元应当看到同一个对象引用;否则第一次修改可能落在一个实例上,第二次登记却把另一个实例当成最新状态。

身份映射通常与工作单元共享生命周期:工作单元开始时建立,工作单元结束时清理。它可以减少重复查询,却不能替代变更登记,也不能把它升级成跨请求的永久缓存。缓存范围越大,过时数据和并发判断越难解释。

并发冲突需要在提交前有答案

常见于两个请求同时修改同一订单。工作单元可以在对象上保存版本号,在提交时把“读取到的版本”作为更新条件;条件不满足,就拒绝本次提交并回滚,而不是默默覆盖另一个请求的结果。

因此,工作单元的边界至少要回答四个问题:谁拥有待写集合,谁决定顺序,谁触发回滚,谁向调用者报告冲突。若这些答案分散在 ORM 回调、控制器和数据库触发器里,表面上仍有一个 commit(),实际却没有可审计的责任链。

用订单聚合走一遍五个阶段

把订单聚合持久化切成“事务范围 → 身份映射 → 对象访问 → 变更集合 → 提交”五个观察点。每个阶段都要能指出输入、输出和失败信号:范围结束意味着登记关闭,身份映射保证同一对象,访问阶段产生领域变化,集合阶段形成写入计划,提交阶段才与数据库交互。

const uow = new UnitOfWork();
const order = await orders.load(orderId);
order.addLine(line);
uow.registerDirty(order);
uow.registerNew(invoice);
await uow.commit();

这段代码的关键不在类名,而在责任顺序:领域对象先产生变化,工作单元再登记变化,最后由一次提交统一执行。若 load 返回的对象没有纳入当前范围,或 commit 内部仍然逐对象自动保存,代码看似简洁,实际已经绕过了模式的边界。

三步交互实验:观察登记与提交

下面的分步图把同一笔订单从登记到提交的状态变化拆开。先预测:把第三步的提交顺序改成“更新客户 → 插入订单 → 删除旧条目”,当订单身份尚未建立或某一步失败时,会比图中的顺序更安全吗?用每一步的图示检查你的答案,再用练习中的代码修改题复现它。

分步1 / 3

1. 打开范围:登记新增对象

业务操作开始,工作单元建立本次范围;订单先进入新增集合,尚未向数据库写出。

Unit of Work:登记变化 → 原子提交阶段一:登记新增对象,暂不写库1. 登记2. 收集3. 提交业务代码工作单元数据库registerNew(order)new[]registerDirty(customer)dirty[]registerRemoved(old)del[]commit()INSERT → UPDATE → DELETE全部成功;失败则全部回滚当前焦点:new[] 暂存订单工作单元把变化收集起来,再以可检查的顺序统一写出
1 步:阶段一:登记新增对象,暂不写库。同一张专属图在分步实验中逐步突出登记、收集和提交状态。

常见误区

选择与拒绝矩阵

评审问题选择工作单元的证据应拒绝或改用其他方案的信号
责任一个范围拥有全部待写集合并触发提交控制器、对象和数据库触发器各自保存
顺序新增、修改、删除的依赖顺序可检查只能依赖 ORM 回调的偶然顺序
失败任一错误都会停止并回滚整组变化只能看到最终错误,无法定位首个失败
并发版本条件在提交时检查并能重试后写入无条件覆盖先写入
替代已比较直接事务脚本、活动记录和映射器只因为框架自带就跳过边界分析

本章小结

  • 工作单元收集一次业务操作中的新增、修改和删除。
  • 变更登记表达写入意图,原子提交表达整组成功或回滚。
  • 身份映射保证范围内的同一身份只对应一个对象。
  • 并发冲突应在提交时被拒绝、回滚并报告,而不是静默覆盖。
  • 只有当范围、顺序、失败和替代方案都可审计时,才值得采用该模式。

本章练习

练习

问题 1: 订单结算包含“新增订单、修改客户积分、删除旧草稿”三个变化。请写出三个登记动作,并说明为什么提交阶段不能只调用一个没有类型的 saveAll()

问题 2: 修改下面的 Demo 代码,使客户积分变化也进入同一次提交,并让测试能够验证登记顺序。

const uow = new UnitOfWork();
const order = await orders.load(orderId);
order.addLine(line);
uow.registerDirty(order);
await uow.commit();

问题 3: 两个请求同时读取订单版本 7;请求 A 先提交成功,请求 B 随后提交失败。工作单元应该怎样处理 B?什么时候可以自动重试?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

工作单元

围绕一次业务操作收集受影响对象,并在最后统一安排写入和回滚。

变更登记

把对象放入新增、修改或删除集合,让提交阶段知道要执行哪类写入。

原子提交

一组相关写入要么全部成功,要么全部撤销,不留下中间结果。

身份映射

在一个工作范围内让同一持久化身份只对应一个内存对象。

并发冲突

两个操作基于同一旧状态提交不同修改时产生的竞争,需要检测、重试或拒绝。

出处声明

前后导航

讨论

评论区加载中…