事件溯源(ES)系统中过往事件的处理方法及场景适配性问询
问题背景
在事件溯源(ES)系统中,通常默认事件会在实际发生时立即录入,但实际场景中常因人为忙碌、系统批量推送等原因出现事后补录过往事件的情况,导致事件存储顺序与真实发生顺序不一致。
以小型汽车租赁公司为例:
涉及核心事件:
CarRented(包含租车日期等字段)CarReturnedByCustomer(包含还车日期、行驶里程等字段)CarServicedByMechanic(包含保养日期、费用等字段)
实际事件存储顺序(与真实时间线颠倒):
CarRented(date: 2022-08-20)CarServicedByMechanic(date: 2022-08-16)—— 真实发生在租车前,却晚录入CarReturnedByCustomer(date: 2022-08-22)
原有投影逻辑(用于跟踪「上次保养后行驶里程」):
On CarReturnedByCustomer: model.MileageSinceLastService += event.MilesDriven On CarServicedByMechanic: model.MileageSinceLastService = 0;
按错误的存储顺序执行时,会先累加还车里程,再重置为0,完全不符合真实业务逻辑。
核心解决方案
事件溯源并非不适用于这类场景,只需调整事件处理和投影逻辑,适配「事件存储顺序≠真实时间顺序」的情况:
1. 为事件绑定真实发生时间戳,投影按真实时间线重放
所有事件必须携带actualOccurredAt字段(记录事件真实发生的时间),而非仅依赖事件的存储时间(recordedAt)。
投影重建时,先将该聚合根的所有事件按actualOccurredAt排序,再按这个真实时间顺序执行投影逻辑:
- 对上述案例,排序后的事件顺序为:
CarServicedByMechanic(2022-08-16)→CarRented(2022-08-20)→CarReturnedByCustomer(2022-08-22) - 执行投影后,先重置里程为0,再累加还车里程,结果完全符合业务预期。
2. 使用事件版本号+补偿投影处理增量补录
如果不想每次重建投影都全量排序,可以给每个聚合根的事件维护一个基于真实时间的版本号,或者在补录事件时触发补偿性投影更新:
- 当补录一个过往事件时,标记该事件所属聚合根的投影为「过期」
- 重新从该聚合根的第一个事件开始,按真实时间顺序重放并更新投影
- 若聚合根事件量较大,可优化为:找到补录事件在真实时间线中的位置,从该位置之后的事件重新执行投影
3. 调整投影逻辑,支持事件的幂等与时间感知
修改投影逻辑,不再依赖事件的处理顺序,而是基于事件的真实时间计算状态:
比如针对「上次保养后行驶里程」:
// 维护所有保养事件的时间列表,以及所有还车事件的里程和对应时间 On CarServicedByMechanic: model.ServiceEvents.Add(event.actualOccurredAt) On CarReturnedByCustomer: model.ReturnEvents.Add((event.actualOccurredAt, event.MilesDriven)) // 计算当前里程时,找到最新的保养时间,累加该时间之后的所有还车里程 var lastServiceTime = model.ServiceEvents.Max(); model.MileageSinceLastService = model.ReturnEvents .Where(r => r.actualOccurredAt > lastServiceTime) .Sum(r => r.MilesDriven);
这种方式下,无论事件处理顺序如何,都能基于真实时间计算出正确的状态,代价是投影需要存储更多原始事件数据,计算时需要做过滤求和。
4. 事件存储层优化:支持按真实时间插入排序
部分事件存储系统允许在写入时指定事件在聚合根事件流中的逻辑位置(基于真实时间),而非仅追加到尾部。这种情况下,补录的过往事件会被插入到事件流中正确的时间位置,后续投影直接按事件流顺序处理即可,无需额外排序。
总结
事件溯源完全适用于存在事后补录需求的场景,核心是让事件和投影摆脱对「存储顺序」的依赖,转而以「真实业务时间」为基准。上述方案可根据系统规模、性能要求选择适配:小系统可直接用全量排序重放,大系统可结合事件时间戳+增量补偿,或调整投影逻辑为时间感知型。
内容的提问来源于stack exchange,提问作者Mike

