CQRS/Event Sourcing 模式实践
如果还不清楚什么是 CQRS 和 Event Sourcing 模式,可以先阅读下面的两篇文章:
Event Sourcing 模式,即事件溯源模式。该模式使用只可追加的存储来记录对数据所进行的所有操作,而不是存储领域数据的当前状态。其中,存储 Event 记录的介质称作 Event Store。
CQRS(Command Query Responsibility Segregation),即命令和查询责任分离。该模式使用单独的接口来隔离更新数据(命令)的操和读取数据(查询)的操作。这意味着用于查询和更新的数据模型是不同的。
在实践中,通常将 Event Sourcing 模式与 CQRS 模式相配合。通过命令接口将事件持久化存储,通过查询接口读取 Materialized View(物化视图)。物化视图可以由事件处理器实时或定期生成。

命令和事件
基于消息的系统
从上图可以看出,整个系统是基于消息驱动的。事件就是消息,命令接口可以视为消息的生产者,事件处理器就是消息的消费者。Event Store 是持久化存储介质,能保证数据不会丢失。命令接口只要确保事件进入 Event Store 就能返回,而事件处理器在后台运行,为查询接口生成物化视图。整个系统没有阻塞操作,十分高效。
缺点是,查询接口获得的数据并非实时的,系统只能保证数据的最终一致性。不过这点对于普通应用来说都不是问题,毕竟在网页或 APP 中显示的信息,本质上都属于“历史数据”。
并发、并行和数据最终一致性
假设一个理想化的 Event Sourcing & CQRS 系统,所有的事件都严格按顺序存储,那么只要按顺序遍历事件,就可以得到系统的任意时间的状态。事件就好比数据库通过日志,并且每个事件都是原子操作。每处理一个事件,就能得到唯一对应的系统状态。这样就可以保证数据的一致性。
不过在这样的系统里,事件无法被并行处理,命令接口会成为性能瓶颈。为了实现事件的并行处理,就需要部署多个事件处理器,而每个处理器仅负责处理一个类型的事件,并且要保证不同类型的事件的处理顺序不会影响到数据的一致性。
一种可行的事件分类方式是按照聚合根分类。每个逻辑处理器仅处理仅负责处理一个聚合根的事件,每个事件也仅涉及到一个聚合根。如果一个操作需要涉及到多个聚合根,那就等于是一个分布式事务。分布式事务的提交方案有 2PC/3PC 等。根据事件溯源模式的特性,我们采用 Saga 分布式事务解决方案来实现数据的最终一致性。
关于 Saga 模式,可以参考《分布式事务:Saga模式》和《Saga分布式事务解决方案与实践》。
命令接口允许返回数据
标准 CQRS 模式的命令接口,只接受写入参数,不返回数据。要获取数据,必须通过查询接口。然而在实践中要遵守着一条会带来很多麻烦。首先,我们无法得知命令是否执行成功;其次,对基于数据库自增字段的实体 ID,我们无法得知新建实体的 ID。
因此,命令接口返回数据是有必要的。具体实现上,可以分为两种模式:
- 异步模式:返回一个唯一标识,通过该唯一标识,异步查询执行结果;
- 同步模式:阻塞直到命令被处理完毕,并将结果返回。
Event Store 存储介质
当系统运行一段时间后,Event Store 保存的事件会积累得越来越多。数据存储和生成物化视图都会成为瓶颈。一种可行的解决方案是定期生成系统快照,对于快照生成之前的事件数据可以离线归档,线上只要保留上次快照后新生成的事件数据即可。
Event Store 的存储介质要支持顺序写入,能够使用索引快速检索。
物化视图
物化视图不是领域模型的视图,也不是系统模型的视图,而是面向查询的视图。
案例
以用户之间转账为例,假设我们从账户 A 转一笔金额为 x 的资金到账户 B。我们将整个事务拆分为两个子事务:
- T1 = 从账户 A 中扣除数量为 x 的资金;
- T2 = 向账户 B 中添加数量为 x 的资金。
如果两步都执行成功,则转账事务完成;如果 T1 执行失败,比如余额不足,则转账失败;如果 T2 执行失败,比如账户 B 不存在,则回滚 T1,转账失败。
当然,这只是个简化的模型,需要优化。现实情况可能会更加复杂。比如规定账号被锁定,余额不能变化。那么有可能会发生当 T2 执行失败,而账户 A 又已经被锁定,导致 T1 无法回滚的情况。我们必须采取某些补偿机制来保证所有子事务都是可回滚的。
从领域模型的角度思考,转账行为应该是一个聚合根,它有唯一的标识 ID(流水号)。甚至对于复杂的系统,转账系统可以单独视作一个子域。
- 创建转账事务,初始状态为 PENDING;
- 账户 A 扣款;
- 如果扣款成功