事件溯源需严格遵循持久化、有序、带聚合id和版本号等规则,使用eventstoredb时须用eventdata包装事件、匹配类型名与元数据,重建状态需排序事件并调用apply方法,快照为性能必需,并发冲突需重放而非静默重试,事件命名应体现具体业务动作。

事件溯源不是给类加个 public event 就完事——它要求事件必须持久化、有序、带聚合 ID 和版本号,且能通过重放精确重建任意时刻状态。如果你只是想解耦模块或响应 UI 变化,用 .NET 原生事件就够了;但若需要审计、回滚、最终一致性或时间点查询,就得按事件溯源的规则来建模和落地。
EventStoreDB 写入必须用 EventData 包装,不能直接序列化领域对象
直接把 OrderShippedEvent 对象传给 AppendToStreamAsync 会失败或丢失类型信息,EventStoreDB 不认识你的 .NET 类型。必须显式构造 EventData,并严格匹配元数据:
-
EventType字符串必须与事件类名完全一致(如"OrderShippedEvent"),大小写敏感 -
ContentType推荐System.Text.Json序列化的ApplicationJson,避免Binary(调试困难、跨语言不友好、ESDB 投影无法解析) - 聚合根 ID 必须作为流名,例如
"order-9f3a7b",不能全写进"all"流——否则重建时无法隔离聚合边界 -
ExpectedVersion首次写用ExpectedVersion.NoStream,追加用ExpectedVersion.StreamExists或具体整数版本,错配会抛WrongExpectedVersionException
重建聚合必须按 EventNumber 升序消费,且跳过空事件体
调用 ReadStreamAsync("order-9f3a7b", StreamPosition.Start, int.MaxValue) 获取全部事件后,不能直接遍历返回结果——默认有序但不保证,尤其在 mock 或分页场景下容易出错:
- 必须显式按
ResolvedEvent.Event.EventNumber升序排序后再处理,这是状态演化的唯一合法顺序 - 每个事件反序列化后,必须调用聚合根的私有
Apply(事件实例)方法,禁止用 public setter 绕过业务逻辑 - 检查
ResolvedEvent.Event.Data.Length == 0,为软删除或元事件,需跳过,否则JsonSerializer.Deserialize会抛异常 - 重建过程不能依赖外部服务(如 HTTP 调用),否则重放失败或结果不可重现
快照不是可选优化,而是重建性能的刚性门槛
当一个聚合流积累超过 200 个事件,重建延迟通常突破 50ms;到上千事件时可能达秒级。快照必须独立存储、带版本对齐、加载时三段式合并:
- 快照流名应为
"order-9f3a7b-snapshot",事件类型统一为"AggregateSnapshot" - 快照数据体必须包含聚合当前完整状态 + 最后应用的事件
Version(即SnapshotVersion) - 加载时先查最新快照,再从
_eventStore.GetEvents(aggregateId, afterVersion: snapshot.Version)补充后续事件 - 快照写入需幂等:用
(AggregateType, AggregateId, SnapshotVersion)作唯一键,防止重复覆盖
并发冲突必须重载重放,不能静默重试
高并发下两个线程同时对同一聚合提交事件,第二个必然触发 OptimisticConcurrencyException。这不是错误,而是事件溯源的正常反馈机制:
- 捕获异常后,必须重新读取该聚合的最新事件流(含刚写入的那个),重放生成新事件,再尝试写入
- 重试次数建议限为 3–5 次,超限应转入人工审核队列或异步补偿流程,避免雪崩
- 业务语义决定是否允许重试:库存扣减类操作重试可能超卖,此时应降级而非硬重试
- 聚合根必须维护
Version字段,每次Apply()后自增,且写入时参与 WHERE 条件校验
最常被忽略的一点是:事件命名必须反映真实业务动作,而不是技术操作。写 UserUpdatedEvent 是错的,它掩盖了“邮箱改了”还是“手机号换了”;应该拆成 EmailChangedEvent 和 PhoneVerifiedEvent——只有这样,重放、审计、投影才能真正支撑业务决策。











