SD项目:基于状态机和MQ的语音游戏方案

SD项目的语音直播游戏房间,使用状态模式拆分房间状态和对应行为。服务器端是事件驱动和无状态的。切换房间状态,向RocketMQ注册一个超时检查的延迟消息,用于异常状态下触发房间轮转。客户端和服务器端在房间的交互建立在长连接通道和信令。客户端有pull机制,异常情况下主动从服务器拉取房间的聚合信息,切换本地状态。

业务分析

SD项目的一个核心功能是语音直播游戏房间。用户在游戏房间内唱歌PK、语音聊天、评论、点赞等。简单的业务分析如下:

游戏模式:

  • 轮唱。每个人都有机会唱歌。
  • 抢唱。抢到麦克风才有机会唱歌。

房间状态:

  • 新建
  • 播放音乐中
  • 等待答题
  • 等待抢麦
  • 等待抢麦答题
  • 展示结果
  • 结束

人在房间的操作, 受限于房间状态和用户角色:

  • 加入
  • 退出
  • 解散
  • 开始游戏
  • 唱歌
  • 抢麦克风
  • 放弃作答
  • 点赞
  • 举报
  • 评论

性能要满足10w+房间同时游戏

技术角度的思考

状态的改变影响行为

房间状态的改变,影响房间(服务器端)的行为。例如,“播放音乐中”状态下发歌曲,“展示结果”状态下发用户的答题情况。 房间状态同样影响用户行为。例如,只有房间处于“等待抢麦”状态,用户才可以发起“抢麦”请求,其他状态下的抢麦请求直接就是非法的。

分离关注点:拆分房间状态,每个状态只处理自己的职责,状态内的行为是高内聚。

状态的自动转换

异常情况下,用户可能不作答,或者离开游戏,或者由于网络原因,不能正常上报答题结果。这时房间状态也必须要做异常处理,保证继续轮转到下一个状态。

高可用

上面的分析表明,房间业务本身是有状态的。并且用户可以是全时段使用app。服务器端设计必须要无状态,部署过程才不影响已经开始的房间。

客户端和服务器端的交互

服务器端对所有房间内的客户端进行广播或者单播通知,可以抽象为信令,作为客户端行为的触发事件(事件驱动)。 房间内交互频繁,适合使用长连接通道。

解决方案

使用状态模式

房间状态多,并且状态的改变影响对象行为,适合使用状态模式。

statemachine.gif

业务有限状态机FSM的抽象

  • 状态state
  • 状态转换的触发器trigger
  • 抽象上下文context
  • 进入状态的前置检查,permitIf
  • 两个状态的轮转操作,封装在transition
  • 事件驱动触发,生成context、fromState、toState、trigger,再由state machine进行处理即可

房间状态的切换行为封装在transition,实现高内聚。对外暴露统一的入口,调用方只需要知道当前状态state和触发的路径trigger即可。路径合法性检查、切换行为等细节,由状态机引擎处理。

采用状态模式的好处:

  • 避免了大量if else 操作和状态检查
  • 不同玩法抽象为不同状态机,互不干扰,扩展性好,回归测试简单
  • 代码可读性高

关于状态模式,可以参考下列文章:

简化的房间状态机图:

SD简化的状态机图.jpg

状态的持久化

保存到mysql,外加redis作为缓存。

状态的自动转换

前面提到一个问题:没人抢麦,或者上报超时,没有客户端的事件触发,服务器的状态机怎么流转?

候选方案:

  • 注册本地scheduler线程,比如15s后检查某个房间的游戏状态是否已经超时。缺点:很显然重启jvm就丢失任务信息。
  • 定时任务job。每隔1s检查所有已经开始游戏的房间状态。缺点:效率太低。
  • 分布式定时任务。为每个房间手动注册检查job,以及触发时间。一旦房间销毁,则撤销该任务。

分布式定时任务看上去是可行的。但是考虑下来并没有采用,原因是:

  • 每个房间切换状态要手动注册新任务、注销旧定时任务,略显啰嗦。
  • 分布式定时任务框架底层通常使用mysql + zookeeper,10W+同时运行的房间,能否提供足够的实时性响应?极端情况下会增加产生10W+ tps的读写。
  • 以前经历过几次定时任务多了,分布式定时任务启动执行时间滞后的问题。房间服务对时间很敏感。

最终使用的是一套新方案:RocketMQ的延迟消息。把超时检查打包为一个RocketMQ延迟消息。到点再由RocketMQ向消费者投递。

用MQ延迟消息解决异常情况下状态轮转

服务器端是事件驱动编程模型。如果没有客户端发送的交互事件,房间状态轮转就会有问题。

以唱歌为例。正常情况下,用户唱完歌,或者用户放弃作答,客户端都会主动上报,使得状态机可以切换。但是,如果这条上报结果请求丢失,由于没有事件触发,状态机就无法轮转了。 解决的方法是,切换状态的时候,注册当前状态超时检查的延迟消息。 正常的用户行为,会使得房间的状态机顺利切换到下一个状态,因此在下一个状态收到上一个状态注册的延迟消息,就可以直接丢弃。 相反,异常情况下,由于没有收到用户行为,那么到达超时时间后,MQ服务器发送之前注册的超时消息,就可以触发房间业务对于该状态的处理。 还是以唱歌为例。进入唱歌状态,假设最大允许一个人唱歌60s,那么就注册一个60s以后的延迟消息。异常情况下,60s后消费者收到RocketMQ发来的延迟消息,触发状态机引擎轮转。

解决方案:

  • 在状态的transition阶段,注册延迟消息,比如1s,5s,30s
  • 收到延迟消息之后,根据context判断是丢弃,还是启动状态机引擎处理
  • 延迟消息的消费速度,可以增加消费者实例、消费者线程来提高

限制:

  • RocketMQ不支持任意精度的延迟,业务层自己做策略模块解决

RocketMQ延迟消息相关文章:

关于超时

因为mq消息可能堆积,导致延迟消息发送滞后。也有可能因为时钟问题,提前发送。因此在消息体增加expectExecuteAt字段,表明期望执行时间,服务器端的处理策略更加灵活。

事件驱动和pull补偿

在游戏房间内,客户端和服务器端的交互主要通过长连接通道上的交互事件实现。 由于网络的原因,可能会出现信令延迟甚至丢失。客户端如果纯粹依赖服务器端下发的信令,就有因为信令延迟或者丢失导致不能正常切换。 对于这种异常情况,要使用pull补偿方案:

  1. 服务器端提供聚合接口,返回当前房间的若干信息,客户端根据返回可以进行本地状态切换
  2. 客户端本地注册超时事件,一旦正常时间内没有收到服务器端的信令,主动拉取聚合接口,并进行补偿操作

事件驱动.png

高可用

服务器端无状态

房间状态持久化到mysql。加上事件驱动设计,房间状态轮转由外部事件(客户端请求、RocketMQ发送的延迟消息)触发,本身不会在服务器内存维护房间状态。因此服务器端是无状态的设计。更新部署房间服务不会对已经进行的游戏产生影响。

强依赖MQ

每个游戏中的房间都会注册一条MQ延迟消息,用于异常情况下触发状态轮转。因此该方案强依赖于MQ的稳定性。发生MQ故障会导致房间服务不可用。

为此考虑服务器端的补偿机制,计划在未来实施。引入分布式任务组件,对已经开始游戏的房间进行扫描,扫描频次比正常轮转要低(只是最坏情况下房间状态可以轮转,因此SLA可以更长),减少db资源消耗。

推拉结合的补偿

应对网络问题,客户端有补偿机制,主动请求服务器数据,实现推拉结合。

Built with Hugo
Theme Stack designed by Jimmy