Skip to content

要理解事件溯源(Event Sourcing),我们需要先跳出“存储当前状态”的传统思维,回到DDD的领域事件核心——状态变化的本质是事件的累积。事件溯源不是“替代数据库”,而是一种状态建模的范式,它用不可变的领域事件流来记录系统的所有历史变化,而非直接存储当前状态。

一、事件溯源的核心思想

传统CRUD架构中,我们存储的是当前状态(比如account表的balance字段),状态变化是通过“覆盖更新”实现的(UPDATE balance = balance + 100)。而事件溯源的核心是:

系统的当前状态 = 所有历史事件的有序叠加

换句话说:

  • 不直接存储“当前余额”,而是存储“账户创建时的初始金额”“存款100元”“取款50元”等领域事件
  • 当需要获取当前状态时,**重放(Replay)**该聚合根的所有事件,计算得到当前值(比如初始100 + 存款100 - 取款50 = 当前150);
  • 所有事件不可修改、不可删除(Append-Only),确保历史轨迹的真实性。

二、事件溯源的关键组件

事件溯源的架构围绕聚合根(DDD的核心边界)展开,核心组件包括:

1. 领域事件(Domain Event)

领域事件是领域内发生的事实,必须满足:

  • 不可变性:事件一旦发生,就不能修改(比如“2025-07-25 10:00 存款100元”的事实不会改变);
  • 自描述性:包含足够的上下文,能还原当时的场景(比如AccountDeposited事件需包含accountIdamounttimestampoperatorId);
  • 归属聚合根:每个事件都属于某个聚合根(比如OrderCreated事件属于Order聚合根)。

示例事件结构:

json
{
  "eventId": "uuid-123",
  "eventType": "AccountDeposited",
  "aggregateId": "account-456",
  "timestamp": "2025-07-25T10:00:00Z",
  "data": {
    "amount": 100,
    "operatorId": "user-789"
  },
  "version": 1
}

2. 事件存储(Event Store)

事件存储是** append-only 的事件数据库**,负责:

  • 存储所有领域事件(按聚合根ID和时间排序);
  • 支持按聚合根ID查询事件流(用于重放);
  • 确保事件的原子性(比如一个聚合根的多个事件必须同时写入)。

常见的事件存储方案:

  • 专用事件数据库:EventStoreDB(原生支持事件溯源);
  • 关系型数据库:PostgreSQL(用JSONB存储事件数据,按aggregate_idtimestamp索引);
  • 流处理系统:Kafka(适合高吞吐量的事件流,但需自己实现重放逻辑)。

3. 聚合根(Aggregate Root)

在事件溯源中,聚合根的状态完全由事件驱动

  • 聚合根的方法接收命令(Command)(比如DepositCommand),验证业务规则(比如存款金额>0);
  • 若验证通过,生成对应的领域事件(比如AccountDeposited);
  • 将事件提交到事件存储(原子操作);
  • 聚合根本身的状态通过应用事件(Apply Event)来更新(比如Apply(AccountDeposited e) { balance += e.Amount; })。

聚合根的状态更新流程:

mermaid
sequenceDiagram
    participant Client as 客户端
    participant AR as 聚合根(Account)
    participant ES as 事件存储
    Client->>AR: 发送DepositCommand(金额100)
    AR->>AR: 验证业务规则(金额>0)
    AR->>AR: 生成AccountDeposited事件
    AR->>ES: 提交事件到事件存储
    ES->>AR: 确认事件写入成功
    AR->>AR: 应用事件更新状态(balance +=100)

4. 快照(Snapshot)

当聚合根的事件数量非常大时(比如10万条),重放所有事件会很慢。快照的作用是:

  • 定期保存聚合根的当前状态快照(比如每100条事件保存一次);
  • 重放时,先加载最新的快照,再应用快照之后的事件(比如快照是第100条事件后的状态,只需重放第101~10万条事件)。

快照的结构通常包含:

  • 聚合根ID;
  • 快照对应的事件版本(比如第100条);
  • 聚合根的当前状态(比如balance: 5000)。

5. 投影(Projection)与CQRS

事件溯源不适合直接查询(因为需要重放事件),因此通常结合CQRS(命令查询职责分离):

  • 命令侧(写操作):用事件溯源维护聚合根的状态;
  • 查询侧(读操作):通过投影(Projection)从事件流中构建读模型(比如account_balances表)。

投影的工作流程:

  1. 事件存储中的事件被发布到消息队列(比如Kafka);
  2. 投影服务监听事件,异步更新读模型(比如AccountDeposited事件触发UPDATE account_balances SET balance = balance + 100 WHERE id = 'account-456');
  3. 查询时直接读取读模型,无需重放事件。

三、事件溯源的优势

事件溯源的价值源于对历史的“全量记录”,解决了传统CRUD架构的核心痛点:

1. 完整的审计轨迹

所有状态变化都有可追溯的事件,无需额外记录日志(事件本身就是日志)。例如:

  • 银行可以还原任意时刻的账户余额;
  • 电商可以追溯订单从创建到完成的所有操作(谁修改了地址?谁取消了订单?)。

2. 历史回放与时光旅行

通过重放事件,可以:

  • 回到任意时间点的系统状态(比如“查2025-06-01的库存数量”);
  • 调试问题(比如“重现上周三的支付失败场景”);
  • 重新计算统计数据(比如“修改佣金规则后,重新计算所有订单的佣金”)。

3. 原生支持事件驱动

事件是系统的“事实源”,可以直接作为其他系统的输入:

  • 库存系统监听OrderCreated事件,扣减库存;
  • 营销系统监听UserRegistered事件,发送欢迎邮件;
  • 数据仓库监听所有事件,构建数据湖。

4. 聚合根的状态一致性

聚合根的状态变化完全由事件驱动,避免了“部分更新”的问题(比如转账时,账户A的扣款和账户B的收款必须同时生成事件,否则事务失败)。

四、事件溯源的挑战与解决

事件溯源并非银弹,需解决以下问题:

1. 查询性能问题 → 用CQRS+投影

如前所述,查询侧通过投影构建读模型,避免直接重放事件。读模型可以是关系型数据库、Elasticsearch等,根据查询需求优化(比如按用户ID查询订单列表)。

2. 事件版本管理 → 版本号+转换器

当事件结构变化时(比如AccountDeposited事件新增currency字段),需要:

  • 给事件添加版本号(比如version: 2);
  • 编写事件转换器(Event Upcaster),将旧版本事件转换为新版本(比如旧事件的amount默认currency: USD);
  • 重放时自动应用转换器,确保聚合根能处理所有版本的事件。

3. 快照的一致性 → 原子快照

快照必须与事件存储保持一致:

  • 保存快照时,必须记录对应的事件版本(比如快照对应第100条事件);
  • 重放时,先加载快照,再应用该版本之后的事件,确保状态正确。

4. 聚合根的大小 → 小聚合原则

聚合根的事件数量越多,重放越慢。因此:

  • 遵循DDD的小聚合原则(比如Order聚合根只包含订单本身和订单项,不包含用户信息);
  • 避免跨聚合根的事件(比如OrderCreated事件不应包含User的信息,应通过userId关联)。

五、事件溯源的适用场景

事件溯源适合需要审计、历史回溯、事件驱动的场景:

  • 金融系统(账户、交易、支付);
  • 电商系统(订单、库存、物流);
  • 协作系统(文档编辑历史、项目进度跟踪);
  • IoT系统(设备状态变化的全量记录)。

不适合的场景

  • 简单的CRUD系统(比如博客文章的增删改查,无需历史回溯);
  • 对查询性能要求极高且无需历史的系统(比如实时计数器)。

六、事件溯源与DDD的关系

事件溯源是DDD的最佳实践之一,二者的结合点:

  1. 领域事件的落地:DDD强调“领域事件是领域内的重要事实”,事件溯源将其作为状态存储的核心;
  2. 聚合根的封装:聚合根是事件的边界,所有事件都属于某个聚合根,确保状态变化的一致性;
  3. 业务规则的验证:聚合根的命令处理逻辑验证业务规则(比如“账户余额不能为负”),只有通过验证才会生成事件;
  4. 限界上下文的集成:事件可以跨限界上下文传递(比如OrderCreated事件从订单上下文传递到库存上下文),实现上下文间的解耦。

七、总结:事件溯源的本质

事件溯源不是“存储事件”,而是用事件重新定义状态——状态是事件的累积结果,而非独立的存储单元。它的核心价值是保留系统的“历史真相”,让系统具备“时光旅行”的能力,同时原生支持事件驱动架构。

在DDD的语境下,事件溯源帮我们更贴近领域的本质:领域中的变化不是“更新记录”,而是“发生了某件事”——比如“用户下了一个订单”“账户收到一笔存款”。这些“事”构成了系统的历史,也定义了系统的当前状态。

最后一句话总结
事件溯源是“用过去的事实,构建现在的状态,驱动未来的变化”。