2. 事件溯源基础

在银行核心系统中,账户余额、交易流水、冻结金额这些状态每天都在变化。传统做法是直接更新数据库中的余额字段,而事件溯源(Event Sourcing)换了一种思路:不保存最终状态,而是保存导致状态变化的所有事件。本节将拆解事件溯源最基础的五个概念,并说明它与传统CRUD的本质差异。

一、核心原理:事件是唯一事实来源

事件(Event)描述的是“过去已经发生的事实”,例如“账户A向账户B转账100元成功”。它具有不可变性:一旦写入事件存储,就不能修改或删除。事件流(Event Stream)是同一聚合根所有事件按时间顺序排列的序列,它构成了该聚合根完整的历史记录。

聚合根(Aggregate Root)是事件溯源中的一致性边界。在银行场景中,一个“账户”就是一个聚合根,它负责保证“余额不能透支”“冻结金额不能超过余额”等业务规则。命令(Command)则是外部发起的、表达“希望发生某事”的请求,例如“转账命令”。命令可能被拒绝,而事件一定已经发生。

关键区分:命令是意图,事件是结果。命令用祈使语气(TransferMoney),事件用过去时(MoneyTransferred)。命令可以失败,事件不可撤销。

二、方法流程:从命令到事件再到状态

事件溯源的处理流程可以概括为“接收命令 → 加载事件流 → 重建聚合 → 校验业务规则 → 产生新事件 → 追加到事件流”。下面用一段伪代码展示账户聚合根如何处理转账命令:

class Account {
  private balance = 0;
  private frozen = 0;

  // 从事件流重建状态
  apply(event) {
    switch(event.type) {
      case 'AccountOpened': this.balance = event.initialBalance; break;
      case 'MoneyDeposited': this.balance += event.amount; break;
      case 'MoneyWithdrawn': this.balance -= event.amount; break;
      case 'MoneyTransferred': this.balance -= event.amount; break;
    }
  }

  // 处理命令,校验后产生事件
  transfer(command) {
    if (this.balance - this.frozen < command.amount)
      throw new Error('余额不足');
    return new MoneyTransferred(command.from, command.to, command.amount);
  }
}

注意:聚合根本身不直接修改数据库,它只负责“根据历史事件重建状态”和“根据命令产生新事件”。事件的持久化由事件存储(Event Store)完成,通常采用追加写入(append-only)方式。

三、案例场景:银行转账的事件流

假设账户A初始余额为1000元,发生以下操作:开户、存入500、转出300、转入200。对应的事件流如下:

序号事件类型关键数据重建后余额
1AccountOpenedinitialBalance=10001000
2MoneyDepositedamount=5001500
3MoneyTransferredamount=300, to=B1200
4MoneyTransferredamount=200, from=C1400

任何时候需要查询账户余额,只需从事件存储中读取该账户的全部事件,按顺序重放(replay),即可得到当前状态。这就是“事件是唯一事实来源”的含义。

银行账户事件流与状态重建 命令 Command TransferMoney 聚合根 Account 校验余额/冻结规则 事件存储 Event Store append-only 追加写入 当前状态 余额=1400 事件流(按时间顺序) AccountOpened 1000 MoneyDeposited +500 MoneyTransferred -300 MoneyTransferred +200 重放全部事件 → 重建聚合根状态 → 余额 = 1000 + 500 - 300 + 200 = 1400

四、与传统CRUD的对比

传统CRUD直接操作当前状态,数据库里只有“最新快照”。事件溯源则保留完整历史,状态由事件推导而来。两者在审计、并发、调试等维度差异明显:

对比维度传统CRUD事件溯源
存储内容当前状态(余额=1400)全部事件(开户、存入、转出、转入)
历史追溯需额外审计表或日志天然完整,不可篡改
并发控制锁或版本号更新基于事件流版本号乐观并发
调试与复盘只能看到最终结果可重放任意时间点状态
写入性能原地更新,快追加写入,顺序IO,也很快
查询复杂度直接查表,简单需投影(Projection)生成读模型

提示:事件溯源并不排斥CRUD。读模型(查询侧)通常仍然使用传统数据库表,通过订阅事件流异步更新,从而兼顾写入的历史完整性和查询的高效性。

五、常见误区与复盘

  • 误区一:事件就是日志。日志是技术副产品,事件是业务事实,是系统的唯一事实来源,二者地位完全不同。
  • 误区二:事件可以修改。事件不可变。如果写错了,只能追加一个补偿事件(如“冲正”),而不是删除或更新原事件。
  • 误区三:聚合根越大越好。聚合根是事务一致性边界,应尽量小。银行账户和交易流水通常属于不同聚合根,通过事件最终一致。
  • 误区四:命令和事件可以混用。命令可能被拒绝,事件一定已发生。命名上必须严格区分,否则代码语义会混乱。

注意:事件版本升级是事件溯源落地的难点。当事件结构变化时,需要设计向上兼容的反序列化策略,否则历史事件将无法重放。建议在事件中携带版本号,并保留旧版本解析器。

理解事件、事件流、聚合根、命令与事件的区别,以及与传统CRUD的差异,是掌握事件溯源的第一步。在银行交易场景中,这些概念直接决定了系统能否做到“每一分钱的来龙去脉都可追溯”。