共计 3948 个字符,预计需要花费 10 分钟才能阅读完成。
导读 | 由于存在增删改与查询逻辑有差异的这个问题,为了更好的针对差异进行抽象,我们可以将它们分开进行设计。也就是我们的 CQRS 模式,即命令查询的责任分离 Command Query Responsibility Segregation 模式。 |
当我们系统中的数据模型层级较少时,数据模型足够简单时,模型与数据库可以直接进行映射。这种简单数据模型使我们不需要针对其相互关系进行复杂的建模设计,直接在工程中使用经典的三层模型就足以支撑项目需求。
对于这种简单系统,过度设计会增加后续维护、重构的成本 (并不能保证预设计能完美符合后续需求)。同时,对于简单系统,我们大部分的需求都只涉及其中的少量数据模型逻辑处理。
而我们直接对数据模型进行 CURD 就能满足需求,进而的结论就是:
针对简单需求,我们不需要特别区别查询和增删改的程序结构。
如果我们的系统具有一定复杂性,这种复杂性可能是源于访问频次、数据量或者是数据模型数量。这时候我们遇到的问题是数据在查询和更新的需求差距逐渐变大。
- 频次:数据的查询频次会远高于新增、更新、删除频次。
- 数据量:数据量变大后会增加对数据进行分库分表的设计诉求,从而导致数据查询变得的复杂性 (涉及分表关键字)。
- 数据模型数量:数据模型数量的增大,会导致在进行新增、更新与删除操作时同时影响的数据模型变多,而在查询时同时跨多模型的查询条件会让查询的性能具有极大的挑战性。
根据以上举例我们可以发现,当我们的需求具有一定的复杂性后,根据引入复杂性的不同,会导致系统功能上需要用更加复杂的设计来对需求的复杂性进行支撑。同时我们也可以发现,引入的不同复杂性在增删改和查询方面的带来的功能需求差别很大。
所以:
需求的复杂性会放大程序中查询和增删改的设计差异。
如果我们对系统整体的构建与设计有了更高的可维护性与可扩展性要求,以至于我们需要使用 DDD 来设计整个系统。
在这种情况下往往模型中具有相对复杂的模型关系,在增删改时我们需要将所有请求封装为领域对象,以便程序可以基于领域模型完成大量复杂的校验、业务逻辑。而在查询需求时,我们常常需要组织跨领域数据来完成一个列表中数据内容的展示。所以:
在 DDD 设计中,增删改操作便于应用领域模型执行,而查询操作往往无法直接通过领域模型执行。
根据第一节中的内容我们可以发现,在进行系统架构设计时,当系统出现复杂性后存在一个核心问题:
增删改类型的功能与查询类型的功能,在功能需求上具有较大的差异。
这种差异带来的直接结果就是在系统开发的过程中,针对增删改和查询操作的业务设计上差异会比较大。如果举几个例子来说的话,比如:
- 针对增删改系统我们需要事务来保证多领域模型的更新原子性; 针对查询我们需要增加缓存来提高热点数据的查询性能。
- 数据读取和写入的模型通常是不匹配的,他们维护和查询的列或者属性坑没有交集。
- 在更新的时候查询数据可能会产生冲突。
- 使用统一模型进行存储可能会导致复杂查询时的性能降低。
由于存在增删改与查询逻辑有差异的这个问题,为了更好的针对差异进行抽象,我们可以将它们分开进行设计。也就是我们的 CQRS 模式,即命令查询的责任分离 Command Query Responsibility Segregation 模式。其中我们称增删改为命令型操作。
CQRS 本质上是一种读写分离设计思想,这种框架设计模式将命令型业务和查询型业务分开单独处理。通过这种方式,CQRS 可以针对命令和查询单独进行业务模型上的设计,从而用更加适合各自场景的方案与组件来提供能力。
查询操作并不会修改数据库中的内容,所以查询本身是一种幂等操作,以同一个查询条件在系统不改变的情况下反复执行会返回相同的结果,我们可以针对这种特性提供数据缓存来提高系统性能; 同时因为不影响数据库,查询逻辑是不会产生数据一致性问题。查询往往会存在较高的使用频率。
命令操作会直接修改数据库,并针对多个领域模型的情况下我们需要增加来保证操作的原子性。而对于一个命令操作,我们往往是不直接依赖命令的返回值的,所以通常可以异步执行命令操作。对于一般系统来说,往往命令操作的使用频次会较低。
由于 CQRS 的本质是对于读写操作的分离,所以比较简单的 CQRS 的做法是:
CQ 两端数据库表共享,CQ 两端只是在上层代码上分离。
这种做法在不对数据库进行分离设计的情况下,CQ 两端在上层代码进行分离个字单独维护,例如命令型的都用 xxxManagerController、xxxManagerService 来定义,而查询则直接用 xxxController、xxxService 定义。
因为使用同一个数据库,所以没有 CQ 两端的数据一致性问题。但因为已经对上层代码进行了抽离,所以可以满足一些设计特性如:
- 命令应基于任务,而不是以数据为中心。
- 命令可以放置在队列上进行异步处理,而不是同步处理。
- 查询从不修改数据库。查询返回的 DTO 不封装任何域知识。
这种方案可以满足代码逻辑上的分离维护,但由于是使用同一数据库表,所以无法根据 CQ 两种业务的特点单独进行模型设计。
在代码分离的基础上,我们可以再将数据存储的模型进行物理分离,读取存储可以是写入存储的只读副本,使用多个只读副本可以提高查询性能; 也可能为读取模型单独设计库表。单独对查询和更新进行模型设计可以减小设计和实现的难度。并且此时读取数据库可使用自己的已针对查询进行优化的数据架构。比如读数据库可以直接存储查询数据宽表从而避免进行 join 操作或者复杂的查询映射。甚至可以针对读取操作使用 mongo 或者 es 等 nosql 数据库对查询逻辑进行增强。
分离后的数据将存在在不同的数据库中,Q 的数据由 C 端同步过来。通常,这是通过在每次更新数据库时使写入模型发布事件来实现的。而说到数据同步则就有同步执行和异步执行两种方案:
- 同步:导致性能降低,但是可以保证数据的强一致性。
- 异步:拥有较高的性能,但需要系统接受最终一致性的。
同样的,这种同步也可以解释为对缓存进行的更新,即:查询数据库是使用缓存,而写入数据库使用普通 MySQL,两者之间数据同步通过领域事件实现最终一致性。
进一步的,由于命令操作实际上是对“操作”进行的记录,而只有查询才需要将所有的操作进行汇总展示。基于这种思想,可以使用事件溯源 EventSourcing 模式来进行命令操作的记录。在这种方案下,保存记录时更新的不是当前的记录,而是会导致状态变化的事件日志,每个事件表示对数据所作的一系列更改,而我们可以通过重播事件构造数据当前的状态 (可以参考 Mysql 的 Binlog 设计)。这种记录的优点是可以根据回放,重现每一次状态变更的时间点以及变更轨迹。而查询则可以根据当前状态的快照来为查询提速。来自于网络的架构图:
这种设计模式听起来就比较复杂,但是却有很多好处,例如:实现透明的分布式处理,当使用事件作为状态改变的引擎时,你可以通过实现多任务并发处理,比如通过 JVM 并行计算或事件消息总线机制,事件能够很容易序列化,并在多个服务器之间传送。同时因为是保留的操作记录,可以在回放的时候对于异常操作数据进行过滤,从而增加了数据的鲁棒性。
如果希望使用 CQRS,根据你希望实现的系统性能,你需要评估当前系统架构以及个人经验是否有以下能力:
- 复杂性设计:尽管 CQRS 基础理念较为容易理解,但是这种模式会导致系统的构建复杂度上升,尤其是进一步使用事件溯源模式时。
- 消息队列处理:在进行高性能设计的时候,通常会使用消息处理命令和发布更新事件。在此情况下,应用程序必须处理消息失败或重复的消息。
- 最终一致性:如果分离读取和写入数据库,读取数据可能会过时。必须更新读取模型存储,以反映对写入模型存储区所做的更改,并且在用户根据过时的读取数据发出请求时,可能很难检测到这种情况。
对于以下场景不建议引入 CQRS:
- 领域或者业务十分简单。
- 基本的 CRUD 就可以支撑完整的系统数据访问需求。
如果系统存在一定的复杂性,并且有以下的特点,则可以根据特点,选择适合的 CQRS 实现方式。
- 在用户操作中,需要在用户界面中进行一系列的复杂操作来最终定义、组装、修改领域模型。写模型需要有完成的命令处理堆栈,包括:输入验证、业务处理、业务验证。而读模型只需要返回视图中所用到的 DTO 数据。读模型与写模型只需要最终一致性关系。
- 对于用户的操作访问,需要以较小的粒度定义命令,并通过合并命令的方式避免命令冲突。
- 数据写入和数据读取之前存在比较大的性能区别,需要分开进行数据优化。尤其是读取次数远大于写入次数的场景,可以对读模型进行水平扩展。
- 当团队人员可以分拆分,组成专门针对复杂业务写场景的组,以及专门针对高频查询和用户界面的组。
- 当系统随时间不断演进,不断包含多个版本的模型,或者业务规则会定期修改。可以在写模式中包含多个版本的模型,而读模式中使用统一的视图模型。
- 与其他系统集成时,希望不会受到其他系统故障的影响 (读写库表分离)。
总的来说,CQRS 是处理复杂问题的一种具体实现方案,常用于配合 DDD 使用。
总结 CQRS 的主要优点包括:
- 独立缩放:CQRS 允许读取和写入工作负载独立缩放,这可能会减少锁争用。
- 优化的数据架构:读取端可使用针对查询优化的架构,写入端可使用针对更新优化的架构。
- 安全性:更轻松地确保仅正确的域实体对数据执行写入操作。
- 关注点分离:分离读取和写入端可使模型更易维护且更灵活。大多数复杂的业务逻辑被分到写模型。读模型会变得相对简单。
- 查询更简单:通过将具体化视图存储在读取数据库中,应用程序可在查询时避免复杂联接。