事件溯源的核心是确定性重建状态而非单纯存储事件,需确保ApplyEvent为纯函数、聚合根含明确State结构、事件版本控制防并发冲突、事件升级器保障向后兼容、测试覆盖重放一致性。

事件溯源的核心不是存事件,而是重建状态
很多人一上来就猛写 SaveEvent、AppendToStream,结果跑两轮发现状态对不上。根本原因是没想清楚:事件溯源里,逻辑层的职责是「从事件流中确定性地还原出当前业务状态」,而不是“把操作记录下来完事”。你的 ApplyEvent 方法必须是纯函数——相同事件序列输入,永远产出相同状态;不能依赖外部时间、随机数、或未声明的可变字段。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 每个聚合根(Aggregate)定义明确的
State结构体,只包含业务语义字段(如OrderStatus、Version),不放缓存、临时计算字段 - 所有事件类型实现统一接口,比如
interface{ Apply(state *OrderState) },且Apply方法内只读取事件字段、修改state字段,不调用其他函数或 IO - 避免在
Apply里做校验(如 “不能从 shipped 回退到 pending”)——校验应在生成事件前完成,事件本身代表“已通过校验的合法变更”
版本控制不是可选,是并发安全的前提
Go 模块里没内置乐观锁,但事件溯源天然需要防止两条命令同时基于旧状态生成冲突事件。常见错误是只靠数据库主键或时间戳去重,结果导致重复事件被多次应用,状态错乱。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 每个聚合根实例维护一个
Version int字段,每次成功应用事件后递增 - 写入事件时,必须带当前版本号作为条件:
INSERT INTO events (...) SELECT ... WHERE aggregate_version = ?,失败则重试重建状态再试 - 不要用
UUID或time.Now()当版本标识——它们无法保证顺序性和唯一性约束 - 如果用 Redis 或 Etcd 做事件存储,用
INCR或CompareAndSwap原子操作保障版本递增
重构事件结构时,别直接改 struct,要加升级器
上线后发现 OrderCreated 少了个 CustomerId 字段?直接给 struct 加字段,老事件反序列化会 panic,或者静默丢掉字段——状态重建就偏了。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 所有事件 struct 必须带明确版本标签,例如
type OrderCreatedV1 struct { ... },新版本叫OrderCreatedV2 - 定义统一的
Upgrader接口:func (v1 OrderCreatedV1) Upgrade() (interface{}, error),把 V1 映射到 V2 - 加载事件流时,先按原始格式反序列化,再根据
EventType和EventVersion字段路由到对应升级器,最后交给ApplyEvent - 升级器里禁止做业务判断或调用外部服务——它只负责字段搬运和默认值填充
测试不是测“存没存”,是测“重放是否一致”
写个单元测试,Apply(EventA); Apply(EventB) 得到状态 X,就以为 OK 了?漏掉了最关键的验证点:从空状态开始,逐条重放整个事件流,最终状态必须和线上快照完全一致(包括字段顺序、零值表示、浮点精度等)。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 每个聚合根配套一个
ReplayFromEvents([]Event) (State, error)函数,只做重建,不写库 - 测试用例应覆盖“空流”、“单事件”、“跨版本混合流”、“重复事件(应幂等)”四种场景
- 用
reflect.DeepEqual对比状态,但注意提前处理浮点字段(用math.Abs(a-b) )、时间字段(统一转为 UTC 秒级整数再比) - CI 中强制跑一次全量事件重放测试,哪怕慢一点——这是你唯一能信任的状态一致性防线
最常被忽略的点:事件序列的顺序不是靠插入时间保证的,而是靠聚合根内严格递增的 Version 或逻辑时钟(如 Lamport timestamp)。一旦多个协程并发生成事件,又没协调好顺序,重放结果必然不可靠。


















