CQRS架构:解决领域层事务性和分析性需求
柏舟 新冠4年 06-27
使用CQRS(Command Query Responsibility Segregation)架构有助于解决两个问题:
- 领域层的数据一致性、容错性;
- 领域层的分析性需求。
如果看过CQRS架构的介绍就会发现,CQRS架构+事件溯源虽然接口设计很简洁,但是内部实现非常复杂,这是一致性等需求导致的设计固有复杂性。常规的设计将复杂性转移到数据库中,但是当系统越来越复杂,特别是使用异构数据系统时,越来越难维护数据的一致性。所以,系统固有的复杂性并不会消失,只会发生转移。基础设施解决不了,就需要在领域层中自己解决最终一致性、因果一致性,节点部分失效等等问题。当原有的数据系统失效时,复杂性就会重新显现,本文将简单分析一下CQRS常见的需求和复杂性。
核心特点
CQRS是将数据存储的读取操作和更新操作分离的模式。传统的REST设计模式是以数据驱动的,每个接口基本对应于一个数据对象,而CQRS是基于任务/领域对象的操作驱动的,当一个领域对象涉及多个实体时,使用CQRS会减少领域层逻辑的泄露。
领域事件建模的优势
CQRS常与事件溯源一起使用。在事件溯源中,我们假设事件的不可变,事件要么执行成功要么失败。该模式可以将命令排入队列中异步执行。命令执行的背后可以对接多个消费者/数据系统,保证数据系统的流式更新。
一般的方法是读取数据库的更新日志,将更新推送到其它系统(如electric search)中。这样的问题是如果一条命令执行了多个数据库操作,数据采集直接推送到其它系统中,领域层信息就丢失了。一旦命令内部的操作发生改变,派生数据系统的更新就必须跟随数据库操作而改变。
而使用CQRS和事件溯源可以分别维护不同的数据系统的逻辑,并且保证系统的实时性,版本更新只与领域事件的数据结构有关。下图使用的是消息队列通信,也可以使用Actor模型,在通信的可靠性、消息的持久化、故障的恢复有一定的区别。相对而言,Actor模型的通信直接,可靠性和实时性更好,但是需要自己解决消息的持久化、丢失和恢复的问题。而消息队列通信至少两次,可靠性更差,但是RabbitMQ、Kafka等软件提供了消息确认、持久化、延时队列等功能。它们仍然存在消息顺序的问题,比如写入消息队列的顺序与真实的顺序不同。
事件溯源
事件溯源本身更多的用于通过事件日志重建快照。事件和状态的关系就像导数与原函数的关系:
也就是说,状态的改变是事件驱动的。我们只需要注册事件的钩子函数就可以实现状态的管理,而不需要轮询来确认事件的更新,实现控制反转。
状态可以是:
- 事务型数据库存储的状态,记录核心数据;
- 分析型数据库记录的衍生数据、物化视图,例如搜索索引。
事件溯源的事件具有不变性,仅追加。CQRS与事件溯源结合就体现在:查询并不改变状态,只有命令改变状态,仅使用命令的日志就可以用于容错恢复操作。
事件溯源 | 常规方式 | |
---|---|---|
更新 | 追加日志 | 更新状态 |
处理错误 | 在上一次快照上重放事件直至最新状态 | 回滚 |
与传统的记录可变状态的架构比较
对于一般的Web应用(CRUD)是无状态的,而状态的管理和存储通过数据库等数据系统实现,一般的Web应用仅承担协议/数据结构的转换、用户认证和授权、领域层知识的封装等功能。
领域层接口的区别:DDD的设计实践上
从REST的字面意思上看——表述性状态转移,和CQRS没有太大的区别:它们同样强调状态的管理。只是CQRS更加强调查询和修改的分离。从目前的REST实践上看,REST更多的是对直接对数据封装,前端的一个操作可能对应多个REST接口的调用才能完成应用层的操作。而CQRS更强调领域操作的封装,一个命令背后可能对应多个数据库操作,这并不是说前端的一个操作就不能调用多个CQRS的操作,而是强调CQRS的每个命令需要在领域层确保原子性:要么成功或失败,不会存在中间状态。所以,你会发现CQRS和REST的差别很有限,更像是一个更好的实践方式。更大的差别还是结合事件溯源,在状态的管理上。
数据系统的问题和约束
在《数据密集型应用系统设计》分布式系统的挑战和一致性中介绍了数据系统中一些常见的困难,大致有:
- 硬件/软件的不可靠:宕机、网络延迟、时钟不一致、未知的延迟:系统的上下文切换、GC。
- 数据一致性要求:并发请求、因果关系的请求如何维护顺序,备份、分区的一致性。
数据系统像数据库等等,提供锁、事务等抽象的原因不是它们天生就有这个抽象,而是数据对象的数据本身附加了领域概念,领域对象的约束需要这些约束,才需要数据系统提供对应的保证。具体体现在:
- 唯一性等约束:比如名称唯一;
- 关系约束:数据对象之间有约束和因果关系;
- 数据的增删改查有竞争性问题。
由于基础设施的特点需引入:
- 事务:解决并发和故障等情况的一致性问题;
- 主节点切换:当主节点失效时需要将副节点提升为主节点;
- 分析性需求:上述所述的问题大多是事务型数据库的问题,而分析型数据库对海量数据的读写计算有更高的要求。
如果压根不存在并发写入、只有追加操作等情况,完全可以自己实现一套数据系统。只是自己实现数据库是一件不划算的事情,所以不得不接受现有数据库的抽象和局限。由于一个数据系统很难同时满足分析性和事务性需求,有时候不得不引入多种数据库,但是这时又会出现一些问题:
- 异构系统的数据一致性问题;
- 如何将事务数据库的更新实时更新到分析数据库上;
- 底层支持不同的数据库。
现实不存在一个能完全满足领域建模,同时保证容错性、一致性、可用性的数据系统,所以不得不接受现实,使用某些抽象例如事务、CQRS+事件溯源来管理数据。
分析性需求
使用CQRS不一定要严格实现消息回放重建快照,记录领域事件本身有助于保留原始信息,根据需要计算所需要的统计量。
原始信息重要的原因
类似数理统计充分统计量:我们在分析样本时,很少直接使用原始信息,而是对信息进行加工和压缩。在这个过程中可能提取了有关参数的全部信息,也有可能损失一部分信息。对于不同的统计量来说,统计量是否充分是不同的。例如,统计网址的访问次数仅需要请求访问的域名信息,但是统计一段时间窗口内的访问次数还需要时间戳。此外,还可以使用一段时间内的访问次数来估计总的访问次数,这种做法信息不充分,但是计算速度更快。
在系统中,我们需要在原始的数据记录上派生衍生的数据(物化视图)。我们希望:
- 尽量记录原始信息,并且没有冗余;
- 尽量记录结构化的信息,减小处理难度;
- 衍生的数据(统计量)能够实时更新,计算迅速、准确;
- 数据结构能够满足兼容性要求;
- 在满足以上要求的情况下,存储空间尽量少。
总结
CQRS架构的介绍最好还是看看微软的文档,本文仅简要介绍含义和需求。需要注意的是使用CQRS不一定要使用事件溯源,比如说GraphQL这种接口,内部仍然可以使用传统方式实现。
CQRS主要解决了数据一致性、容错性和分析性这两个问题:
- 高一致性,容错性要求:事件溯源更有利于实现快照和回放恢复;
- 分析性要求:记录更多的原始信息,有利于分析。
其它问题
- CQRS架构以异步方式运行,异步虽然提高了系统的部分容错能力,但仍然存在读同步问题(仅保证最终一致性),需要额外引入分布式线性化等方式解决。
- 升级兼容性问题:CQRS不适合原型开发,试想如果版本快速迭代,一个系统的事件存在多个数据结构,处理兼容性问题将异常痛苦。
- 不可变事件的删除:当存在合规/管理要求时,例如因为隐私问题必须删除某些信息,事件的删除将异常麻烦。事实上,由于数据存在备份以及物化视图,很难完全删除信息。
- 事件的数量和压缩:对于事件非常多的应用不适用,更适用于主要是添加数据的应用。
- 权限管理:将事件名称和人员的角色联系起来就可实现有效的管理,但权限管理确实是一个非常复杂的事情,以后有机会再介绍。
- 人员实现架构的复杂性:我认为想要用好CQRS架构需要对数据系统本身的问题有较深的了解,并且了解业务的实际情况,确定需要哪些分析性需求。使用CQRS很容易出现过度设计的情况。
- 错误处理。
我认为大多数要求不高的情况可以参考CQRS的接口设计,内部使用传统的方法实现,仅记录事件不使用事件溯源,快速做出原型就可以了。如果有分析性需求就在接口内部把事件打入log,然后跑批处理统计一下就能满足大部分需求。
系统设计的关键是快速实现需求,在成本、质量和时间取得良好的平衡。内部存在点问题或不优雅的地方很正常,程序和程序员有一个能跑就行。