CQRS 架构模式
使用 CQRS 架构模式优化数据访问
1.CRUD系统
围绕关系数据库构建而成的“创建(Create)、读取(Read)、更新(Update)、删除(Delete)”系统(即CRUD系统)
我们平常最熟悉的就是三层架构,通常都是通过数据访问层来修改或者查询数据,一般修改和查询使用的是相同的实体。通过业务层来处理业务逻辑,将处理结果封装成DTO对象返回给控制层,再通过前端渲染。反之亦然。
这里基本上是围绕关系数据库构建而成的“创建、读取、更新、删除”系统(即CRUD系统)此类系统在一些业务逻辑简单的项目中可能没有什么问题,但是随着系统逻辑变得复杂,用户增多,这种设计就会出现一些性能问题。
存在的问题:
对数据库进行读写分离。让主数据库处理事务性的增、删、改操作,让从数据库处理查询操作,然后主从数据库之间进行同步。
-
为什么要分库、分表、读写分?
单表的数据量限制,当单表数据量到一定条数之后数据库性能会显著下降。
当一个订单单表突破两百G,且查询维度较多,即使通过增加了两个从库,优化索引,仍然存在很多查询不理想的情况。当大量抢购活动的开展,数据库就会达到瓶颈,应用只能通过限速、异步队列等对其进行保护。
对订单库进行垂直切分,将原有的订单库分为基础订单库、订单流程库等。
垂直切分缓解了原来单集群的压力,但是在抢购时依然捉襟见肘。原有的订单模型已经无法满足业务需求,可以设计了一套新的统一订单模型,为同时满足C端用户、B端商户、客服、运营等的需求,通过用户ID和商户ID进行切分)同步到一个运营库。
-
切分策略
-
查询切分
将ID和库的Mapping关系记录在一个单独的库中。
优点:ID和库的Mapping算法可以随意更改。
缺点:引入额外的单点。
-
范围切分
比如按照时间区间或ID区间来切分。
优点:单表大小可控,天然水平扩展。 缺点:无法解决集中写入瓶颈的问题。
-
Hash切分
一般采用Mod来切分,下面着重讲一下Mod的策略。
方法1:32*32
数据水平切分后我们希望是一劳永逸或者是易于水平扩展的,所以推荐采用mod 2^n这种一致性Hash。如果分库分表的方案是32*32的,即通过UserId后四位mod 32分到32个库中,同时再将UserId后四位Div 32 Mod 32将每个库分为32个表。共计分为1024张表。线上部署情况为8个集群(主从),每个集群4个库。
方法2:32 * 32 * 32
如果是32 * 32 * 32 (32个集群,32个库,32个表=32768个表)。
方法3:单表容量达到瓶颈(或者1024已经无法满足)
分库规则不变,单库里的表再进行裂变,当然,在目前订单这种规则下(用userId后四位 mod)还是有极限。
-
唯一ID方案
-
利用数据库自增ID
优点:最简单。 缺点:单点风险、单机性能瓶颈。
-
利用数据库集群并设置相应的步长(Flickr方案)
优点:高可用、ID较简洁。 缺点:需要单独的数据库集群。
-
Twitter Snowflake
优点:高性能高可用、易拓展。 缺点:需要独立的集群以及ZK。
-
-
带有业务属性的方案
> 时间戳+用户标识码+随机数
用户标识码即为用户ID的后四位,在查询的场景下,只需要订单号就可以匹配到相应的库表而无需用户ID,只取四位是希望订单号尽可能的短一些,并且评估下来四位已经足够。
-
数据迁移
数据库拆分一般是业务发展到一定规模后的优化和重构,为了支持业务快速上线,很难一开始就分库分表,垂直拆分还好办,改改数据源就搞定了,一旦开始水平拆分,数据清洗就是个大问题。
阶段1:
- 数据库双写(事务成功以老模型为准),查询走老模型。
阶段2
阶段3:
Tips:
并非所有表都需要水平拆分,要看增长的类型和速度,水平拆分是大招,拆分后会增加开发的复杂度,不到万不得已不使用。在大规模并发的业务上,尽量做到在线查询和离线查询隔离,交易查询和运营/客服查询隔离。
本质:
这只是从DB角度处理了读写分离,从业务或者系统层面上来说,读和写的逻辑仍然是存放在一起的,他们都是操作同一个实体对象。
2. CQRS系统
Command Query Responsibility Segration
命令(Command)处理和查询(Query)处理 (Responsibility )责任 分离(Segration)
命令与查询两边可以用不同的架构实现,以实现CQ两端(即Command Side,简称C端;Query Side,简称Q端)的分别优化。两边所涉及到的实体对象也可以不同,从而继续演变成下面这样。
CQRS 作为一个读写分离思想的架构,在数据存储方面,也没有做过多的约束。所以 CQRS可以有不同层次的实现。
CQRS 实现方式:
第一种实现:CQ 两端数据库共享,只是在上层代码上分离。
好处是可以让我们的代码读写分离,更容易维护,而且不存在 CQ 两端的数据一致性问题。因为是共享一个数据库的。这种架构是非常实用的(也就是上面画的那种)
第二种实现:CQ 两端不仅代码分离,数据库也分离,然后Q端数据由C端同步过来
同步方式有两种:同步或异步,如果需要 CQ 两端的强一致性,则需要用同步;如果能接受 CQ 两端数据的最终一致性,则可以使用异步。
C端可以采用Event Sourcing(简称ES)模式,所有C端的最新数据全部用 Domain Event 表达即可。要查询显示用的数据,则从Q端的 ReadDB(关系型数据库)查询即可。
第一种CQRS 的简单实现:
代码层面实现分离,数据库共享。
CQRS 模式中,首先需要有 Command,这个 Command 命令会对应一个实体和一个命令的执行类。肯定有很多不同的 Command,那么还需要一个 CommandBus 来做命令的分发处理。
假设有个用户管理模块,我要新增一个用户的信息。那么根据上文的分析,需要有个新增命令以及对应的用户实体(这个用户实体并不一定和数据库的订单实体完全对应)。
-
首先先创建一个命令接口,接口内部是这个命令的处理方法。
type Create interface{ Excute() }
-
创建用户的新增命令
type UserCreate struct{ account string password string } func (u *UserCreate) Excute(){ //检验是否合法,为了防止恶意的新增用户的请求 //然后创建一个和数据库对应的model //数据赋值 //插入到数据库当中 }
-
写好命令具体的执行逻辑之后,该命令的执行需要放到 CommandBus 中去执行
type CommandBus struct{ } func (b *CommandBus) DisPath( c Create){ c.Excute() }
-
Controller 层该如何去调用呢?
//java实现 @PostMapping(value = "/getInfo") public Object getOrderInfo(GetOrderInfoModel model) { return getOrderInfoService.getOrderInfos(model); } @PostMapping(value = "/creat") public Object createOrderInfo(CreateOrderModel model) { return commandBus.dispatch(createOrderCommand, model); }
查询和插入是不同的方式,插入走的是
CommandBus
分发到CreateOrderCommand
去执行.tips:
CQRS 是一种思想很简单清晰的设计模式,通过在业务上分离操作和查询来使得系统具有更好的可扩展性及性能,使得能够对系统的不同部分进行扩展和优化。在 CQRS 中,所有的涉及到对 DB 的操作都是通过发送 Command,然后特定的 Command 触发对应事件来完成操作,也可以做成异步的,主要看业务上的需求了。
3.CQRS解决了什么问题
当使用像 CRUD 这样的传统架构时,使用相同的数据模型来更新和查询数据库以获得大规模解决方案,最终可能会成为一种负担。例如:
- 读取端点可以在查询端对不同的源执行多个查询,以返回具有不同形状的复杂 DTO 映射。我们已经知道映射可能会变得相当复杂
- 在写入方面,模型可能会实现多个复杂的业务规则来验证创建和更新操作。
- 我们可能希望以其他方式查询模型,可能将多条记录合并为一条,或者将更多信息聚合到当前在其域中不可用的模型,或者只是通过使用一些辅助字段来更改查询查看记录的方式作为一把钥匙。
- 结果,我们围绕模型对象的 CRUD 服务开始做太多事情,并且随着它的增长变得最糟糕。
4.CQRS模式
CQRS 是Command and Query Responsibility Segregation
它的主要目的是基于将数据操作(命令)与读取操作(查询)分离的简单思想。为了实现这一点,它将读取和写入分离到不同的模型中,使用命令进行创建/更新,并使用查询从它们中读取数据。
如上图所示,您会注意到,每次在写入端创建/更新我们域的实例时,都会通过将事件推送到主题上来连接写入和读取世界的事件队列。然后,查询服务将从传入的事件中读取,对数据进行非规范化、丰富、切片和切块,以创建查询优化模型并将它们存储起来以供以后读取。
特别是,重点在于通过将事件溯源架构添加到组合中来利用 CQRS 模式。当我们希望保持此流程具有明确的关注点分离、异步以及利用适当的数据库引擎以提高查询性能时(例如,用于写作的 SQL 数据库和用于在物化视图上查询操作的 NoSQL),它非常适合查询以避免昂贵的连接)
除此之外,当我们使用事件溯源架构时,事件主题将成为我们的黄金数据源,因为它可以随时用于存放整个事件集合并重现数据的当前状态。这样我们就有可能从一开始就异步读取队列,并在系统进化时,或者读取模型必须改变时,从原始数据中生成一组新的物化视图。物化视图实际上是数据的持久只读缓存。
分离世界的另一个好处是有机会分别扩展两者,从而减少锁争用。由于大多数复杂的业务逻辑都进入了写入模型。因此通过分离模型使它们更加灵活并简化了维护。
5.CQRS适用场合
- 数据读取的性能必须与数据写入的性能分开进行微调,尤其是在读取次数远大于写入次数时。在这种情况下,您可以扩展读取模型,但仅在少数实例上运行写入模型。
- 允许读取最终一致的数据。由于这种模式的异步性质。
DDD 不是什么?
- DDD 不是一个软件框架。但是基于 DDD 思想的框架是存在的,比如 Axon,它是以 DDD 为指导思想,使用 Java 实现的一个微服务软件框架。
- DDD 不是一种软件设计模式。它不是像工厂,单例这样子的设计模式。但是 DDD 思想中提出了诸如资源库(Repository)之类的设计模式。
- DDD 不是一种系统架构模式。它不是像 MVC 之类的架构模式。但是 DDD 思想中提出了诸如事件溯源(Event Souring),读写隔离(Command Query Responsibility Segregation) 之类的架构模式。
1.DDD 到底是什么?
建模的方法论
软件是服务于人类,为提高人类生产效率而产生的一种工具, 每一个软件都服务于某一个特定的领域。比如一个 CRM,它是以管理客户数据为核心,帮助商户与客户保持联系的工具。而软件的实质是计算机中运行的代码,如何将抽象的代码更准确地映射到人类所关心的领域中,这是软件开发者一直在探寻的话题.函数式编程(FP)还是面向对象编程(OOP)也好,都是为了帮助开发者开发出更贴近于领域中的软件模型。
在传统的软件开发方法中,我们常常会遇到一系列影响软件质量的技术以及非技术问题:
- 开发者热衷于技术,但缺乏设计和业务思考。开发人员在不完全了解业务需求的情况下,闭门造车,即使功能上线也无人问津。
- 代码输入而非业务输入。技术人员对技术实现情有独钟,出现杀鸡焉用牛刀的情况。
- 过于重视数据库。以数据库设计为中心,而非业务来进行开发,结果往往是,软件无法适应一直在变动的业务逻辑。
DDD 是一种设计思想,一种以领域(业务)为出发点,以解决软件建模复杂度为目的设计思想.就是建模的方法论。
2.DDD 的设计思想:战略和战术
战略设计
通用语言(Ubiquitous Language)
开发人员习惯了使用技术术语,领域专家(领域专家在此泛指精通业务的专家,比如用户,客户等等)对技术术语毫不关心,于是造成了不可避免的沟通问题,一旦沟通出现问题,开发出来的软件便很难解决领域专家的真正痛点。通用语言是 DDD 思想的基石,它是开发人员和领域专家共同创建一套沟通语言,一套在团队中流行的,通用的沟通语言,团队的组员之间可使用通用语言进行无障碍交流。
通用语言往往可以直接应用于代码中,它可以直接被写成一个类或者一个类的方法。
//开发一个购物车时,与其使用技术术语:
Cart::create(): 创建一个购物车。
Cart::updateStatus():更新购物车状态。
Cart::remove():移除购物车。
//贴近业务的通用语言:
Cart::init(): 创建一个购物车。
Cart::addItemToCart():添加商品。
Cart::removeItemFromCart():移除商品。
Cart::empty():清空购物车。
//使用后者时,开发人员不用解释每一个类方法的意义,领域专家可以直接看懂每一个类方法的目的。开发人员甚至可以和领域专家坐在一起使用代码来打磨业务流程。
限界上下文(Bounded Context)
实现了通用语言自由以后,我们需要使用限界上下文来规定每一套通用语言的使用边界。限界上下文是语义和语境的边界,在其内的每一个元素都有自己特定的含义,也就是说每一个概念在一个限界上下文中都是独一无二,不可以出现一词多义的情况
比如在一个购物车的限界上下文中,我们可以用 User 一词来代表购买商品的客户。
在一个注册系统中,我们可以用 User 一词指的是带有用户名和密码的账号。虽然词汇一样,但是在不同的限界上下文中,它们的含义不同。
我们使用限界上下文和通用语言,对业务进行语言层面的拆分。限界上下文为领域中的每一个元素赋予清晰的概念。
子域(Subdomain)
如果说限界上下文是对业务进行语言层面拆分的话,那么子域便是对业务进行商业价值的拆分。每一个商业都有自己的关注点,即便是看起来一样的电商平台,淘宝是开放平台模式,京东是价值链整合模式,一个明显的区别是,淘宝使用第三方物流而京东自建物流体系。那么作为一个开发人员,为何要关心看起来似乎与自己无关的商业模式呢?恰恰相反,只有当我们了解一个商业的结构时,才能开发出一个主次分明的系统来支撑一个商业的飞速发展。子域便是这样一个帮助我们划分主次的工具。
有三种类型的子域:
- 核心域(Core Domain):这是系统中需要最大投资的领域,它代表着整个商业的核心竞争力。我们需要花大量资源以及资源来打磨核心域,这关乎一个企业的存亡。比如京东的自建物流系统。
- 支撑域(Supporting Domain):此领域并非一个企业的核心业务,但是核心域却离不开它,它可以采用外包定制方案实现。比如认证上下文,权限上下文。
- 通用域(Generic Domain):如果已有成熟的解决方案,通用域可以采购现成方案来,如果没有,也可以采用外包,在通用域上的投资应该是最小的。比如对于淘宝而言,物流便是其通用域。
限界上下文和子域的关系众说纷纭,有专家提倡1:1,也有专家提倡1:N。个人比较提倡 1:1。
上下文映射(Context Mapping)
在一个庞大的系统中,限界上下文之间必定存在一定的依赖关系。如何将一个上下文中的概念映射到另一个上下文中?我们使用上下文映射。以下是几种上下文映射的关系类型:
- 合作关系(Partnership)
- 共享内核(Shared Kernel)
- 客户方-供应方开发(Customer-Supplier Development)
- 遵奉者(Conformist)
- 防腐层(Anticorruption Layer)
- 开放主机服务(Open Host Service)
- 发布语言(Published Language)
- 另谋他路(SeparateWay)
- 大泥球(Big Ball of Mud)
战术设计
实体(Entity)
首先我们讲到的是,实体。
实体是领域中独立事物的模型,每个实体都拥有一个唯一的标识符,比如 ID, UUID,Username 等等。大多数情况下,实体是可变的,它的状态会随着时间的迁移改变,不过,一个实体不一定必须可变。
实体的最大的特征是它的个体性,唯一性。比如在一个简单的购物车上下文中,订单(Order) 便是一个实体,ID 是它的标识符,它的状态可以在提交(placed),确认(confirmed) 以及已退 (refunded) 之间变化。
值对象(Value Object)
值对象是领域中用来描述,量化或者测量实体的模型。和实体不同,值对象没有唯一的标识符,两个对等的值对象是可以替换的。值对象具有不变性(Immutability),一旦创建以后,一个值对象的属性就定型了,不可更改。
理解值对象的最直接的方法是,想象我们现实生活中的钞票,在日常生活中,甲的十块钱人民币和乙的十块钱人民币是可以对等交换的。在上文的购物车上下文中,金额(Money)便是一个值对象,金额由货币(currency)和数目(amount)
聚合(Aggregate)
聚合是什么?聚合是上下文中对业务领域更精细的划分,每一个聚合保证自己的业务一致性。
那么什么是业务不变性?业务不变性表示一个业务规则,该规则在业务领域中不可违背,必须保证其一致性。比如,在进行订单退款时,退款金额不可以超过已付金额。聚合的组成部分是实体和值对象,有时候也只有实体。为了保护聚合的业务一致性,每个聚合只可以通过某一个实体对其进行操作,该实体被称为聚合根。
领域事件(Domain Event)
领域事件是通过通用语言分析出来的事件,与常见的事务事件不同的是它与业务息息相关,所以它的命名往往夹带业务名词,而不应该与数据库挂钩。比如购物车增添商品,对应的领域事件应该是 ProductAddedToCart
, 而不是 CartUpdated
。
Tips
DDD 还提供了诸如应用服务(Application Service),领域服务(Domain Service) 等战术设计,DDD 还提出了文章开头就提过的事件溯源,六边形等架构模式,在此我们将不一一介绍。
DDD 的核心是从业务的角度为软件建立模型,其目的是打造更贴近业务的代码,能更直观的从代码理清业务流程。 然而实现 DDD 并非一日之举,它需要不断的实践,不断的打磨。