3、事件溯源模式:事件溯源原理、事件存储设计、事件重放与状态重建、快照策略
好,咱们今天聊事件溯源(Event Sourcing)。
这名字听起来挺唬人,其实说白了就是:不存当前状态,只存状态变更的流水。
你想想看,传统CRUD里,我们更新一条订单记录,直接就把数据库里的金额改了。旧值是什么?没人知道。事件溯源不一样——每次修改都生成一个事件,比如“订单已创建”、“金额已修改”、“订单已取消”。这些事件按顺序存起来,永不删除。
我个人习惯把事件溯源比作银行的流水账单。你账户里现在有1000块,银行不会只记“余额1000”,它会记下每一笔存取记录。余额?那是算出来的。
3.1 事件溯源的核心原理
事件溯源的核心就三句话:
- 事件是事实——每件事都真实发生过,不可篡改
- 状态是推导结果——当前状态 = 所有历史事件的累加
- 事件是唯一数据源——没有其他表存“当前状态”
我在项目中遇到过这样一个场景:电商系统的订单状态机。传统做法是订单表里有个status字段,从“待支付”变成“已支付”。但问题来了——如果用户支付后又退款,中间经历了什么?查不到。用事件溯源后,每个状态变更都是一个事件,审计、回溯、甚至回滚都变得极其自然。
核心公式:
当前聚合状态 = 初始状态 + 按顺序应用所有事件
嗯,这里要注意:事件是不可变的。一旦写入,就不能修改或删除。你只能追加新事件来“修正”之前的错误。比如你发现某笔订单金额算错了,不是去改旧事件,而是追加一个“金额已更正”事件。
3.2 事件存储设计
事件存储是事件溯源的基石。设计不好,后面全崩。
我建议用两张核心表:
| 表名 | 字段 | 说明 |
|---|---|---|
| event_store | event_id, aggregate_type, aggregate_id, version, event_type, event_data, created_at | 存储所有事件 |
| snapshot_store | aggregate_type, aggregate_id, version, state_data, created_at | 存储快照(后面讲) |
event_data 通常存JSON或二进制序列化数据。我个人偏好JSON,调试方便。但如果你对性能要求极高,可以考虑Protobuf。
关键点来了:版本号。每个聚合(Aggregate)都有一个递增的版本号。写入事件时,必须检查版本号是否冲突。这是实现乐观锁的基础。
避坑指南:
我曾经在项目里忘了给aggregate_id加索引,结果事件重放时慢得像蜗牛。记住:aggregate_type + aggregate_id 必须建联合索引,而且顺序不能反。
事件存储的写入模式很简单:
// 伪代码:写入事件
function appendEvent(aggregateType, aggregateId, expectedVersion, event) {
// 1. 检查当前版本是否等于expectedVersion
// 2. 如果不等,抛出并发冲突异常
// 3. 写入事件,版本号+1
// 4. 返回新版本号
}
为什么要有expectedVersion?防止两个请求同时修改同一个聚合。你想想看,如果没这个检查,事件顺序就乱了,状态重建出来就是错的。
3.3 事件重放与状态重建
事件重放,就是把所有事件拿出来,按顺序执行一遍,算出当前状态。
代码大概长这样:
// 伪代码:状态重建
function rebuildState(aggregateType, aggregateId) {
let state = new State(); // 初始状态
let events = loadEvents(aggregateType, aggregateId); // 按版本升序
for (let event of events) {
state.apply(event); // 每个事件修改状态
}
return state;
}
这里有个性能问题:如果某个聚合有10万条事件,每次重建都要从头读到尾,太慢了。所以需要快照。
我记得有一次做金融风控系统,一个账户每天产生上千条事件。上线第一天还好,第三天开始,每次查询账户状态都要等好几秒。用户投诉说“你们系统是不是卡了?”——其实就是事件太多,重放太慢。
警告:
事件重放是CPU密集型的操作。如果事件里有复杂的业务逻辑(比如计算利息、校验规则),重放10万条事件可能需要几秒甚至几十秒。千万别在用户请求的同步路径里做全量重放!
解决方案?异步重建 + 缓存。或者,用快照。
3.4 快照策略
快照就是“某个时间点的状态备份”。有了快照,状态重建就不需要从头开始了——从最近的快照开始,只重放快照之后的事件就行。
快照策略的核心问题:多久打一次快照?
我见过三种常见策略:
- 基于数量:每N个事件打一次快照。N通常取100、500或1000。简单粗暴,但有效。
- 基于时间:每小时或每天打一次快照。适合事件产生速度不均匀的场景。
- 混合策略:数量和时间都满足才打。比如每500个事件且至少过去1小时。
我个人习惯用基于数量的策略。N取多少?看你的事件大小和业务复杂度。如果每个事件很小(几十字节),N可以大一点。如果事件里包含大JSON(几KB),N就得小一点。
快照的存储也很重要。我建议:
- 快照存成序列化后的二进制或JSON
- 每个聚合只保留最近N个快照(比如3个),旧的删掉
- 快照写入时,最好异步执行,别阻塞事件写入
带快照的状态重建:
// 伪代码:带快照的状态重建
function rebuildStateWithSnapshot(aggregateType, aggregateId) {
let snapshot = loadLatestSnapshot(aggregateType, aggregateId);
let state;
let fromVersion;
if (snapshot) {
state = deserialize(snapshot.stateData);
fromVersion = snapshot.version;
} else {
state = new State();
fromVersion = 0;
}
let events = loadEventsAfterVersion(aggregateType, aggregateId, fromVersion);
for (let event of events) {
state.apply(event);
}
return state;
}
嗯,这里要注意:快照本身也是数据,也会占用存储。别打得太频繁,否则快照比事件还多,那就本末倒置了。
我的经验:
我曾经在一个项目里把快照间隔设成了50个事件,结果快照表比事件表还大。后来改成500,性能反而更好。快照不是越多越好,够用就行。
最后,画一张图帮你理解整个流程:
事件溯源不是银弹。它适合需要审计、回溯、复杂事件处理的场景。但如果你的业务只是简单的CRUD,用事件溯源反而增加复杂度。选型时要想清楚。
好,今天就聊到这儿。记住:事件是事实,状态是推导结果。这个思维转变,是理解事件溯源的关键。
公众号:蓝海资料掘金营,微信deep3321