3
头图

DDD与CQRS结合

背景知识

讨论这个问题之前,我们先回忆几个基础概念。

聚合根: 遵守不变性规则设计的聚合边界,聚合内的所有操作都是严格遵守业务规约具备强一致的保证

聚合根的概念很抽象,但却是领域建模核心中的核心。这里不讨论领域划分或者聚合根如何建立,仅谈谈我对"遵守不变性规则设计的聚合边界"这句话的理解。
首先我们得认识到领域建模本质上是一种知识建模的方案,在这个前提下,我们的建模必须不能违背业务法则。聚合根本质上就是"业务法则"在系统内的一致性体现,
比如在"拆单"这个语境下不仅仅是父订单状态变为已拆单,也必须伴随着子单的生成。
在此基础上与聚合根有关的另一条设计原则"通过聚合根访问实体"也就不难理解了,通过聚合根来有效的限制实体(可变对象)在系统内的访问,使实体的所有变更
都在"不变性规则"下进行,也就能够避免"业务法则"被破坏。

洋葱模型

两个同心圆层次:
代表传达机制和基础设施的外层;
代表业务逻辑的内层
洋葱模型

在理解了聚合根的基础上,我再来看下"洋葱模型"亦或是"六边形架构"就不难理解了。聚合根设计的根本出发点是"业务准则的高度内聚",从这个角度出发势必要求系统内
的数据扭转都必须经过领域模型约束,简单的可以将过程概括为
用户层(同步事件处理(即对外API)、异步事件处理(消息监听))==>领域层(业务逻辑)==>基础设施层(聚合内的数据变化持久化(DB或其他存储形式),跨系统的持久化变更(发送领域事件))

CQRS: 命令查询责任分离,读写双模型;

命令模型用于有效地执行写/更新操作,而查询模型用于有效地支持各种读模式。通过领域事件或其他各种机制将命令模型中的变更传播到查询模型中,让两个模型之间的数据保持同步。
CQRS两种实现策略
同步方案,实时双写,优点是实时性高,双写带来了额外的写时开销,具体案例可以参看《拍卖系统优化历程(一) -- 建立拍品Lot模型,实践CQRS》
异步方案,借助消息或者其他异步同步机制 ,让两个模型达到最终一致性(这里介绍我们如何借助数据库的读写同步策略来达到读写模型的最终一致性);
两种cqrs实现方案

支付流程的领域建模与CQRS落地

订单领域划分

订单基础领域划分
领域划分

订单支付领域划分

  • 安全的构建实体(不变性规则的一部分)

    围绕订单我们拆分成了多个领域("订单"作为各自领域的聚合根),这些不同领域上的订单虽然基于的底层数据是重合的,但"订单"在不同的上下文内的定义是不相同的。
    以"支付订单(PayingOrder)"与"待拆单订单(SplittingOrder)"为例,其本质上的底层数据都建立在同样的数据上,划分这两个聚合的边界一方面是其领域内聚焦的业务问题不相同,
    另一方面从"订单"这个实体上看其生命状态有着明显的边界,PayingOrder待支付订单,SplittingOrder已支付完成待拆单订单,订单是否支付这两个订单实体有了本质的区别。
    注意这里我们讨论的是实体状态而非数据状态,这一点很重要,在这个前提下如果订单状态不符合当前聚合下的状态要求则聚合内的实体构建失败,通过屏蔽非安全的实体(或者聚合)构建避免了不安全因素造成的影响。
    问题到此并没有结束,前面我们定义的不安全,有时候可能是某个时间段内的误判,比如由于限流降级、主同步延迟等策略生效导致了我们基于偏差的数据得出了"不安全的访问"的错误判定
    (这也是我们做读写分离比较头疼的问题),那么怎么解决?这里就要借助聚合之外的最终一致性策略来达到。

  • 聚合外的最终一致性

    我们在聚合内部需要追求强一致性保障,聚合外我们往往采用最终一致性来解除系统间耦合(上图中黑色箭头),对于聚合内产生的事件可以采用例如kafka这类的消息中间件来进行通信。
    必然可以做到对上面误判的重试(比如消息的重试)直至最终一致,而不用从业务实现上再去投入过多精力进行干预,因为这个系统间的一致性问题没有主同步延迟、限流降级的影响它也是存在的。

支付事件的处理过程

洋葱模型比较抽象,换个方式描述
支付事件处理
这里的具体实现可以参看我之前写的两篇文章
《DDD实践落地(二)》
《支付订单领域建模实践》

从CommandModel到QueryModel

前面提到了两种实现CQRS的机制,并不难理解。

  • 同步方案,DomainRepository访问读写模型各自的数据源,进行同步的数据更新;
  • 异步方案,CommandModel(领域实体)执行完命令后发送响应的领域事件,通过订阅领域事件来更新查询模型;
  • 借助数据层的读写同步机制,达到读写模型的最终一致性;

    CQRS读写职责分离,本质上是通过实体职责的分离简化了读模型的构造(优化查询),同时因为读模型不再承载写命令的执行所以也规避掉了读模型在数据不完备或者不实时的情况下不会进一步扩散过期读问题。
    在"订单支付"、"订单发货"这些场景我们采用了不同于其上两种方式的读写模型同步方案(或者说是异步方案的变种),在外部系统收到支付完成事件后通过读模型查询待拆单订单,待拆单订单的有效构造建立在
    待支付订单的支付行为的得到了一致性处理(PayingOrder负责了写模型对应的数据的强一致性,SplittingOrder只需要验证其状态即能够确保数据得到了完整的同步),若构建成功则意味着读模型数据的同步已完成进行数据返回,
    否则支付完成的订阅方继续重试等待读模型的数据同步完成。
    读写模型同步1
    具体的实现细节可以参看CQRS在一条订单系统中的实践(一)
    中关于订单读写库的使用细节

  • 读模型进行事件重放,达到逻辑的一致性;

    在读写同步的方案中,我们可以看到同步都是通过领域事件影响构成读写模型的数据层,并向上反馈来达到最终一致性。那么如果某些场景下读模型对于事件响应不通过数据层向上反馈,而是直接作用于逻辑层是不是依旧行的通?
    读写模型同步2
    具体的实现细节可以参看CQRS在一条订单系统中的实践(一)
    中关于"订单查询流程"的描述

    这个方案能够生效必须建立在读模型的构造过程中能够重放写模型的事件,许多场景下这个条件还是很难达到的。

总结

回过头来我们再看整个方案中我们并没有从业务实现上刻意的去做一些调整但依旧能够完成了CQRS的落地,而这一切的根本其实还是在于开篇所谈的聚合根的原则:"在聚合边界内建模真正的不变条件"、"在聚合边界之外使用最终一致性"。
从书本中来到实践中去,这也是我对近期实践的一次总结,希望能够大家一些启发。

image.png


答案在风中
139 声望47 粉丝

程序员,先后供职于盛大、阿里巴巴、一条,目前在字节🐂🐴