2

从单体架构转向CQRS - Wu

 1 year ago
source link: https://www.jdon.com/61171
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

从单体架构转向CQRS - Wu
软件设计是一个不断发展的过程。每一个大系统都是从一个小系统开始的。当现有架构遇到问题但无法解决时,系统将开始演进。每一次进化都伴随着一些技术选择。应该解决哪些问题?它会付出怎样的代价?作为架构师或高级工程师,必须找到合理的演进方式,无论开发进度、技术堆栈、团队水平如何,都必须能够满足这些标准,然后才能做出可行的解决方案。

本文将介绍CQRS(Command Query Responsibility Segmentation)的精神和要解决的问题。我们将从一个小的单体开始,像每个软件系统的演进一样进行演进,本文将介绍每个演进背后的原因和方法。

传统的单体架构

v2-9e04abaa609400e015450a1a136a7642_720w.jpg

这是最常见的系统设计。有一个 API 服务器,通常是一个 RESTful API 和一个数据库。客户端提前与后端协商传输格式。读取和写入都是通过数据传输对象 DTO 完成的。而后端在处理业务逻辑时,会将DTO转化为具有领域知识的领域对象,并以领域对象作为数据库的存储单元。

为了实现Read/Write Splitting,在左边的写路径中,客户端向上发送DTO到后端对数据库进行CUD(create/update/delete)操作,后端用Ack响应客户端success 和Nak为处理后的失败。在restful API中,通常2xx代表成功,4xx代表失败。右边的读路径简单的通过读请求获得对应的DTO。

我进一步为客户解释了DTO的含义。客户端上的 DTO 通常包含要在屏幕上呈现的所有数据。例如,当您在社交媒体上查看您的个人资料时,它将包括您的姓名、帐户和其他个人信息,以及您自己最近的活动,甚至是您关注的活动。DTO 包含需要在此页面上显示的所有信息。

为什么我们需要强调读/写拆分?我们不能在读写路径上使用相同的过程吗?因为我们希望在未来更好地优化我们的系统。写路径有特定的优化方法,读路径也有。例如,要制作缓存,可以在读取路径上使用只读缓存来减少响应时间。并且,可以通过缓存写入来改进写入路径。其次,写入也可以异步执行。所有的 DTO 都写入消息队列,由 Worker 处理,以处理大量写入的数据。此外,每个适当的数据库都可以用于写入和读取。

因此,读/写拆分是必不可少的。并且在系统设计的早期阶段就应该考虑到这一点。写入路径是专注于数据持久化;而读取路径则专注于数据查询。

然而,这种系统设计模型存在两个主要问题。

  1. 贫血模型。它也被称为 CRUD 模型。当后端专注于数据转换时,很难有处理业务逻辑的空间,这会导致业务逻辑四处分散。领域知识也会消失,例如对于电子商务网站,我们会说“购买”而不是“插入订单记录”。
  2. 扩展性不足。从系统架构来看,数据库很容易成为整个系统的瓶颈。阅读和写作都必须在上面。由于没有水平扩展,RDBMS 的问题更加严重。

基于任务的单体
为了解决上述传统单体所遇到的问题,这里我们尝试引入域的概念。

v2-e32f40f6395da99b2bee56a1b7049137_b.jpg

此图与上图基本相同。唯一的区别是将 DTO 替换为写入路径上的消息。消息包含操作和数据,而不仅仅是像 DTO 这样的数据本身。因此,我们可以在消息中携带特定领域的动作,使后端更容易识别每个动作,并有相应的领域实现。

在这个阶段,C inCQRS已经出现,message 是一种命令。但是,可扩展性的问题仍然没有解决。

另外,虽然我们简化了DTO,改为使用消息进行通信,但在读取路径上仍然需要DTO。让我们再次以社交媒体为例。修改昵称时,消息的格式可能是{"rename": "LazyDr"}. 但是在渲染配置文件时,我们仍然需要额外的信息,例如活动。这种信息差距使得有必要在读取路径上进行大量处理以检索 DTO。

CQS(命令查询分段)
CQS的出现就是为了解决上述Read/Write Splitting的痛点。
读取时,客户端需要DTO,所以后端可以在读取路径上做一些专门用于读取的优化,比如从原始域对象预先生成DTO,将DTO存储在专用数据库中进行读取。

v2-e5725666066d876101061740e3a83b49_b.jpg

这样,在读路径上,应用服务的实现就变得更简单了。应用服务可以变成一个瘦读层,只需要负责分页、排序等工作,客户端请求之后,就可以很方便的从数据库中取回DTO。

所以问题是,谁来生成这些预先构建的 DTO?这是写路径的责任。

v2-526a6ae767cef02b243dcfdf5d816438_b.jpg

虽然图和之前看到的例子差不多,但其实应用服务除了要持久化领域对象外,还必须要持久化DTO。也就是说,大部分业务逻辑都会压在写路径上,需要准备各种读视图。

在这个阶段,我们已经解决了领域遇到的大部分问题,但是缩放仍然没有解决方案。现在,我们进一步定义缩放。缩放有两个不同的方面。

  1. 流量:写入量增加。
  2. 扩展:功能需求增加,比如需要各种不同的读取视图。继续以社交媒体为例,个人资料上有一个演示文稿,但时间轴上可能有另一个演示文稿。

CQRS
为什么写路径负责准备读视图?写应该关注持久化,那些各种读视图不应该在写路径上处理。但是读取路径上只有读取,谁应该准备那些读取视图?
因此,整体解决方案如下。

v2-a201ee987471e15a28d3fd913587246f_b.jpg

左边的写路径和右边的读路径已经在CQS部分介绍过了。唯一的区别是添加了一个finally块,负责将写路径上的数据库转换成读路径上使用的数据库。一旦涉及到数据同步,就有可能遇到数据一致性问题,所以这里列出了几种实现最终一致性的方法,按照耗时从短到长排序:

  1. 后台线程:典型代表是Redis。数据写入主节点后,Redis 会立即将数据发送到后台的副本。
  2. 消息队列加工作者:这是异步数据复制的常见做法。写入数据库时​​,会在消息队列中启动一个事件并由工作人员处理。
  3. Extract-Transform-Load:这个时间间隔最长,从几分钟到几小时不等。使用 map-reduce 或其他方法将结果写入另一端。

无论采用哪种方法,唯一的事实来源都是强制性的。也就是说,如果转换发生任何故障,系统必须能够恢复未完成的工作。因此,数据必须是唯一且可靠的。

数据通常分为两种类型,

  1. state:state 是指你此刻看到的,比如银行存折上写的余额。
  2. 事件:事件是修改每个状态的动作,例如银行存折上的每笔交易记录

实际上,我们已经有了可以存储为事件的消息。对于写路径,按顺序存储消息是非常有效的。通过每条不同的消息,您可以根据需要轻松构建不同的阅读视图。这种方法也称为事件溯源

但是只有事件很难有效地使用。为了获得最终结果,每次转换都必须从头到尾运行以重建读取视图。因此,混合方法将是理想的。在写路径上,状态和事件都保留,转换过程可以根据实际情况选择数据源。
总结一下CQRS中数据的整个生命周期。

v2-6afccf77b06e65ac00bd293b1a34e64e_b.jpg

数据从客户端开始,然后以命令格式进入后端。根据业务逻辑,将其转换为领域对象并存储在数据库中。这些领域对象被转换成各种读取视图,并根据需要存储在不同的读取专用数据库中。最后,客户端将这些读取视图以 DTO 的形式取回。

结论
有许多书籍和文章描述了具有多种模式的 DDD 和 CQRS。在我看来,这些模式限制了实体、值对象、聚合等 DDD 的想象力。这导致大多数开发人员觉得 DDD 离自己很远,很难实现和实现。实际上,DDD 的概念并没有那么复杂;相反,DDD被提出来封装业务逻辑,然后方便扩展功能需求。

CQRS 更简单。在这篇文章中,我们从系统演化的过程入手,了解整个系统设计过程和要解决的问题,最后自然得出CQRS的结论。
系统设计中没有灵丹妙药。每次进化都是为了解决一些特定的问题,然而,它可能会出现一个新的问题。以本文的设计过程为例,CQRS 似乎解决了所有提到的问题,模型贫乏,可扩展性不足,但实际上 CQRS 也带来了新的问题,比如数据一致性。每个技术选择都有它的取舍,只要了解每个选项背后的所有威胁,就可以选择相对可接受的方法。

即使你选择了 CQRS,在实践中,实现最终一致性仍然有三种选择。系统设计是不断选择的结果。
这篇文章的目的是告诉你,DDD 没有那么可怕,CQRS 也没有那么复杂,它只是一个决定。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK