- CQRS的演变和架构
- Event Sourcing 原理与应用
- Event Sourcing 与CQRS的完美结合
- CQRS的例子
CQRS(Command-Query Responsibility Segregation) 是一种读写分离的模式,从字面意思上理解Command是命令的意思,其代表写入操作;Query是查询的意思,代表的查询操作,这种模式的主要思想是将数据的写入操作和查询操作分开。
它源于Bertrand Mayer设计的命令查询分离(CQS)原理。CQS声明一个类只能有两种方法:改变状态并返回void的方法和返回状态的方法。而Greg Young 是负责命名这种模式为CQRS 并推广它的人。
首先来看看在没有CQRS之前是如何处理系统中的修改和查询的吧,如图1所示:
图1 传统的系统请求
传统的系统请求从最左边的Client开始,沿着红线往右通过Application Service对系统进行请求。这里Application Service 可以理解为系统的门面,或者是Controller层负责接收客户端的请求,此时请求的内容比较简单基本和数据库中的信息一致,因此这里使用DTO(Data Transfer Object)直接请求。DTO经过Domain Model 以后直接到达Database,从而沿着蓝色的线条返回给Client端。传统的请求方式部分读操作和写操作,都使用同样的数据模型和一套Domain Model以及相同的数据库。
从传统操作来看Client的请求在经过Application Service,用户意图全部被分解为CRUD操作,但是在Domain Model中是无法体现的。为保证DTO的完整性和一致性,与操作无关的信息会被纳入DTO,查询操作和创建操作都共用一个DTO,而领域模型的业务流程被弱化。为了适应同时适应查询和创建操作,DTO被设计的面面俱到,也就显得臃肿。从而在传输中存在不必要的字段传递。
而且一次操作,在DTO与领域对象间进行多次转换,增加了系统复杂度。还有,读写操作将围绕同一数据模型展开,对于读多写少的系统而言效率并不是最高的,特别在读操作为主的高并发系统中缺点就尤为突出。
正因为传统系统架构存在上面这些问题,因此CQRS根据读写职责的不同,把领域模型切分为Command端与Query端两个部分,如图2所示,红色线部分就是Command端,其对应的是Domain Model 对其发送Command 操作的指令往数据写入状态信息。
Query端作为查询操作,由蓝色的线表示,通过Query Model向数据库获取信息,通过黑色向左的先返回结果给Client。Command端与Query端都通过Application Service 进入系统,共享同一个数据库,但Command端只写入状态,Query端只读取状态。
图2 CQRS 分为Command 端和 Query端
目前而言已经将读写操作分开了,由于两个操作依旧共用一个数据库,为了提高读写效率数据库的分离就成为必然的选择。如图3所示,于是将原来的Database,分离为Writer Database 和Reader Database分别用于写操作和读操作。为了保证读写操作的数据一致性,需要在两个数据库之间进行数据同步。
由于数据同步是由时效性的,因此写入方是Command端,读取方是Query端,因此系统智能保证最终一致性。那么如何保证两个库之间的同步呢?下面需要引入Event Sourcing的概念。
Event Sourcing也叫事件溯源,是Martin Fowler提出的一种架构模式。其设计思想是系统中的业务都由事件驱动来完成。系统中记录的是一个个事件,由这些事件体现信息的状态。业务数据可以是事件产生的视图,不一定要保存到数据库中。
为了便于理解Event Sourcing 我们通过一个例子来进一步解释,如图3 所示:
图3 Command 端和 Query端 读写数据库的分离
我们从左往右看。对于一个业务类“账户”,拥有“属性”包括“账户ID”和“账户金额”信息,同时拥有“方法”包括“创建账户”、“存现金”和“取现金”。中间绿色的事件序列,是针对“账户”进行的一些列操作,按照其中的序列号来看。
1. 创建了一个银行账户,假设此时的账户ID为“0001”。
2. 针对“0001”这个账户存入300元现金。
3. 然后从“0001”这个账户取出100元现金。
4. 最后,再存入200元。
上面生成的这一系列事件会保存到下方的Event Store的事件库中,这里并不会保存“账户”的状态信息。当需要获取“账户”数据的时候,会通过这些事件信息,还原成“账户”的最终状态,也就是“账户ID”为“0001”,“账户金额”为400。其具体实现方式是,通过账户相关的四个事件对应的处理方法,重新生成当前状态。如果每次查询状态信息都需要这样处理势必会造成资源的浪费,因此在右侧黄色的部分,我们将最终的“账户”信息通过视图的方式保存下来,以供查询。
Event Sourcing 包含的内容
-
Event Store:在Event Sourcing模式中,事件所保存的数据库称为Event Store。在事件中需要包含聚合对象的ID,以及事件的顺序。这样在查询的时候可以根据聚合ID从数据库中找到相关的事件,并通过事件的序号还原执行顺序。也就是事件的重现,也就是某一时刻执行的事件取出来,调用他的处理函数,还原那个时间点的业务状态。
-
为了获取最新的“账户”状态信息,需要通过Event Sourcing 中获取对应的事件进行回放,从而获取当前的状态,这样的操作会浪费很多资源。因此我们会将聚合对象的最新数据状态,写到一个表中,这个表就是视图。又或者将这个状态信息发送给其他的应用程序进行后续的业务操作。
-
查询的内容是针对“账户”最终状态的,因此针对的对象应该是视图。这里的设定刚好的CQRS中的读写分离不谋而合,通过Event Store存放Command 端的Event 信息,通过视图存放实体最终状态的信息,而Query 端从视图查询数据返回给用户。
Event Sourcing 的优缺点
-
溯源事件与重现操作:特别是在业务复杂的系统中,一个事务包含多个操作,它们有的是并行有的串行,如果需要了解操作的执行就需要对每个事件了如指掌。Event Sourcing 恰恰提供了事件的历史信息,方便查找任何时间点发生的事情。 -
追踪和修复Bug:可以通过事件分析业务的执行过程,帮助发现Bug,例如重方Bug产生时的事件序列,从而定位Bug所处位置。发现Bug并且修复以后,可以通过重新聚合业务数据,重放执行的事件序列验证修复结果,同时将Bug造成的损失进行挽回。 -
提高性能:Event Sourcing模式下,由于是记录事件执行的序列,因此都是新增操作,没有更新操作,相对于需要更新操作的系统而言记录数据的性能是提高了。如果使用视图的方式将实体的最终状态可以传递给其他的应用,而不用写入数据库以后再读取,这种做法也提高了效率。
-
转变思路:Event Sourcing的落地需要在设计时就用领域驱动的方式开展,需要有基于事件的响应式编程思维。这种方式需要以领域模型设计优先,而不是传统的数据库设计优先。
-
变更事件结构:随着业务流程的变化需要不断调整事件结构,对事件添加或者修改一些数据。这种行为会影响到“历史重现”,需要考虑兼容之前的事件结构。
-
处理幂等事件:如果对应的事务在执行过程中被中断,需要通过事件回放的方式达到事务的最终一致性问题。此时需要对事件的幂等性提出要求,也就是同一个事件运行多次得到的结果不变。需要在事件处理时丢弃重复事件。
-
查询事件数据库(event store):由于数据库中存放的一个个事件,如果针对实体状态的查询会相对困难。需要将这些事件重放,获取最新的实体状态的信息。这也是为什么需要通过CQRS的方式将读写进行分离,Command端使用Event Sourcing 而Query端使用Event Sourcing 发出Event 的最终状态进行查询的原因。
通过上面对Event Sourcing 的介绍,可以发现它针对Event 进行记录存放到Event Store中,并且把最终的状态放到视图中进行保存可以供给Query端进行查询。这种模式天生与CQRS就有默契的配合。
从CQRS模式的结构看,实体状态的变化发生在Command端,Command端知道业务处理进行了哪些具体操作,将这些具体的操作进行封装就形成了Event。
而Query端,查询返回的是实体当前状态状态。根据“当前状态 + 变化 = 新的状态”,如果能从Command端得到“变化”,再加上Query端自身获取的“当前状态”就能得到变化后的“新的状态”。
此时Command 端发出的Event正好符合这个“变化”,如果当变化发生也就是新Event产生时,由Command端将这个Event推送到Query端,Query端根据Event刷新状态,就能保证两端实体状态一致,达到最终一致性,如图4所示:
图4 Event Sourcing 和 CQRS 结合
在图3的基础上加入Event Handler 也就是图中蓝色部分,这部分接收从Domain Model中发过来的Event信息,也就是最新的实体修改信息。再将这个信息存放到Reader Database(也可以理解为视图)中,这样新的Event 信息加上当前的实体信息就时最新的实体信息了。而采用这种方式以后Query 端依旧可以通过Reader Database获取数据对其原来的操作并没有产生影响。
再回到Command端,其对应的多次操作的Event 会存放到Event Store中,作为业务跟踪的记录被保存下来。
上面提到的只是一种系统架构的模式,在实际运用中可以根据具体情况进行改进和优化。如图5所示,可以在Command 端和Query 端进行Event 交换的时候加入队列,满足两套应用程序部署在不同进程的场景需求。
图5 Command 端和Query 端加入队列
图4 Event Sourcing 和 CQRS 结合
-
绿色向下的线:其连接了紫色的区域是UserProjection,它的作用是将Write database的数据同步到Read database中。
-
蓝色向右的线:Client 发起Query请求通过AddressByRegionQuery类和ContactByTypeQuery类构建请求,将其传送到UserProjection类进行处理,其中handle方法分别对两类参数的请求进行处理。最后通过UserReadRepository获取Read database中的信息。
-
紫色向左的线:当从Read database 中获取信息以后,返回给Client。
-
通过CreateUserCommand,创建新建用户的Command,并且通过UserAggregate生成对应的事件。
-
通过UserProjector将事件映射到Query端的数据库中。
-
通过UpdateUserCommand,创建更新地址信息的Command,生成对应的事件。
-
通过UserProjector将事件映射到Query端的数据库中。
-
通过AddressByRegionQuery,创建查询地址信息的Query。
-
执行查询从Read database 中获取数据与假设值进行比较。
图16 文件结构
本文从CQRS的演变切入,介绍了如何从“读写一体”过渡到“读写分离”的CQRS的架构方式,以及CQRS方式的几种表现形式。通过读数据库与写数据库之间同步的问题,引出Event Sourcing 的原理和应用,包括Event Sourcing的内容和优缺点。从而得出CQRS与Event Sourcing 结合完成读写分离的结论。最后,通过一个CQRS的例子带大家从代码的角度走了一遍CQRS的流程。