业务分析
SD项目的一个核心功能是语音直播游戏房间。用户在游戏房间内唱歌PK、语音聊天、评论、点赞等。简单的业务分析如下:
游戏模式:
- 轮唱。每个人都有机会唱歌。
- 抢唱。抢到麦克风才有机会唱歌。
房间状态:
- 新建
- 播放音乐中
- 等待答题
- 等待抢麦
- 等待抢麦答题
- 展示结果
- 结束
人在房间的操作, 受限于房间状态和用户角色:
- 加入
- 退出
- 解散
- 开始游戏
- 唱歌
- 抢麦克风
- 放弃作答
- 点赞
- 举报
- 评论
- …
性能要满足10w+房间同时游戏
技术角度的思考
状态的改变影响行为
房间状态的改变,影响房间(服务器端)的行为。例如,“播放音乐中”状态下发歌曲,“展示结果”状态下发用户的答题情况。 房间状态同样影响用户行为。例如,只有房间处于“等待抢麦”状态,用户才可以发起“抢麦”请求,其他状态下的抢麦请求直接就是非法的。
分离关注点:拆分房间状态,每个状态只处理自己的职责,状态内的行为是高内聚。
状态的自动转换
异常情况下,用户可能不作答,或者离开游戏,或者由于网络原因,不能正常上报答题结果。这时房间状态也必须要做异常处理,保证继续轮转到下一个状态。
高可用
上面的分析表明,房间业务本身是有状态的。并且用户可以是全时段使用app。服务器端设计必须要无状态,部署过程才不影响已经开始的房间。
客户端和服务器端的交互
服务器端对所有房间内的客户端进行广播或者单播通知,可以抽象为信令,作为客户端行为的触发事件(事件驱动)。 房间内交互频繁,适合使用长连接通道。
解决方案
使用状态模式
房间状态多,并且状态的改变影响对象行为,适合使用状态模式。

业务有限状态机FSM的抽象
- 状态state
- 状态转换的触发器trigger
- 抽象上下文context
- 进入状态的前置检查,permitIf
- 两个状态的轮转操作,封装在transition
- 事件驱动触发,生成context、fromState、toState、trigger,再由state machine进行处理即可
房间状态的切换行为封装在transition,实现高内聚。对外暴露统一的入口,调用方只需要知道当前状态state和触发的路径trigger即可。路径合法性检查、切换行为等细节,由状态机引擎处理。
采用状态模式的好处:
- 避免了大量if else 操作和状态检查
- 不同玩法抽象为不同状态机,互不干扰,扩展性好,回归测试简单
- 代码可读性高
关于状态模式,可以参考下列文章:
简化的房间状态机图:

状态的持久化
保存到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补偿方案:
- 服务器端提供聚合接口,返回当前房间的若干信息,客户端根据返回可以进行本地状态切换
- 客户端本地注册超时事件,一旦正常时间内没有收到服务器端的信令,主动拉取聚合接口,并进行补偿操作

高可用
服务器端无状态
房间状态持久化到mysql。加上事件驱动设计,房间状态轮转由外部事件(客户端请求、RocketMQ发送的延迟消息)触发,本身不会在服务器内存维护房间状态。因此服务器端是无状态的设计。更新部署房间服务不会对已经进行的游戏产生影响。
强依赖MQ
每个游戏中的房间都会注册一条MQ延迟消息,用于异常情况下触发状态轮转。因此该方案强依赖于MQ的稳定性。发生MQ故障会导致房间服务不可用。
为此考虑服务器端的补偿机制,计划在未来实施。引入分布式任务组件,对已经开始游戏的房间进行扫描,扫描频次比正常轮转要低(只是最坏情况下房间状态可以轮转,因此SLA可以更长),减少db资源消耗。
推拉结合的补偿
应对网络问题,客户端有补偿机制,主动请求服务器数据,实现推拉结合。