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 定快照间隔 + 恢复演练 + 恢复限速。

关联