软件建模25:Aggregate与一致性边界
两个租赁合同各自合法,却可能承诺同一台设备在同一时间交付。合同甲租十点到十二点,合同乙租十一点到十三点;即使合同都有身份、金额和完整字段,把它们分别保存成功也不能保证设备不被重复占用。
计划、资源与承诺 已区分计划和资源承诺。本篇进一步检查承诺写入的竞争条件:先给出两个线程都会确认的反例,再把冲突检查与状态更新放进一个明确边界。时间仍采用 UTC 半开区间,实验不引入数据库或分布式锁。先写出需要同时成立的条件
本例要求同设备的任意两条活跃预留不重叠。请求编号在整个租赁点日历内唯一:同编号同负载返回原结果,改设备或租期必须拒绝。取消只释放活跃承诺,历史回执保留;旧成功请求重放不能重新占用,旧拒绝请求也不能因空位出现就变成成功。
这几条条件共同决定需要保护的数据。若冲突检查只读预留表,写入却分开更新回执和占用,其他调用可能看见半完成状态。若取消删除回执,重试又可能重新建单。一个合理边界应让调用者只能看到操作前或操作后的完整状态。
Eric Evans 的《DDD Reference》2015年版在 Aggregates 中要求定义聚合整体的不变量,由根或指定机制维护,并让事务与分布安排遵循这个边界。原文,印刷页16 本例据此选择资源预留日历;它是场景推导出的候选,并非所有租赁系统都应采用的固定模板。
合同边界为什么没有保护设备
一个诱人的方案是让合同持有设备、付款、维修、费用和预留的全部对象。这样能从合同一路导航到相关资料,但对象关联并不自动说明哪些更新必须原子完成。两份不同合同仍可能访问同一个资源,而各自根上的锁互不相干。
实验保留了 ContractCandidate:每个合同对象都有同步方法,两者共享一个线程安全的预留列表。方法先判断重叠,再写入。列表选用 CopyOnWriteArrayList,以免容器本身的数据竞争干扰观察;需要验证的是“先查后写”这组操作能否维持排他条件。
sequenceDiagram
participant A as 合同甲线程
participant L as 共享预留列表
participant B as 合同乙线程
A->>L: 检查 E1 的十至十二点
L-->>A: 无冲突
B->>L: 检查 E1 的十一至十三点
L-->>B: 无冲突
Note over A,B: 两线程均到达检查后的屏障
A->>L: 插入甲的预留
B->>L: 插入乙的预留
Note over L: 两条活跃区间发生重叠
两个真实工作线程在完成检查后调用同一个 CyclicBarrier。只有两者均到达,才允许继续写入。因此该失败不依靠碰巧切换线程,也没有用静态数组冒充并发。标准库文档明确规定了屏障的等待行为及其内存一致性效果。Java 21 CyclicBarrier
日志同时记录线程名、读取结果和插入动作。本次运行中两次读取都是 conflict=false,两次结果都是 CONFIRMED,最终活跃数量为二。具体插入顺序可以变化;只要两个读操作都先于任一写操作,这个反例就成立。给每份合同加锁没有覆盖需要协调的共享事实。
将预留和回执放进一个日历
正确候选 ReservationCalendar 以租赁点日历编号作为根身份,保存请求回执。每条 Receipt 含原请求、原结果和当前是否活跃;拒绝结果不能带活跃标志。取消把标志置为假,从而让占用变化与回执保留在同一记录中表达。
flowchart TB
U[应用调用] --> R[ReservationCalendar 聚合根]
subgraph B[同一实例监视器保护的边界]
R --> H[按 RequestId 保存 Receipt]
H --> Q[原请求与原 Outcome]
H --> A[当前 active 标志]
R --> C[重放检查 加 重叠检查 加 更新]
end
R --> S[不可变 Snapshot]
U -. 边界外 .-> E[设备生命周期]
U -. 边界外 .-> D[合同及费用]
reserve、cancel、snapshot 都是同步实例方法。预留操作取得同一监视器后,先检查历史编号,再计算冲突,最后写入完整回执。第二个线程进入时能看到第一个线程留下的活跃记录。JLS 规定同一监视器一次只能被一个线程持有,解锁先行发生于后续加锁。JLS 21,17.1、17.4.5
正确版本的屏障放在调用同步方法之前,仅用于同时发起竞争。把屏障放进同一锁内部,会让第一个线程等第二个线程,而第二个线程又无法取得锁。这会测试出死锁,不会证明业务排他条件。实验为屏障、结果等待和线程退出都设置超时,失败不会伪装成无限等待中的成功。
实跑结果是一条确认、一条拒绝,活跃数量为一。测试不指定哪个请求胜出,因为当前合同没有先到先得或公平排队的保证。并发用例提供可重复观察,互斥范围的代码检查则说明为什么另一个交错也不能越过临界区;有限运行次数本身不是所有线程调度的数学证明。
日历粒度需要说明代价
这个根包含租赁点的多台设备,所有预留共用一把锁,因此设备 E1 的操作也可能等待 E2。选择较粗边界,是为了在小实验中同时维持设备排他和日历范围的请求编号唯一。实验没有测吞吐量,不能据此判断真实规模下是否合适。
若以后按设备拆成多个聚合,同设备排他可以落在各自日历内,但跨设备复用请求编号的检查就移出了边界。需要另定编号作用域,或让应用协调全局回执与分片更新。直接换成多个锁而保留原有承诺,会漏掉不同设备使用同编号的反例。
合同大聚合也有相反的代价。把所有相关事实装进合同,既可能扩大锁定范围,又仍然保护不到另一份合同引用的资源。确定聚合时应分别问:哪个条件不能暂时失真,哪些资料只是查询需要,哪些结果可以随后传播。图中连线数量无法回答这些问题。
恢复入口也必须守住结构
快照返回复制后的不可变列表,元素由不可变请求、枚举和布尔值组成。外部无法借返回值直接追加占用。restore 创建独立实例,并拒绝重复请求编号、重叠的活跃区间,以及拒绝结果却标记活跃的记录;取消后的成功回执则可以合法恢复为不活跃。
实验验证恢复前后快照相等、旧结果仍能重放,以及修改恢复实例不会改变原实例。这些检查证明内存往返和局部约束,没有证明持久化真实性。没有事件历史时,恢复器也无法判断某次过去的拒绝当时是否确有冲突。
重放测试还包含一个容易混淆的顺序:甲成功,乙因重叠被拒绝,取消甲,然后再次提交甲和乙。甲仍得到原成功回执,但没有重新占用;乙仍得到原拒绝回执。只有使用新编号的丙才能取得已释放区间。返回值描述的是原请求结果,当前活跃标志描述的是资源现状,两者不能互相替代。
同编号改成另一台设备也会被拒绝,而且异常不会修改原有记录。这个反例解释了为何检查历史编号要先于计算当前重叠:若先按当前空位决定结果,再发现编号冲突,错误实现可能已经插入另一条占用。当前方法在所有可能的业务拒绝检查完成后才写入回执,避免这种局部成功。
相同日历编号恢复两次会得到两把独立锁。实验把两个冲突请求分别交给这两个实例,两者都成功。这揭示了应用接入义务:必须保证调用共享同一受保护状态,或在存储边界增加版本与冲突检测。仅仅给类命名为聚合,不会让多份副本自动一致。
本地成功之后仍可能失败
实验还在预留成功后注入“合同写入失败”。异常发生后,日历仍有活跃预留,而合同写入尚未发生。内存临界区没有包住后续操作,更没有数据库回滚能力。设备是否在役、维修是否放行、合同是否保存成功,都属于当前日历之外的事实。
实际应用需要明确这些步骤如何衔接:同库事务、条件更新、失败补偿或后续协调,各自要求不同。当前实验没有实现其中任何一套持久化方案。Evans 对跨边界更新的讨论提供设计方向,但不会替实现自动生成提交、重试和恢复协议。
若日历改存数据库,普通的“查询后插入”仍需要重新分析两个事务的交错,不能只把内存方法改名为事务方法。需要保护的判定始终是指定设备与区间的组合,而非某一行已经存在的合同。这里没有数据库实跑,因此保留这个待实现条件,不把单进程结果推广为数据库隔离级别的证据。
运行与检查结果
仓库入口为 examples/software-modeling/labs/25/run.sh。设置 Java 21 后,从仓库根目录执行 JAVA_HOME=/path/to/jdk21 examples/software-modeling/labs/25/run.sh;脚本使用 --release 21 -Xlint:all -Werror 编译,并先运行原有基线。
本次输出包含基线48项、本章26项检查,正常入口退出零。附加 require-broken-safe 会要求错误候选也满足排他条件,得到断言失败并退出一;这是用于确认反例可被检查器捕获的负入口。完整日志在 examples/software-modeling/evidence/25/,接入约束在 examples/software-modeling/models/25/contracts.md。






