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。对应的事件流如下:
| 序号 | 事件类型 | 关键数据 | 重建后余额 |
|---|---|---|---|
| 1 | AccountOpened | initialBalance=1000 | 1000 |
| 2 | MoneyDeposited | amount=500 | 1500 |
| 3 | MoneyTransferred | amount=300, to=B | 1200 |
| 4 | MoneyTransferred | amount=200, from=C | 1400 |
任何时候需要查询账户余额,只需从事件存储中读取该账户的全部事件,按顺序重放(replay),即可得到当前状态。这就是“事件是唯一事实来源”的含义。
四、与传统CRUD的对比
传统CRUD直接操作当前状态,数据库里只有“最新快照”。事件溯源则保留完整历史,状态由事件推导而来。两者在审计、并发、调试等维度差异明显:
| 对比维度 | 传统CRUD | 事件溯源 |
|---|---|---|
| 存储内容 | 当前状态(余额=1400) | 全部事件(开户、存入、转出、转入) |
| 历史追溯 | 需额外审计表或日志 | 天然完整,不可篡改 |
| 并发控制 | 锁或版本号更新 | 基于事件流版本号乐观并发 |
| 调试与复盘 | 只能看到最终结果 | 可重放任意时间点状态 |
| 写入性能 | 原地更新,快 | 追加写入,顺序IO,也很快 |
| 查询复杂度 | 直接查表,简单 | 需投影(Projection)生成读模型 |
提示:事件溯源并不排斥CRUD。读模型(查询侧)通常仍然使用传统数据库表,通过订阅事件流异步更新,从而兼顾写入的历史完整性和查询的高效性。
五、常见误区与复盘
- 误区一:事件就是日志。日志是技术副产品,事件是业务事实,是系统的唯一事实来源,二者地位完全不同。
- 误区二:事件可以修改。事件不可变。如果写错了,只能追加一个补偿事件(如“冲正”),而不是删除或更新原事件。
- 误区三:聚合根越大越好。聚合根是事务一致性边界,应尽量小。银行账户和交易流水通常属于不同聚合根,通过事件最终一致。
- 误区四:命令和事件可以混用。命令可能被拒绝,事件一定已发生。命名上必须严格区分,否则代码语义会混乱。
注意:事件版本升级是事件溯源落地的难点。当事件结构变化时,需要设计向上兼容的反序列化策略,否则历史事件将无法重放。建议在事件中携带版本号,并保留旧版本解析器。
理解事件、事件流、聚合根、命令与事件的区别,以及与传统CRUD的差异,是掌握事件溯源的第一步。在银行交易场景中,这些概念直接决定了系统能否做到“每一分钱的来龙去脉都可追溯”。