Appearance
要理解事件溯源(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事件需包含accountId、amount、timestamp、operatorId); - 归属聚合根:每个事件都属于某个聚合根(比如
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_id和timestamp索引); - 流处理系统: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表)。
投影的工作流程:
- 事件存储中的事件被发布到消息队列(比如Kafka);
- 投影服务监听事件,异步更新读模型(比如
AccountDeposited事件触发UPDATE account_balances SET balance = balance + 100 WHERE id = 'account-456'); - 查询时直接读取读模型,无需重放事件。
三、事件溯源的优势
事件溯源的价值源于对历史的“全量记录”,解决了传统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的最佳实践之一,二者的结合点:
- 领域事件的落地:DDD强调“领域事件是领域内的重要事实”,事件溯源将其作为状态存储的核心;
- 聚合根的封装:聚合根是事件的边界,所有事件都属于某个聚合根,确保状态变化的一致性;
- 业务规则的验证:聚合根的命令处理逻辑验证业务规则(比如“账户余额不能为负”),只有通过验证才会生成事件;
- 限界上下文的集成:事件可以跨限界上下文传递(比如
OrderCreated事件从订单上下文传递到库存上下文),实现上下文间的解耦。
七、总结:事件溯源的本质
事件溯源不是“存储事件”,而是用事件重新定义状态——状态是事件的累积结果,而非独立的存储单元。它的核心价值是保留系统的“历史真相”,让系统具备“时光旅行”的能力,同时原生支持事件驱动架构。
在DDD的语境下,事件溯源帮我们更贴近领域的本质:领域中的变化不是“更新记录”,而是“发生了某件事”——比如“用户下了一个订单”“账户收到一笔存款”。这些“事”构成了系统的历史,也定义了系统的当前状态。
最后一句话总结:
事件溯源是“用过去的事实,构建现在的状态,驱动未来的变化”。
