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