CQRS架构:解决领域层事务性和分析性需求

柏舟   新冠4年 06-27

使用CQRS(Command Query Responsibility Segregation)架构有助于解决两个问题:

如果看过CQRS架构的介绍就会发现,CQRS架构+事件溯源虽然接口设计很简洁,但是内部实现非常复杂,这是一致性等需求导致的设计固有复杂性。常规的设计将复杂性转移到数据库中,但是当系统越来越复杂,特别是使用异构数据系统时,越来越难维护数据的一致性。所以,系统固有的复杂性并不会消失,只会发生转移。基础设施解决不了,就需要在领域层中自己解决最终一致性、因果一致性,节点部分失效等等问题。当原有的数据系统失效时,复杂性就会重新显现,本文将简单分析一下CQRS常见的需求和复杂性。

核心特点

CQRS是将数据存储的读取操作和更新操作分离的模式。传统的REST设计模式是以数据驱动的,每个接口基本对应于一个数据对象,而CQRS是基于任务/领域对象的操作驱动的,当一个领域对象涉及多个实体时,使用CQRS会减少领域层逻辑的泄露。

领域事件建模的优势

CQRS常与事件溯源一起使用。在事件溯源中,我们假设事件的不可变,事件要么执行成功要么失败。该模式可以将命令排入队列中异步执行。命令执行的背后可以对接多个消费者/数据系统,保证数据系统的流式更新。

一般的方法是读取数据库的更新日志,将更新推送到其它系统(如electric search)中。这样的问题是如果一条命令执行了多个数据库操作,数据采集直接推送到其它系统中,领域层信息就丢失了。一旦命令内部的操作发生改变,派生数据系统的更新就必须跟随数据库操作而改变。

flowchart LR; Request <---> Server ---> DB; A[DB LOG] --采集--> B[数据衍生系统1] & C[数据衍生系统2]; DB -.->A;

而使用CQRS和事件溯源可以分别维护不同的数据系统的逻辑,并且保证系统的实时性,版本更新只与领域事件的数据结构有关。下图使用的是消息队列通信,也可以使用Actor模型,在通信的可靠性、消息的持久化、故障的恢复有一定的区别。相对而言,Actor模型的通信直接,可靠性和实时性更好,但是需要自己解决消息的持久化、丢失和恢复的问题。而消息队列通信至少两次,可靠性更差,但是RabbitMQ、Kafka等软件提供了消息确认、持久化、延时队列等功能。它们仍然存在消息顺序的问题,比如写入消息队列的顺序与真实的顺序不同。

flowchart LR; Request <---> Server --打包成领域事件--> 消息队列 --订阅--> B[数据衍生系统1] & C[数据衍生系统2];

事件溯源

事件溯源本身更多的用于通过事件日志重建快照。事件和状态的关系就像导数与原函数的关系:

\[ stream(t)=\frac{\partial state}{\partial t} \]

也就是说,状态的改变是事件驱动的。我们只需要注册事件的钩子函数就可以实现状态的管理,而不需要轮询来确认事件的更新,实现控制反转。

状态可以是:

事件溯源的事件具有不变性,仅追加。CQRS与事件溯源结合就体现在:查询并不改变状态,只有命令改变状态,仅使用命令的日志就可以用于容错恢复操作。

事件溯源 常规方式
更新 追加日志 更新状态
处理错误 在上一次快照上重放事件直至最新状态 回滚

与传统的记录可变状态的架构比较

对于一般的Web应用(CRUD)是无状态的,而状态的管理和存储通过数据库等数据系统实现,一般的Web应用仅承担协议/数据结构的转换、用户认证和授权、领域层知识的封装等功能。

领域层接口的区别:DDD的设计实践上

从REST的字面意思上看——表述性状态转移,和CQRS没有太大的区别:它们同样强调状态的管理。只是CQRS更加强调查询和修改的分离。从目前的REST实践上看,REST更多的是对直接对数据封装,前端的一个操作可能对应多个REST接口的调用才能完成应用层的操作。而CQRS更强调领域操作的封装,一个命令背后可能对应多个数据库操作,这并不是说前端的一个操作就不能调用多个CQRS的操作,而是强调CQRS的每个命令需要在领域层确保原子性:要么成功或失败,不会存在中间状态。所以,你会发现CQRS和REST的差别很有限,更像是一个更好的实践方式。更大的差别还是结合事件溯源,在状态的管理上。

数据系统的问题和约束

《数据密集型应用系统设计》分布式系统的挑战和一致性中介绍了数据系统中一些常见的困难,大致有:

数据系统像数据库等等,提供锁、事务等抽象的原因不是它们天生就有这个抽象,而是数据对象的数据本身附加了领域概念,领域对象的约束需要这些约束,才需要数据系统提供对应的保证。具体体现在:

由于基础设施的特点需引入:

如果压根不存在并发写入、只有追加操作等情况,完全可以自己实现一套数据系统。只是自己实现数据库是一件不划算的事情,所以不得不接受现有数据库的抽象和局限。由于一个数据系统很难同时满足分析性和事务性需求,有时候不得不引入多种数据库,但是这时又会出现一些问题:

现实不存在一个能完全满足领域建模,同时保证容错性、一致性、可用性的数据系统,所以不得不接受现实,使用某些抽象例如事务、CQRS+事件溯源来管理数据。

分析性需求

使用CQRS不一定要严格实现消息回放重建快照,记录领域事件本身有助于保留原始信息,根据需要计算所需要的统计量。

原始信息重要的原因

类似数理统计充分统计量:我们在分析样本时,很少直接使用原始信息,而是对信息进行加工和压缩。在这个过程中可能提取了有关参数的全部信息,也有可能损失一部分信息。对于不同的统计量来说,统计量是否充分是不同的。例如,统计网址的访问次数仅需要请求访问的域名信息,但是统计一段时间窗口内的访问次数还需要时间戳。此外,还可以使用一段时间内的访问次数来估计总的访问次数,这种做法信息不充分,但是计算速度更快。

在系统中,我们需要在原始的数据记录上派生衍生的数据(物化视图)。我们希望:

总结

CQRS架构的介绍最好还是看看微软的文档,本文仅简要介绍含义和需求。需要注意的是使用CQRS不一定要使用事件溯源,比如说GraphQL这种接口,内部仍然可以使用传统方式实现。

CQRS主要解决了数据一致性、容错性和分析性这两个问题:

flowchart LR; CQRS ---- E[结合事件溯源] & A[领域约束] & B[基础设施约束] & C[优点] & D[领域层需维护事务] ; C ---- C1["分析性:原始信息"] & C2["事务性:不变性、快照和日志"]; D ---- 复杂;

其它问题

我认为大多数要求不高的情况可以参考CQRS的接口设计,内部使用传统的方法实现,仅记录事件不使用事件溯源,快速做出原型就可以了。如果有分析性需求就在接口内部把事件打入log,然后跑批处理统计一下就能满足大部分需求。

系统设计的关键是快速实现需求,在成本、质量和时间取得良好的平衡。内部存在点问题或不优雅的地方很正常,程序和程序员有一个能跑就行。