合同写入模型需要检查金额、版本和状态变化;待交付清单则希望直接获得适合展示的列。两种需求长期挤在同一对象里,会使查询越来越依赖内部关联,也会使修改合同的代码背负展示结构。CQRS 把命令与查询所用模型分开,允许它们按各自需求演进。是否把两条路径放在不同数据库、不同进程,仍然需要单独决定。

本实验保留一个合同真值表和一张变化记录表,再用独立 JVM 生成交付清单。停止投影时,写入仍然成功,查询结果会落后;重新运行投影进程后,清单追赶到最新版本。所有状态都写入真实 H2 文件库,水位与读模型在同一事务内推进,删除投影后还能从保留的变化记录重建。

分离到什么程度

Fowler 的 CQRS 文章讨论了分别使用模型处理更新和显示的选择,也提醒这种做法会增加复杂度。简单应用可以先使用同一数据库、同步更新的两个模型;只有查询结构、负载或可用性要求形成实际差异时,才有理由引入异步投影。CQRS 原文

本例把写模型中的合同金额与展示用的交付清单分成两张表。合同表含版本,变化表记录序号、合同编号和金额;清单只保留查询需要的编号与金额。两张表在同一个文件数据库中,但投影由另一个进程执行。这足以暴露延迟与重建问题,不需要为了展示读写分离先增加一套数据库集群。

同步双模型可以在更新合同的事务里同时更新清单,提交后两者立即一致。代价是写事务要承担投影的更新和约束,投影故障也可能阻塞合同修改。异步方式缩短了这部分耦合,却把“已经写入但还查不到”变成正常状态。选择异步前,产品流程必须允许这个状态,不能仅在架构图上把箭头改成虚线。

写成功与读落后的具体状态

write 进程先提交合同金额二十五及序号一的变化,再把金额更新为三十并提交序号二。两次提交各自把合同更新和变化记录一起写入。若只提交合同而没有变化记录,投影就没有可靠来源;若变化记录先提交,投影又可能展示尚不存在的事实。事务必须覆盖这两个写入。

投影暂停时,合同表已经是三十,读表仍为空,水位为零,最新变化序号为二。因此实验得到的延迟是两个变化,而不是两秒。序号差能衡量这个单序列样本尚未处理的进度;要获得时间延迟还需保存提交时间并定义时钟边界,不能把序号差直接换算成用户等待时间。

写模型自身仍要保护约束。实验向合同表写入负金额,数据库 CHECK 拒绝并返回实际 SQLSTATE,驱动回滚后继续执行。分离读模型没有转移业务规则的最终判断权;如果查询端显示的金额合法,但真值表接受了非法值,投影并不能修复写入模型的错误。

这里的合同表只是用于说明读写模型关系的缩小样本,没有实现设备排期和完整租赁业务。冻结基线保持原样,编译时核对其来源;不能因为使用了同一工程目录,就把本章三十这个金额样本当成前面完整预留用例的端到端回归。

水位与幂等更新

投影处理每条变化时先读取水位。如果输入序号小于或等于水位,按已处理重复记录返回;如果它大于水位加一,就拒绝这个间隙;恰好是下一条时,更新清单和水位并提交。清单更新使用合同编号作为键,因此一份合同只保留一个当前投影结果。

水位和清单必须在同一事务里。先推进水位再写清单,进程可能在两者之间退出,恢复后跳过尚未应用的变化;先提交清单再更新水位,恢复后则会再次处理同一变化。对于覆盖式更新,重复执行可能碰巧不改变最终值,但换成累计计费就会扩大金额。把事务边界明确下来,才能避免算法依赖偶然的运算性质。

本实验的序号是固定单写者测试序列,不是通用数据库日志偏移。现实系统用自增编号分配顺序时,事务可能乱序提交,回滚也可能留下间隙。因此“必须等于水位加一”只适用于已确认连续发布的逻辑序列。多分区消息通常需要每分区水位,不能用一个全局整数替代所有顺序关系。

对已经处理过的序号直接忽略,还隐含了同一序号对应不可变内容的约束。本例变化表以序号为主键,数据来自已提交的本地表;它没有为外部不可信消息计算载荷摘要。若外部系统可能用同一编号发送不同内容,需要另行定义冲突检测,不能把全部重复编号都当成安全重试。

追赶与重建

project 进程先故意提交序号二,验证水位仍为零,随后按 SQL 查询的顺序读取一和二。追赶完成后读表金额是三十,水位是二,再次输入二不会增加清单行。每条查询、绑定参数和提交都记录在日志中,实验没有预先打印一组假的投影进度充当运行结果。

rebuild 进程删除派生表内容并把水位重置为零,再从变化记录完整回放。删除与重置在同一事务提交,避免出现“空读表配最新水位”的不可恢复组合。最终由另一个 verify JVM 读取合同、读表和水位,断言它们分别为三十、三十和二。过程跨越多个 Java 进程,因此结果不依赖内存列表保留。

重建能力依赖变化源仍然完整。若变化表按七天清理,而最早的合同来自三年前,仅靠剩余变化无法恢复全部投影。可选方案包括从真值表构建一致性快照,再接续某个可靠日志位置;或者保留覆盖完整生命周期的变化记录。快照和增量之间不能遗漏或重复,切换点也需要验证。

在线重建通常不直接清空用户正在查询的表。可以创建新的投影版本,追赶到可接受水位后切换读取入口,再回收旧版本。本例为了显露机制而执行停机重建,没有实现双版本切换、分页查询、查询缓存和持续写入下的无缝追赶。这些限制应随实验结果一并阅读。

运行与日志

下载本章累计源码 · 校验清单

进入解压后的目录,配置 Java 21,执行 bash run-lab.sh 12。Python 驱动按写入、投影、重建和验证顺序启动四个独立 JVM,每个进程的命令、退出码和标准输出分别保存。H2 依赖按固定版本和摘要解析,不要求 Maven 或 Gradle,也不会把当前机器上的数据库驱动自动当成正确版本。

write.stdout.txt 展示写模型已更新而投影为空;project.stdout.txt 展示乱序拒绝、顺序追赶和重复输入;rebuild.stdout.txt 展示删除后的重新生成;verify.stdout.txt 展示新进程读回。processes.json 记录实际进程调用,database-files.json 记录本轮产生的文件数据库大小与摘要。临时数据库在实验结束后删除,保留的是原始操作与读回证据。

反例不是为了让正常入口最终失败。驱动预期写入负金额与乱序投影被内部拒绝,随后检查事务结果。如果把拒绝逻辑删掉,正常入口会因断言不成立而失败。这样的测试能说明检查与输入之间存在因果关系;仅在日志里写一行“拒绝乱序”而不检验水位,无法发现已经偷偷推进进度的实现。

CQRS 与事件溯源的区别

本章的当前事实仍存放在合同表中。变化表用于驱动读投影,未被定义为恢复合同真值的唯一来源;因此它展示 CQRS,但没有要求使用事件溯源。下一章才把事件序列作为恢复当前状态的依据,并检查从零重放与快照恢复是否相等。

采用 CQRS 后,调用方还需要知道读到的版本。创建合同成功时可以返回写入版本,查询返回投影水位,页面据此显示“结果更新中”或短时等待。对必须立即读取自己写入的流程,也可以直接使用命令结果,或临时访问真值查询。让用户盲目刷新会把正常投影延迟伪装成系统故障。

当查询只是单表按编号读取,且没有独立扩展需求,同步模型通常更容易维护。异步投影会带来重放、监控、保留策略和版本升级成本。本实验提供的是这些成本的可执行样本:延迟可观察,乱序可拒绝,重复可处理,数据丢失可重建;是否值得承担它们,取决于查询需求是否足够具体。

参考资料