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

事件溯源中并发冲突的典型表现和应对方式
在 C# 事件溯源系统中,并发写入最常触发 OptimisticConcurrencyException(或自定义的类似异常),本质是多个线程/请求对同一聚合根尝试基于不同版本号追加事件。EF Core 的 DbUpdateConcurrencyException、自研事件存储中校验 ExpectedVersion 失败,都属于这一类。
关键不是“避免并发”,而是“让并发失败可预测、可重试、不丢数据”:
- 聚合根必须维护
Version(整数递增)字段,每次成功应用事件后 +1 - 保存时严格校验:数据库 WHERE 子句含
WHERE AggregateId = @id AND Version = @expectedVersion,或事件存储要求写入时传入expectedVersion - 捕获冲突后,**不直接抛出上层异常**,而是重新加载最新状态(含所有已存事件)、重放业务逻辑、生成新事件序列,再重试 —— 这个过程需幂等且无副作用
- 重试次数建议限制(如 3–5 次),超限应降级为人工介入或异步补偿,避免雪崩
快照(Snapshot)该在什么时机触发
快照不是“定期执行”,而是“按需截断事件链”。核心判断依据只有一个:EventCount 超过阈值(如 100 或 500)且 重建聚合耗时明显升高(实测 >50ms)。
不要用固定时间(如“每天凌晨”)或固定事件数(如“每 100 条”)硬编码触发,因为不同聚合生命周期差异极大 —— 订单聚合可能一天产生 20 个事件,而用户配置聚合可能三年才变 3 次。
推荐做法:
- 在聚合加载逻辑中,统计从快照点开始读取的事件数量,若 ≥
SnapshotThreshold(例如 200),则在本次保存后主动创建快照 - 快照本身只存聚合当前状态(
State对象),不含事件;其版本号必须与所覆盖的最后一个事件的Version一致 - 快照存储需支持唯一键约束:
(AggregateType, AggregateId, SnapshotVersion),防止重复写入
快照与事件如何协同加载聚合
加载聚合时,不能假设“有快照就一定用快照”,也不能“无视快照全量重放”。正确流程是三段式:
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) aggregate.Apply(e); }
注意两个细节:
-
GetEvents(aggregateId, afterVersion: snapshot.Version)中afterVersion是排他性参数(即从snapshot.Version + 1开始),否则会重复应用快照已包含的事件 - 快照中的
State必须是“可序列化纯净对象”,不能含引用、委托、DbContext 实例等运行时依赖,否则反序列化后无法安全调用Apply() - 若快照损坏或版本错乱,要能 fallback 到全量事件重放 —— 所以快照永远是优化手段,不是状态唯一来源
C# 实现快照时最容易被忽略的序列化陷阱
用 System.Text.Json 或 Newtonsoft.Json 序列化快照时,90% 的问题出在类型丢失和构造器约束上:
- 聚合状态类必须有 public parameterless constructor,否则反序列化失败(即使你写了
[JsonConstructor],也要确保默认构造器存在) - 避免使用
record类型直接作为快照 State —— 它的不可变性和私有字段会让 JSON 序列化器无法写入,除非显式配置IncludeFields = true和PropertyNamingPolicy = null - 若状态中含
DateTimeOffset、decimal或枚举,确认序列化器未做格式转换(如把decimal转成 double 导致精度丢失) - 快照表的数据库字段类型必须匹配序列化结果长度:JSON 字段建议用
NVARCHAR(MAX)(SQL Server)或JSONB(PostgreSQL),别用VARCHAR(200)截断
真正的难点不在“怎么存快照”,而在于“怎么保证快照和事件语义严格一致”——只要有一次 Apply() 方法修改了非状态字段(比如缓存计数器、临时标记),快照就会脱离事件源事实。










