贝利信息

c# 事件溯源(Event Sourcing)模式下的并发和快照策略

日期:2026-01-19 00:00 / 作者:畫卷琴夢
并发冲突典型表现为OptimisticConcurrencyException,应对方式是版本校验+重载重放+幂等重试;快照按需触发(事件数阈值+耗时判断),加载时三段式合并快照与后续事件,序列化需确保无参构造、类型兼容与语义一致。

事件溯源中并发冲突的典型表现和应对方式

在 C# 事件溯源系统中,并发写入最常触发 OptimisticConcurrencyException(或自定义的类似异常),本质是多个线程/请求对同一聚合根尝试基于不同版本号追加事件。EF Core 的 DbUpdateConcurrencyException、自研事件存储中校验 ExpectedVersion 失败,都属于这一类。

关键不是“避免并发”,而是“让并发失败可预测、可重试、不丢数据”:

快照(Snapshot)该在什么时机触发

快照不是“定期执行”,而是“按需截断事件链”。核心判断依据只有一个:EventCount 超过阈值(如 100 或 500) 重建聚合耗时明显升高(实测 >50ms)。

不要用固定时间(如“每天凌晨”)或固定事件数(如“每 100 条”)硬编码触发,因为不同聚合生命周期差异极大 —— 订单聚合可能一天产生 20 个事件,而用户配置聚合可能三年才变 3 次。

推荐做法:

快照与事件如何协同加载聚合

加载聚合时,不能假设“有快照就一定用快照”,也不能“无视快照全量重放”。正确流程是三段式:

var snapshot = _snapshotStore.GetLatest(aggregateId);
if (snapshot != null)
{
    aggregate = new Order(snapshot.State); // 从快照重建
    var events = _eventStore.GetEvents(aggregateId, afterVersion: snapshot.Version);
    foreach (var e in events) aggregate.Apply(e); // 仅重放快照之后的事件
}
else
{
    aggregate = new Order(); // 空构造
    var events = _eventStore.GetEvents(aggregateId);
    foreach (var e in events) agg

regate.Apply(e); }

注意两个细节:

C# 实现快照时最容易被忽略的序列化陷阱

System.Text.JsonNewtonsoft.Json 序列化快照时,90% 的问题出在类型丢失和构造器约束上:

真正的难点不在“怎么存快照”,而在于“怎么保证快照和事件语义严格一致”——只要有一次 Apply() 方法修改了非状态字段(比如缓存计数器、临时标记),快照就会脱离事件源事实。