大多数应用通过同一个模型读写同一批表。这在两边的数据形态开始分化之前都没问题:写入需要对单个订单执行严格的业务规则,而读取需要跨越成千上万个订单的列表、搜索和汇总。CQRS 把两者分开,而事件溯源则从根本上改变了你存储的东西。
它们常被放在一起介绍,但其实是两个独立的决定。本文分别解释两者、它们如何配合、组合系统的架构图是什么样子,以及让许多团队后悔过早采用它们的那些成本。
一段话讲清 CQRS
命令查询职责分离(Command Query Responsibility Segregation)指用一个模型修改数据,用另一个模型读取数据。命令(PlaceOrder、CancelOrder)发往执行规则的写端。查询发往按页面需求设计的读端。Martin Fowler 的 CQRS 文章 把这个模式归功于 Greg Young,文中还有一句值得反复强调的警告:对大多数系统来说,CQRS 会带来有风险的复杂性。只在读写真正分化的地方使用它,而不是用在整个应用上。
CQRS 并不需要两个数据库。一个写模型,加上一组基于同一批表的 SQL 视图,就已经是 CQRS 了。等读负载或读取形态真正需要时,再引入独立的存储。
一段话讲清事件溯源
事件溯源存储的是变化,而不是当前状态。一个订单不是一行带状态列的数据,而是一串事件:OrderPlaced、PaymentAuthorized、OrderShipped。当前状态就是重放这些事件得到的结果。Fowler 的 事件溯源文章 详细介绍了这个思路。由于什么都没有被覆盖,你免费获得了完整的审计历史,而且随时可以为过去的数据构建新的视图。
两者如何配合
事件溯源几乎迫使你采用 CQRS。在修改一个订单之前通过重放事件来加载它,是个不错的方法;但要回答“列出所有金额超过 100 美元的未完成订单”,这就是个糟糕的方法。所以写端处理事件流,读端维护独立的读模型,并根据这些事件进行更新。
流程如下:
- 命令到达命令端。
- 处理器通过重放事件流加载聚合(订单),检查业务规则,并决定要产生哪些新事件。
- 它带着事件流的 期望版本 把事件追加到事件存储。如果在此期间有其他命令追加了事件,这次追加就会失败,命令会重试。这就是乐观并发控制,它取代了行锁。
- 投影器订阅事件流并更新读模型:一张用于订单列表的表、一个搜索索引、一组用于仪表盘的计数器。
- 查询只从读模型中读取。
事件存储
事件存储需要的功能不多,但每一项都必须准确:
- 只追加的事件流, 每个聚合一个,每个事件带一个版本号。
- 基于期望版本的条件追加, 并且是原子的。
- 读取事件流, 从头开始或从某个版本开始。
- 订阅, 按顺序接收所有新事件,并带有一个位置,投影器可以保存它并从那里继续。
专用的事件存储提供这些功能;一张在流 ID 和版本上建有唯一约束的关系表也可以做到,再配合 outbox 或变更流来实现订阅。
读模型与最终一致性
投影在事件存储之后才运行,所以读模型会落后几毫秒,积压时还会落后更久。设计时要考虑到这一点:
- 命令执行后返回新版本号,让客户端等到读模型追上,或者直接根据命令的响应显示结果。
- 给每个投影器各自的位置,这样慢的搜索索引不会拖慢订单列表。
- 让投影保持幂等。投影器会重启并重新读取事件;OrderShipped 被应用两次时,不能算成两次发货。
回报是读模型可以随时丢弃。新页面需要新的数据形态:写一个投影器,把历史重放进去。投影有 bug:修好它,删掉读模型,再重建。
实例:一张新报表
上线一个月后,财务要求按商品类别和日期统计收入,而且要包含历史数据。在 CRUD 系统里,你得写一个迁移和一个回填脚本,而且变更之前的历史可能已经没了。有了事件溯源,你只需写一个监听 OrderPlaced 和 OrderRefunded 的投影器,把它指向事件日志的开头,让它重放。等它追上之后,报表就覆盖了上线以来的每一个订单,并且此后会自动保持最新。
架构图
图表内容
- User
- Command side
- Event store
- Event stream
- Projectors
- Read model: order list
- Read model: search index
- Read model: daily totals
- Query side
- PlaceOrder
- append
- subscription
- GET /orders
这张图的读法如下:
- 上面一行,写路径: 用户的 PlaceOrder 命令发往命令端,命令端把事件追加到事件存储。一个订阅把事件送入事件流。
- 右侧: 投影器消费事件流,让三个读模型保持最新,每个读模型都放在最适合其查询的存储中。
- 左下,读路径: 查询发往查询端,查询端只读取读模型,不包含任何业务规则。
- Markdown 块 展示了一个带版本号的事件流,便于解释期望版本检查。
成本
- 模式演进永无止境。 事件会被永久保存,所以旧版本的事件必须一直可读。从第一天起就规划好向上转换(upcasting,即在读取时把旧事件转换成当前结构)。
- 删除数据很难。 要求删除数据的法规与不可变日志相冲突;不要把个人数据放进事件,或者按人加密,需要删除时销毁密钥。
- 长事件流加载变慢。 每隔几百个事件给聚合状态做一次快照,就能解决。
- 调试的位置变了。 bug 表现为读模型出错,而修复方式是重放,这需要相应的工具。
- 团队技能。 用事件建模是另一种思维方式。只有一个核心领域采用事件溯源就足够了,系统的其余部分可以继续使用 CRUD。
图上应该画什么
- 命令和查询两条明确分开的路径。
- 事件存储作为唯一可信来源, 读模型画成派生数据。
- 每个投影器及其读模型, 让读者看出哪些视图可能滞后。
- 一个示例事件流, 带上版本号,作为讨论的落脚点。
事件和消费者的机制在 事件驱动架构文章 中有介绍,事务性发件箱模式 则解释了在不使用专用事件存储时如何可靠地发布事件。Kafka 架构图 介绍了事件流本身。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加 C4 模型节点包中的一个 人员,以及表示命令端、投影器和查询端的 容器 节点。
- 添加一个 数据库 作为事件存储,再添加几个 数据库 节点作为读模型,并从数据节点包中添加 Kafka 和 Redis,分别表示事件流和计数器。
- 把写路径画在上方,读路径画在下方,投影器从右侧为读模型供数。
- 添加一个基础节点包中的 Markdown 块,写一个示例事件流。
这个示例共用 10 个图表节点,在 Free 套餐的限制之内。
常见问题
CQRS 和事件溯源必须一起使用吗? 不必。CQRS 可以配合普通数据库使用,事件溯源也可以只为一个读模型供数。不过,完全不用某种形式的 CQRS 的事件溯源很少见,因为在重放的事件流上执行查询很慢。
什么时候不该使用 CQRS? 当读和写使用相同形态的数据时,大多数 CRUD 页面都是这种情况。只在系统中读写分化的部分使用它。
读模型如何与事件存储保持一致? 最终一致。投影器按顺序处理事件并记录自己的位置;故障之后从该位置继续。客户端必须能容忍短暂的延迟。
以后要修改事件结构怎么办? 给事件加上版本,在读取时转换旧版本(向上转换)。永远不要原地修改已存储的事件。
事件溯源中的快照是什么? 聚合在某个版本时状态的一份保存副本。有了它,加载聚合时只需读取快照及其之后的事件,而不必读取整个事件流。