[转]基于CQRS的架构在答题PK小游戏中的实践案例
1. 1. 前言
领域驱动设计(Domain-Driven Design,下文简称 DDD)在微服务时代成为了风口话题,而在 DDD 领域,我们常常看到命令查询与职责分离(Command and Query Responsibility Segregation,下文简称 CQRS)架构的身影。CQRS 架构由于本身只是一个读写分离的思想,实现方式多种多样。比如数据存储不分离,仅仅只是代码层面读写分离,也是 CQRS 的体现;然后数据存储的读写分离,Command 端负责数据存储,Query 端负责数据查询,Query 端的数据通过 Command 端产生的 Event 来同步,这种也是 CQRS 架构的一种实现。本文介绍的是后者这种实现方式的 CQRS 架构在研发实时答题 PK 系统中的实践,此外,我们还会引入事件溯源(Event Sourcing)模式,本文认为结合事件溯源模式,可以更好发挥 CQRS 架构的价值。
2. 2. 实践场景
2018 年伊始,开始流行答题撒币这类产品,这些产品成为热门的流量入口。以下是市场上的几款答题 PK 产品:头脑王者、大众点评答题 PK、最强答题。
实时答题 PK 的玩法主要是:在某个赛区随机匹配对手,或者自邀好友开始一局比赛,每局五道题,在不用道具的情况下,答对一道题最多可得 200 分,答得越慢分越少,答错不得分。每道题答题时间上限为十秒,如果在第一秒答对,得 200 分,如果用了一秒(即还剩 9 秒),则得 180 分,剩 8 秒则得 160 分,以此类推,最后一道题得分双倍。最终,按双方总得分评判胜负。
PK 过程中,会产生很多双方的战况数据,如:每个回合的得分、连续答对数(游戏中常称的 combo)、一局比赛中最多的连续答对数、连胜数等。
3. 3. 系统建设
3-1. 3.1 通信方式
为了搭建实时答题 PK 系统,我们首先需要选型前后端的通信方式。
3-1-1. 3.1.1 http 轮询
http 协议是用在应用层的协议,它是基于 tcp 协议的,http 协议建立连接也必须要有三次握手才能发送信息。http 连接分为短连接,长连接,短连接是每次请求都要三次握手才能发送自己的信息,即每一个 request 对应一个 response。长连接是在一定的期限内保持 TCP 连接不断开。
客户端与服务器通信,必须要有客户端发起然后服务器返回结果。客户端是主动的,服务器是被动的。因此,这种方式主要的缺陷是:通信使用资源较高、信息流动的时效性较差。
3-1-2. 3.1.2 websocket 长连
websocket 是 HTML5 开始提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议。websocket 基于 TCP 传输协议,并复用 HTTP 的握手通道,实现了多路复用,是全双工通信。在 websocket 协议下客服端和浏览器可以同时向对方发送信息,即建立了 websocket 之后服务器不必在浏览器发送 request 请求之后才能发送信息到浏览器,这时的服务器也可以主动推送消息给客户端,提高了客户端信息的时效性。
此外,与 http 的长连接通信相比,websocket 不仅能降低服务器的压力,而且传递的信息当中也减少了冗余的协议信息,提高了通信资源利用率。因此,基于 websocket 的通信方式在信息交互较多的游戏场景中是比较好的选择。
但是,为了兼容不同移动设备环境,提升系统可用性,服务端一般需要支持 http 轮询和 websocket 长连两种前后端通信方式。系统通常优先使用 websocket 建立前后端的长连接,如遇前端无法支持 websocket 等情况建立失败,则使用 http 轮询通信。
3-2. 3.2 通信协议
确定支持 http 和 websocket 两种通信方式后,我们需要约定两种通信协议。
3-2-1. 3.2.1 http
使用 http 通信时,主要是约定服务端提供的 api。如下图所示,前后端使用 http 通信的步骤:
1)上报匹配请求;
2)轮询获取匹配结果;
3)得到匹配成功信息后,上报 ready 状态;
4)轮询获取 game 相关信息(主客态玩家信息等);
5)获取下一回合题目,如果没有题目了,进入步骤 7;
6)上报玩家该回合题目答案,并获取该回合结果。轮询直至该回合结束,进入步骤 5;
7)获取该局比赛赛果信息,比赛结束。
3-2-2. 3.2.2 websocket
如下图所示,基于 websocket 长连接通信时,前后台的通信步骤如下:
1)客户端上报玩家待配对请求;
2)服务端推送配对成功消息;
3)客户端回传 ready 信息,准备开始游戏;
4)服务端推送该局游戏相关信息,以及第一回合题目;
5)客户端上报玩家答案;
6)服务端推送该回合答案结果;
7)若该回合结束(双方玩家都已完成答题),服务端推送该回合结果,以及下一回合题目,进入步骤 5。如果所有回合已经结束,进入步骤 8;
8)服务端推送该局游戏结果,比赛结束。
4. 4 为何选择 CQRS 和 Event Sourcing
4-1. 4.1 CQRS VS CRUD
大多数情况下,我们可以把信息系统看成一个可以进行增、删、改、查的数据存储库,系统的所有功能都可以转化为在存储结构的对象模型上进行创建、查看、更新和删除(CRUD)。而当需求变复杂时,CRUD 模式就显示出了不足。我们可能想用特殊的方式查看记录:比如说把多条记录合并为一条;或把不同地方的记录整合为一条虚拟记录。而实际操作时限制重重,通常情况下我们只能进行特定数据的合并,甚至有时存储起来的信息跟我们提供的根本不一致。因此,读写分离的业务架构在解决读写模型不一致时是比较好的选择。
CQRS(Command Query Responsibility Segregation,即命令查询职责分离)是 Greg Young 最早提出来的,其本质上就是一种读写分离的机制,其架构图如下:
命令和查询分离使得我们可以更好地把握对象的细节,更好地理解哪些操作会改变系统的状态。从而使系统具有更好的扩展性,提高系统可用性。CQRS 作为一种架构思想,可以有多种实现方式:
- 最常见的 CQRS 架构是数据库的读写分离;
- 系统底层存储不分离,但是上层逻辑代码分离;
- 系统底层存储分离,Command 端采用 Event Sourcing 的技术,在 EventStore 中存储事件;Query 端存储对象的最新状态,用于提供查询支持。
CQRS 架构的适用场景:
- 应用的写模型和读模型差别比较大时;
- 需要对系统的查询性能和写入性能分开进行优化时,尤其是读 / 写比非常高的系统,分离读写是必须的;
- 系统需要同时满足高并发的写、高并发的读的时候;因为基于 CQRS 架构的系统可以在 Command 端做到最大化的写,在 Query 端容易提供可扩展的读模型。
- 在实践 DDD 时;因为 CQRS 架构可以让我们实现领域模型不受任何 ORM 框架带来的对象和数据库的阻抗失衡的影响。
4-2. 4.2 Event Sourcing
事件回溯(Event Sourcing, ES)的主要特点是不保存对象的最新状态,而是保存对象产生的所有事件,通过事件回溯得到对象最新的状态。通常我们是在每次对象参与完一个业务动作后,把对象的最新状态持久化到数据库中,也就是说我们的数据库中的数据是反映了对象当前最新的状态。
而事件溯源则相反,不是保存对象的最新状态,而是保存这个对象所经历的每个事件,所有的由对象产生的事件会按照时间先后顺序有序的存放在数据库中。当我们需要这个对象的最新状态时,只要先创建一个空的对象,然后把和该对象相关的所有事件按照发生的先后顺序从先到后全部重播一遍,这个过程就是事件回溯。
ES 中的一个事件就是表示一个事实,它是已经发生完成的事件。事实是不能被消失或修改的,因此,ES 中的事件本身是不可修改(更新或者删除)的,所有修改操作都需要新记录一个单独的事件。
但是,实践 ES 时在存储、查询方面有个很大的挑战—由于记录的是影响对象状态的所有相关事件,所以获得对象的最新状态需要重播事件,这个过程所需时间与事件的数量成正比,当数据量大了以后,获取对象最新状态的时间也会随之增长。此外,在很多业务逻辑操作中,进行“写”前一般会需要“读”来做校验,所以实践 ES 架构的系统中一般会缓存一份对象的最新状态,在启动时进行”预热”,读取对象所有的相关事件进行回溯。
因此,在读对象——也就是加载一个聚合根实体的最新状态时,就不会因为查询性能影响系统可用性。在“写”前需要“读”的场景,不可避免会遇到并发问题,因此回溯事件的操作必须是原子性的。在实践中,ES 经常与消息队列配合使用,来保证事件回溯快照的 ACID(原子性、一致性、隔离性、持久性)。
4-3. 4.3 适用性分析
CQRS 与 Event Sourcing 架构在实时答题 PK 系统中的适用性:
1. 读写模型
答题 PK 系统的读写模型差别较大:写模型主要处理单个玩家的答题数据,而读模型是要获取一局游戏中所有玩家的数据,并且需要过滤敏感数据(己方未完成答题时,不可查询正确答案)。答题 PK 系统的主要读写请求如下图所示,这些读请求都能接受异步返回,因此在业务上可以接受最终一致性。
并且,为了支持 http 和 websocket 两种通信协议,读写分离更具扩展性和可维护性。
2. 读写比
基于 http 轮询协议时,实时答题 PK 系统的读写比例是比较高的,因为一局五回合的游戏中,写最多 10 个事件,而客户端需要轮询相关游戏数据。
3. Query 端扩展性
实时答题 PK 系统在 Query 端需要有很好的可扩展性。游戏互动模式是千变万化的,Query 端需要扩展不同的数据查询能力,比如:连胜局数、连胜回合数、是否绝杀(最后一回合反超比分)、重播答题过程(邀请好友和自己的答题记录 PK)、玩家游戏数据统计等等。因此,分离读写,使用 Event Sourcing,我们的系统可以更方便提供易扩展的读模型。
5. 5. 架构实践
5-1. 5.1 领域模型
基于微服务架构和 DDD 设计,我们将后台领域分为了:PK 上下文、匹配上下文、NPC 上下文、玩家账户上下文和题库上下文。实时答题 PK 的后台系统的核心上下文是 PK 上下文,本文主要介绍 PK 上下文的设计与实践,下图描绘了 PK 上下文的建模:
在 PK 上下文中,我们通过 Game 这个聚合来管控 PK 流程,如上图所示,一个 Game 包括了多个(一般一局游戏有 5 个回合)回合(Round),一个回合的主要属性是题目(Question),而一个回合包含两个玩家回合(PlayRound)实体。
在 Game 领域中,玩家的 PK 记录作为 Game 的结果信息,是结算玩家资产的凭据和存根。
5-2. 5.1 Game 初始化——两阶段提交协议
为了提高 PK 配对实际成功率(存在许多玩家进入匹配流程后又退出的情况),提高游戏体验,我们在搭建答题 PK 1vs1 的配对协议时可以借鉴两阶段提交协议 (Two-phase Commit):两个玩家上报待匹配请求后,匹配上下文异步将两个玩家组成一对,此时,PK 领域服务端、两个玩家互相不知道彼此是否已准备好这场对战,为了保证这轮匹配的 ACID 特性,服务端需要将匹配过程分为准备阶段和提交阶段。
如下图所示:玩家 A 上报待匹配请求后,服务端基于匹配策略,给玩家 A 匹配了一个同赛区、相近段位的玩家 B。此时,服务端分别向玩家 A 和玩家 B 发送 prepare 通知,然后,等待玩家 A 和玩家 B 都确认该次匹配,并向服务端 commit 此次配对。双方玩家都确认后,该局游戏正式开始。如若,其中有玩家超时未确认此次 prepare(客户端在约定超时时间内未收到服务端下发的 game start 事件),则该次配对作废,客户端重新开始匹配流程,重新上报待匹配请求,直到配对成功。
当两边玩家确认配对后,服务端会初始化游戏数据(扣减入场费、存储 game 上下文),然后下发 game start 事件给玩家。此时,若有玩家退出或掉线,则由 PK 流程处理,比如由 NPC 托管下线玩家,以保证游戏流程体验。
整个配对过程,基于 websocket 时,就是 client 与 server 互相发送 message 来实现。基于 http 轮询方式时,client 需要主动去服务端拉取这些信息,并约定一定的轮询次数或者超时时间来重试或终结这个流程。
5-3. 5.2 Game 主流程——CQRS
Game 领域对象处理中,我们基于 CQRS 将领域服务的读写请求分离。以玩家答题过程为例,如下图所示,其主要流程是:
1)客户端上报玩家答案和上下文信息;
2)命令处理器解析分离该条请求信息为处理玩家答案命令,并将该条命令交由 game 聚合处理;
3)game 聚合将玩家答案转化为一条答题事件(主要包含 gameId,playerId,roundIndex,answerTime,score 等),将玩家当前回合的得分情况同步返回,并持久化该条事件;
4)一条有效 game 事件持久化后,一条 event added 的消息将发送至消息队列(为了防止共享数据的竞争,这里的消息是按着 gameId 一致性 hash 散列至消息总线里的相应分区内),由游戏视图处理器订阅者处理;
5)游戏视图处理器根据监听到的 event,回溯相应游戏的 event,并生成最新的游戏视图数据,包含游戏进程、各角色玩家的赛况(当前得分、combo 数、当前最长连对数等);
6)将生成的最新视图持久化至视图数据库,供查询处理器提供给 api 使用;
7)游戏视图处理器将最新的游戏进程信息发至消息队列,推送给基于 websocket 连接的客户端,或者其他领域服务处理(比如,一局比赛结束后,账号领域服务需要结算该局游戏各个玩家的资产);
8)基于 websocket 连接的客户端,服务端将主动推送游戏进程信息,包括当前回合赛况、下一回合题目、一局比赛赛果等数据;
9)基于 http 轮询通信的客户端,客户端通过轮询 api 查询相应游戏赛况信息。
5-4. 5.3 Game 视图——Event Sourcing
基于 Event Sourcing 存储的数据,若要支持高效查询,常见的解决方案是提前生成一个视图,该视图以适合所需结果集的格式将数据具体化。这些具体化视图(仅包含查询所需的数据)允许应用程序快速获取所需信息。 除了联接表格或合并数据实体外,具体化视图还可以包含计算列或数据项当前的值、对数据项合并值或执行转换的结果以及指定为查询的一部分的值。 甚至可以仅针对单个查询优化具体化视图。
答题 PK 中,前台需要展现的数据包括,但不限于:
1)当前回合得分
2)当前游戏总得分
3)当前回合 combo
4)对方游戏数据
5)是否绝杀(最后一回合反超比分)
6)该局最长连续答对题数
这些数据都可以从用户的答题事件表中通过事件回溯获取到,但是为了提高查询效率,我们通过订阅答题事件来生成相关数据视图快照,存储在 RDBMS、NOSQL 数据库中。
5-5. 5.4 其他
5-5-1. 5.4.1 最终一致性
使用了 CQRS 架构,由于读写之间会有延迟,就意味着系统的一致性模型为最终一致性 (Eventual Consistency)。而答题 PK 游戏中,在数据的一致性和可用性中,我们优先保证服务的可用性,数据保证最终一致性(延迟 TP999 控制在 200ms 内)是可以接受的。
为了保证最终一致性,我们需要做到:
A. MQ 保证消息不丢;
B. 任何一个消费者线程要保证自己完全处理完后才发送 ACK 给 MQ;
C. 每个消费者线程做到对任何消息处理的幂等性;
5-5-2. 5.4.2 Command 请求幂等
Command 请求发送后,由于各种原因,请求端未能收到正确响应,而被请求端已经正确执行了操作。如果这时重发请求,则会造成重复操作。CQRS/ES 架构下通过聚合根实体 ID、版本号、CommandId 三种标识来识别相同 Command,以幂等处理 Command 请求。在实践中,我们主要通过数据库的唯一键约束来作幂等校验的。
5-5-3. 5.4.3 并发处理
并发处理主要是涉及需要先读再写的业务场景,比如 PK 中相关视图数据(PK 选手的答题数据)的处理。为了预防并发事件的处理带来的数据一致性问题,CQRS/ES 中按事件的先来后到严格执行,聚合根的状态由单一线程原子操作进行改变。而能够支撑大规模流量的实时答题 PK 系统必然是处于分布式环境中的,因此,我们可以通过消息队列(比如 kafka)将同一根实体的相关事件生产至同一 partition,同一 partition 有序消费,以保证对同一个聚合根实体状态的有序处理。
6. 6 总结
脱离业务场景谈系统架构都是徒劳的,基于 CQRS/ES 构建的项目拥有一个更具可扩展性、可维护性和高性能的代码基础,但是这个收获并非来源于对任何技术的完美实践,而是需要对业务需求细节进行详细分析、深入思考。本文的案例就是一个比较适合实践 CQRS/ES 的场景。
Eric Evans. 领域驱动设计. 赵俐 盛海艳 刘霞等译. 人民邮电出版社,2016.Vinicius Feitosa Pacheco. Microservice Patterns and Best Practices: Explore patterns like CQRS and event sourcing to create scalable, maintainable, and testable microservices. Packt Publishing, 2018.
[resource]基于CQRS的架构在答题PK小游戏中的实践案例