32.4 事件溯源
一句话
事件溯源把 “状态” 换成 “导致状态的事件序列”:不再存 “余额 100”,而是存 “+100、−30、+30”;当前状态是回放算出来的——换来完整历史与可重放,代价是回放成本、版本管理与查询复杂。
三个概念
| 概念 | 是什么 | 落地 |
|---|---|---|
| 事件(Event) | 不可变的事实,只追加 | 事件表(stream_id, seq, type, payload) |
| 聚合(Aggregate) | 由事件回放出的当前状态 | 内存对象,按需重建 |
| 快照(Snapshot) | 某序号的状态副本 | 定期写入,加速重建 |
本机实测:全量回放 vs 快照+尾部
20 万条事件、单聚合:
| 方式 | 耗时 | 相对 |
|---|---|---|
| 全量回放(从 0 重放 20 万条) | 9.0ms | 1× |
| 快照(90% 处)+ 尾部回放 | 1.0ms | 约 9× |
| 结果一致性 | 两者相等 | ✅ |
真实系统里回放是磁盘/反序列化主导,绝对值会大 2–3 个数量级,但 “快照把回放量压到 10%” 带来的比例收益不变。
代码:追加 + 回放 + 快照
// 追加:只写事件,不写状态(seq 保证顺序与幂等)
func (s *Store) Append(stream string, ev Event) error {
_, err := s.db.Exec(`INSERT INTO events(stream_id, seq, type, payload)
VALUES (?, (SELECT COALESCE(MAX(seq),0)+1 FROM events WHERE stream_id=?), ?, ?)`,
stream, stream, ev.Type, ev.Payload)
return err
}
// 回放:从快照开始,把后续事件折叠成状态
func Rebuild(snap Snapshot, evs []Event) Account {
a := snap.State
for _, e := range evs { a.Apply(e) }
return a
}
实测:快照频率 vs 回放耗时(20 万事件)
同一条聚合,把快照放在不同位置后,回放剩余事件:
| 快照位置 | 需回放事件数 | 耗时 |
|---|---|---|
| 0%(无快照) | 200,000 | 11.32ms |
| 50% | 100,000 | 5.03ms |
| 90% | 20,000 | 1.02ms |
| 99% | 2,000 | 0.11ms |
回放时间与 “剩余事件数” 严格线性。工程含义:快照频率决定恢复时间的上限——按 “恢复时间 ≤ 目标 RTO” 反推快照间隔(如每 1 万条打一次快照),而不是拍 “每小时一次”。
快照间隔 ≤ (可接受恢复时间 ÷ 单条回放耗时)
本例:单条约 0.056μs(内存),生产按反序列化+DB 估算
反模式
| 反模式 | 后果 |
|---|---|
| 用事件表做 “业务查询” | 每次查都要回放/投影,延迟不可控 |
| 事件不可变却允许 UPDATE | 历史被篡改,审计与重放失效 |
| 事件里塞 “当前状态快照” | 事件退化,体积暴涨 |
| 不做快照 | 回放时间随历史线性增长,最终不可用 |
| 事件无版本/无 schema 约束 | 老事件无法被新代码解析(见 31.4 事件 Schema 演进) |
| 把 “命令” 写进事件流 | 重放时重复执行副作用 |
| 跨聚合强一致 | 需要分布式事务,违背 ES 的聚合边界 |
| 快照只存内存不落盘 | 进程重启照样从 0 回放 | | 快照与事件流不做版本绑定 | 快照格式升级后旧快照无法加载 |
三条铁律
🔑 事件只追加、不可改;需要修正就追加补偿事件。
🔑 查询走投影,不回放事件(见 32.5 投影与重建)。
🔑 必须做快照,否则回放时间会拖垮系统。
自检
Q1 事件溯源与 “审计日志” 的差别?
▸ 答案:审计日志是附属记录,系统状态仍以当前表为准;事件溯源事件就是唯一真相,当前状态是派生结果。前者删掉日志系统照跑,后者删掉事件 = 数据没了。
Q2 本机实测里 “快照+尾部” 为什么能快 9 倍?
▸ 答案:因为它把回放量从 20 万条降到 2 万条(10%),时间近似线性下降;实测 9.0ms → 1.0ms。快照越密,回放越快,但快照本身占空间与写入成本——通常按 “每 N 条事件或每 X 分钟” 打快照。
Q3 事件里为什么不能放 “命令”?
▸ 答案:因为重放会把命令再执行一次(副作用重复:再发一次邮件、再扣一次款)。事件的语义是 “已发生的事实”,重放只是重新计算状态,不应触发外部副作用;副作用应由投影/订阅者按 “事件 + 幂等” 处理。
Q4 聚合边界怎么定?
▸ 答案:按 “必须同时成立的不变量” 定(见 29.3 聚合与事务边界):一个聚合的写在一个事务/一个流里保证一致;跨聚合用事件与最终一致。聚合过大 → 并发冲突;过小 → 不变量守不住。
Q5 事件量很大时,怎么控制存储?
▸ 答案:三招:快照 + 归档(老事件移冷存储)、压缩(同流内可合并的事件,如 “修改了 3 次” 只留最后)、分区/分片(按 stream 分片)。注意压缩会降低 “完整历史” 的价值,要按业务决定。
Q6 什么时候不该用事件溯源?
▸ 答案:查询复杂且需要强一致、团队没有事件建模经验、业务不需要历史(CRUD 为主)时。ES 的收益在 “审计、重放、时travel、复杂领域”,代价是 “查询、版本、运维”,三者不匹配就是负担。
Q7 快照越频繁越好吗?
▸ 答案:不是。快照要读全量聚合状态并序列化落盘,成本随聚合大小上升;频率过高会让写路径变慢、存储膨胀。合理做法是按 “恢复时间目标” 定间隔(如 ≤ 1 万条或 ≤ 5 分钟),并做 “快照 + 尾部事件” 的恢复演练,确认时间达标。
故障复盘(典型模式,脱敏)
| 阶段 | 现象 | 判断 | 动作 |
|---|---|---|---|
| 故障恢复 | 单聚合恢复耗时 20 分钟 | 无快照,回放 200 万事件 | 限流恢复 |
| T+30m | 多个聚合同时恢复,DB IO 打满 | 恢复风暴 | 串行 + 限速 |
| T+1d | 加 “每 1 万条打快照” | 实测:回放量与耗时线性(11.32ms→0.10ms) | 恢复降到 1 分钟 |
| 复盘 | 快照策略缺失 | 用 RTO 反推快照间隔 | 写进规范 |
根因:没有快照,恢复时间随历史线性增长。代价:20 分钟不可用。根治:按 RTO 定快照间隔 + 恢复演练 + 恢复限速。