企业应用架构13:Event Sourcing与持久事件重放
合同当前状态是“已取消、累计五天”,并不能说明它最初租了几天、何时延期、为什么保留这个计费长度。普通状态存储可以另加审计日志,但事件溯源采用更强的约定:事件序列本身就是恢复当前状态的事实依据。应用状态可以删除,只要事件流完整、解释规则一致,就应能重新计算出来。
这个约定增加了历史解释责任。过去保存的事件会长期参与恢复,新代码不能随意改变旧事件含义。重放还必须避免再次执行外部动作,否则一次修复读模型可能重新发出取消通知。本实验用真实 H2 事件表、版本条件追加、纯状态折叠和独立进程恢复,把这些要求分别变成二值检查。
事件流与审计记录
Fowler 的 Event Sourcing 文章讨论了通过事件序列重建应用状态,以及事件处理与外部系统交互之间的边界。审计日志可能只是已经更新完状态后的说明;事件溯源则要求它能够作为重建输入,遗漏一条必要事件就可能无法恢复正确状态。Event Sourcing 原文
本例事件具有流内版本、事件编号、种类、格式版本和一个整数载荷。合同创建、延期、取消各占一条。当前状态用 State 表示版本、天数和状态,派生表仅保存重放结果。事件表以流版本作为主键,以事件编号建立唯一约束,从存储层拒绝重复位置和重复事件标识。
为了把机制限制在可检查范围,数据库中只有一个合同流。真实多合同系统需要用合同标识和流版本构成联合键,也需要明确哪些操作跨越多个流。把所有业务事件塞进一个全局序列会放大串行瓶颈;把必须一起满足的不变量分散到多个流,又会引入协调问题。本实验没有替这种边界选择提供自动答案。
用期望版本保护追加
客户端读取版本零,表示它基于尚无事件的状态作出决定。追加时先执行带期望版本条件的更新:只有数据库头版本仍为零,才能把它推进为一,再插入第一条事件。头版本更新与事件插入在同一事务提交。如果更新影响行数为零,调用方持有的状态已经过期,必须重新读取并重新决定。
仅靠“查询最大版本,再加一插入”也能借唯一约束阻止重复位置,但冲突会发生在插入阶段。条件更新使冲突位置更直接,也把版本头和事件正文的一致性变成同一事务责任。实验在重复事件编号场景中先推进头版本,再触发唯一约束失败,回滚后检查头版本恢复为三,没有留下一个不存在事件的版本四。
并发场景使用两个独立 JDBC 连接。两个线程都先读到同一个头版本,再由闩锁一起放行,各自尝试追加。结果必须恰好一次成功、一次版本冲突,新连接读回事件总数为一。这里使用真实数据库条件更新和锁等待,不以两个内存变量模拟数据库竞争。
版本冲突不意味着整个命令可以无条件重试。若命令是“在当前日期基础上延期三天”,重新读取后可能需要重新验证可用性;若命令已向外部系统发送消息,直接再执行也可能重复副作用。实验只追加事件,不在冲突重试里执行外部动作,因此断言范围限于事件流版本保护。
旧格式的解释位置
第一条创建事件使用旧格式,整数载荷表示四十八小时;新格式使用天数。重放到创建事件时,适配逻辑将旧载荷除以二十四,得到两天,之后再应用延期三天,最后取消。最终状态应为版本三、五天、已取消。这个样本明确展示了字段单位演进,不把版本号存在表里就当成已经完成兼容。
这里的旧格式样本限定为整天小时数。生产转换必须定义非整天值的处理方式、舍入规则和时区影响,不能直接照搬整数除法。更复杂的升级可以使用独立 upcaster,将旧事件转换成统一的内部事件表示,再交给状态机;这样状态机不必在每个分支里重复兼容判断。
事件格式升级与业务含义升级也不同。把字段从小时改成天属于表示变化;把“取消后仍计费”改成“取消即免单”属于规则变化。如果用新规则重新解释旧事件,历史结果可能发生变化。应明确系统是在恢复当时已承诺的事实,还是要执行一项显式修订,不能让普通代码发布偷偷改写历史。
快照只是恢复加速器
写入前两条事件后,实验保存版本二的快照,内容是五天且仍有效;第三条事件取消合同。恢复时分别从空状态重放全部三条,和从快照继续重放版本大于二的后缀,然后比较两个完整 State。比较包含版本、天数和状态,不能只比较一个看起来正确的状态字符串。
快照可以缩短长事件流的恢复时间,但它会引入额外兼容问题。快照格式可能随代码变化,快照对应的事件位置也必须准确。遇到不兼容快照时,通常可以退回完整事件流;如果旧事件已经被删除,这个退路就不存在了。保留策略必须同时考虑事件、快照和恢复程序的版本。
本实验删除派生表再重建,保留事件表和快照表。它没有验证快照损坏检测、压缩策略和百万事件恢复时间,也没有把一次三条事件重放称为性能评测。文件数据库和多个 JVM 的意义是验证恢复来源确实持久存在,而不是把一个内存对象复制成另一个内存对象。
重放不再发送通知
状态折叠函数只接收旧状态和事件,返回新状态,不访问网络或通知表。正常写入流程另外保存一条取消通知记录。重放前后查询通知数量,必须保持一。这样能够发现把“取消”状态转换与“发送取消通知”硬绑在同一个函数中的错误设计。
通知表在此是副作用计数的本地替身,没有真实邮件或消息投递,因此实验不证明通知可靠送达。它证明的是重放过程没有调用这条副作用写入路径。若实际系统需要向消息代理发送通知,应使用下一章的 Outbox 等机制保存待发事实,同时区分正常命令执行和历史恢复。
外部查询也会影响重放。历史定价若在重放时重新调用今天的价格服务,恢复出来的金额可能不同。必要事实应记录在事件中,或者使用具有明确版本的历史参考数据。事件应包含多少数据,要由恢复时是否还能获得同样依据决定,不能只追求载荷最小。
运行与验证结果
配置 Java 21 后执行 bash run-lab.sh 13。驱动依次启动写入、重放、并发竞争和最终验证进程。每个 JVM 使用文件数据库,依赖固定 H2 2.1.214,并通过摘要验证。编译器开启全部警告且把警告视为错误;冻结领域基线先做文件核对,本章不会改写其来源。
write.stdout.txt 保存三条事件和快照的提交记录;replay.stdout.txt 包含完整恢复、快照后缀恢复、重复事件与过期版本拒绝;race.stdout.txt 保存两个独立连接的交错;verify.stdout.txt 由另一个进程确认派生状态、事件数量和通知数量。实际执行命令与退出码见 processes.json。
重复编号测试期望数据库返回唯一约束 SQLSTATE,随后显式回滚。过期版本测试期望条件更新没有成功,不允许继续追加。两种失败产生的原因不同,但都要求最终事件数不增加。若只捕获异常而不核对头版本,可能掩盖“错误事件没写进去、版本却已推进”的部分提交。
不可变历史与删除要求
事件长期保存会遇到个人信息更正和删除需求。把姓名、地址或敏感内容直接复制进每条不可变事件,会使后续治理困难。可以减少事件中的个人信息、把可删除信息放到单独存储,或者设计可追踪的修订机制;具体策略取决于数据用途与适用规则。本实验没有实现隐私删除,不应被用来证明某种删除方案合规。
事件含义也需要文档和兼容测试。只有创建、延期、取消三个名称,仍不足以解释金额、时间和授权上下文。正式系统应固定事件字段、约束、单位、因果关系和未知事件处理方式,并把历史样本放进回归集合。版本字段提供的是查找解释规则的入口,解释规则本身仍由代码和契约承担。
当业务只需要当前状态和有限审计信息,事件溯源可能增加过多维护成本。它适合那些确实需要可靠历史重建、时间查询或复杂派生视图的边界。这个实验提供的判据是可运行的:同一序列恢复相同状态,快照与完整重放一致,并发追加拒绝过期版本,恢复不重复执行副作用。






