Event Sourcing 的核心在于通过事件重放重建状态而非单纯存储事件;需确保事件有序、Apply 方法无副作用、原子写入、结构化序列化、快照机制与 PostgreSQL 合理选型。

Event Sourcing 的核心不是存事件,而是用事件重建状态
Go 里做 Event Sourcing,最容易掉进的坑是把 event 当成普通日志往数据库一塞就完事。其实关键不在“存”,而在“重放”——每次读取聚合时,必须能从初始空状态开始,严格按时间顺序应用所有 Apply() 方法还原出最新状态。这意味着:
- 每个事件结构体必须带明确版本(如 Version int 或 Timestamp time.Time),不能靠数据库自增 ID 排序(分布式下不可靠)
- 聚合根必须提供 Apply(event interface{}) 方法,且该方法只能修改内部字段,不能触发副作用(比如发 HTTP 请求、写 DB)
- 事件序列必须原子写入:要么全部成功,要么全部失败,推荐用单条 SQL 插入多行(PostgreSQL 的 INSERT ... VALUES (...), (...))或事务包装
用 Go struct 实现可序列化的事件,别用 map[string]interface{}
常见错误是用 map[string]interface{} 存事件数据,看似灵活,实则导致三类问题:反序列化时类型丢失、无法做编译期校验、JSON 字段名大小写混乱(比如前端传 userId,Go 解析成 UserID 后再序列化变 user_id)。正确做法是为每个事件定义具体 struct:
- 所有事件实现统一接口,例如 type Event interface{ EventName() string; Timestamp() time.Time }
- 使用 json.RawMessage 或 gob 序列化(gob 更适合 Go 内部系统,性能高、类型保真)
- 避免在事件 struct 中嵌套指针或未导出字段(gob 不序列化未导出字段,json 默认忽略)
type OrderCreated struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Total float64 `json:"total"`
Timestamp time.Time `json:"timestamp"`
}
func (e OrderCreated) EventName() string { return "OrderCreated" }
func (e OrderCreated) Timestamp() time.Time { return e.Timestamp }
重放事件时别直接查数据库,先加载快照(Snapshot)
当一个聚合的历史事件达到几千条,每次重建状态都从头重放会明显拖慢读路径。Go 服务里必须支持快照机制:
- 快照不是替代事件,而是定期保存聚合当前状态(比如每 100 条事件存一次),并记录对应事件版本号(SnapshotVersion int)
- 重放逻辑变成:查最新快照 → 从快照版本+1 开始查后续事件 → 应用这些事件
- 快照本身也要持久化,但格式可以更紧凑(比如用 gob 序列化整个聚合 struct),不需要带事件元信息
- 注意快照和事件存储必须在同一个事务中更新(或用可靠消息保证最终一致),否则会出现“快照比事件新”或“快照比事件旧”的不一致
PostgreSQL 是最省心的事件存储选型,别过早考虑 Kafka
初学者常以为 Event Sourcing 就得上消息队列,结果卡在 Exactly-Once、Offset 管理、消费者组重启状态丢失等问题上。对大多数业务系统(订单、账户、库存),PostgreSQL 完全够用:
- 用 events 表,字段至少包含:aggregate_id, aggregate_type, version, event_type, payload jsonb, created_at
- 建复合索引:CREATE INDEX ON events (aggregate_type, aggregate_id, version),确保按聚合+版本高效查询
- 利用 jsonb 的查询能力,比如快速统计某用户所有 “PaymentFailed” 事件:SELECT COUNT(*) FROM events WHERE aggregate_type = 'User' AND payload @> '{"type":"PaymentFailed"}'
- 不要为了“流式处理”强行引入 Kafka:除非你真有跨语言消费、实时大屏、或需要事件广播给多个异构系统,否则纯写 DB + 定时轮询(或 LISTEN/NOTIFY)更可控
Event Sourcing 在 Go 里最难的部分,从来不是怎么存事件,而是怎么让 Apply() 方法真正无副作用、可重复执行,以及怎么让快照和事件的边界在故障时依然可推导。这两点没想清楚前,代码写得再“模式化”也没用。










