
Event Sourcing 在 Go 里不是内置特性,得自己建骨架
Go 没有 EventStore、AggregateRoot 这类开箱即用的抽象,所有状态变更必须显式转为事件、持久化、再重放。这不是语法限制,而是设计选择:Go 倾向暴露控制权,不隐藏重放逻辑、序列化细节或存储耦合点。
常见错误是直接套用 Java/C# 的聚合根模板,结果写出带锁、带内部状态机、依赖反射重建对象的代码——这在 Go 里既难测又慢。
- 聚合逻辑应纯函数化:
ApplyEvent接收当前状态 +Event,返回新状态,不改原状态 - 事件必须可序列化(推荐
json.RawMessage或proto.Message),避免用含方法或闭包的 struct - 重放时禁止跳过任意事件:哪怕某次
ApplyEvent返回 error,也得记录并告警,不能静默忽略
用 json.RawMessage 存事件比结构体更稳
早期容易把每个事件定义成具体 struct,比如 OrderCreated、OrderShipped,然后用 map[string]interface{} 统一存。问题来了:字段增减、类型变更、版本混用时,json.Unmarshal 直接 panic 或静默丢字段。
json.RawMessage 把序列化/反序列化责任推给业务层,只保证字节不丢、顺序不错。重放时由 handler 自行决定用哪个版本的 struct 解析。
立即学习“go语言免费学习笔记(深入)”;
- 写入前:用
json.Marshal得到json.RawMessage,连同EventType、Version、Timestamp一起存进数据库 - 读取后:先按
EventType查 handler,再用对应 struct 调json.Unmarshal——失败就 fallback 到兼容逻辑,不中断重放 - 别用
interface{}接原始 JSON,它会把数字全转成float64,整型 ID 可能失真
重放性能瓶颈常卡在 IO 和锁,不是 CPU
本地测试时一切正常,上线后重放 10 万事件要 8 秒?大概率是每次 ApplyEvent 都去查一次 DB,或者用 sync.Mutex 包裹整个聚合实例。
Event Sourcing 的重放本质是单线程线性过程,但不意味着每步都得同步 IO 或强一致锁。
- 批量加载事件:用
SELECT * FROM events WHERE aggregate_id = ? ORDER BY version一次性拉全,别用循环 +SELECT ... LIMIT 1 OFFSET N - 聚合状态尽量放内存:重放期间状态是临时的,别边 Apply 边
UPDATE状态表 - 如果必须并发重放多个聚合,用
sync.RWMutex替sync.Mutex,读多写少场景能省掉 30%+ 锁争用
“可回溯”不等于“能 rollback”,时间点查询要额外建索引
用户说“查订单 2024-03-15 14:22 的状态”,这不是重放全部事件就能解决的。从头 replay 到那个时间戳,效率太低,而且中间任何事件时间戳不准(比如服务时钟漂移)就会错位。
真正可用的方案是:写事件时同步生成快照(snapshot),按时间窗口或版本号切片,并在快照表加 created_at 索引。
- 快照不是全量状态 dump,而是关键字段 +
last_applied_version,比如{"status": "shipped", "version": 42} - 查某个时间点状态:先查最新快照
WHERE created_at ,再从此 version 开始重放后续事件 - 快照频率要权衡:太密浪费空间,太疏重放压力大;建议按事件数(如每 100 条)+ 时间(如每天零点)双触发
快照和事件的存储一致性最难兜住——要么用事务(如 PostgreSQL 的 savepoint),要么接受最终一致,靠后台校验任务修复偏差。这点没人提,但线上出过事。










