麻将游戏后端架构里的多并发模型

写在前面

受到政策因素影响,经历了近三个月封闭开发的 汇闲麻将 最终还是没能成功上线。当前的感悟,创业的路上有很多槛,技术研发只是其中的一个槛。

这里仅以一名程序员的角色总结一下 汇闲麻将 的后端架构,也算是给过去三个月的自己一个交代。

汇闲麻将的后端架构

信息收集

就我个人的方法论,拿到一个问题后,① 首先要做的是尽可能多地收集情报,② 然后分析情报,有必要的情况下还要进一步收集情报,③ 接着才是制定方案,④ 最后实施方案。

三个要求

在后端架构初期,负责产品的同事就给后端的架构提了三个老生常谈的要求:

  • 支持高并发(单台服务器至少大几百并发)
  • 高可扩展性(方便产品迭代添加新特性)
  • 支持平行扩容(多台服务器同时提供服务)

汇闲麻将后台中的核心对象

  • 玩家 :每一个进到游戏中的用户都是玩家,当然也包含陪真人玩家打牌的机器人玩家;
  • 牌桌 :每 4 个玩家加入到一个牌桌进行游戏;
  • 大厅 :每个玩家进入游戏后,会首先到达大厅,参与转盘、签到、每日任务等功能;
  • 其他 :其他一些小的对象,比如麻将、色子等,这些就都比较容易处理了。

需要考虑的几个关键问题

  1. 同一个玩家的不同操作需要是保序的,比如用户登录后才可以进行入牌桌的动作;
  2. 同一个牌桌上进行的操作也需要是保序的,比如入桌顺序、出牌顺序等;
  3. 需要控制协程(goroutine)的数量,避免恶性增长资源耗尽;
  4. 需要监控牌桌信息,采样牌桌状态从而便于查错;
  5. 玩家的断线重连,游戏状态的恢复;
  6. 机器人玩家的开发;
  7. 其他。

上面的几个问题并未涉及到麻将游戏的具体逻辑(比如算输赢、算番数等),架构做好后可以填充这些逻辑。

历史经验

  1. 为了避免并发问题,传统的麻将游戏后端,每个房间一个进程;例如菜鸟场、富豪场,每个场都是一个房间,房间里包含许多的牌桌,这些牌桌上的逻辑由一个进程轮转处理。
  2. 每个房间启动一个进程,并暴露对应的端口供客户端连接。
  3. 用户进入游戏的的逻辑步骤是这样的:① 用户首先登陆到登陆服务器进行登陆鉴权;② 用户拿着鉴权得到的秘钥连接到大厅服务器,进行转盘、签到、任务、选择房间入桌等操作;③ 用户选择房间后,连接具体的房间服务器(游戏服务器)进行游戏;④ 用户进行完游戏后,与游戏服务器断开,重新与大厅服务器建立连接,回到步骤②。

信息分析与结论

由于我的技术栈是 Golang,因此选定了 Golang 作为汇闲麻将的后端开发语言,分析问题的时候自然就带入了 Golang 的语言特性。

  1. 受开发资源(时间和人力)的限制,不拆分登录、大厅、游戏等模块,在一个代码库中进行开发,方便把控研发节奏,降低前期的运维难度;
  2. 同样的道理,游戏服暂不按照房间进行进程上的划分,所有的房间都在一个主进程下面(启用 Golang 的多线程特性),对房间里的牌桌进行动态调整(如果某个房间里的牌桌不够用,而其他房间里闲置的牌桌比较多,就临时“借一个”使用)。
  3. 每个牌桌挂一个 goroutine 处理牌桌上的信息(牌桌状态轮转、用户出牌吃牌等);
  4. 大厅的交互频次较低,只需挂一个 gotoutine 处理所有用户的相关动作;
  5. 玩家断线重连时,通过替代底层的 session 进行恢复;
  6. 数据入库时由专门的 goroutine 池负责写入,从而避免对游戏逻辑的阻塞(为此还专门写了开源项目 gochan );
  7. 为方便分析各个组件的状态,统一打印日志,并把日志收集到 ELK 中进行分析(为此专门写了开源项目 sugar );
  8. 其他。

汇闲麻将后端架构里的并发模型

在架构设计初期,我曾经尝试通过 的方式维持玩家、桌子等的信息一致性,后来编写代码的时候发现逻辑非常的啰嗦,很多操作都需要考虑到加锁与解锁,当业务逻辑稍微变得复杂后难以维护,还很容易出现死锁的情况。那几天正好看到一位同事在玩《异星工厂》,受里面的传送带的启发,构思出了最初的“游戏后端线程架构图”原型,如上图所示。

“不要通过共享内存来通信,要通过通信来共享内存”,这句话是 Go 社区中非常经典的一句话。上面的架构图的设计一脉相承了“通过通信维护对象状态”的思路。每个协程(goroutine)搭配一个传送带(buffer-channel),此协程只处理自己传送带上的逻辑(闭包)。 上图中每个圆圈都是一个协程,圆圈的周围则是配套的传送带,外界(其他协程)可以把逻辑封装放置在传送带上,然后被当前协程顺序进行处理。

具体的:① 每个用户与游戏服的长连接上面挂两个协程(goroutine),其中读协程(read)负责读取客户端传送过来的数据,写协程(write)负责写服务端返回的数据给客户端。② 读协程对用户数据进行拆包后,把请求打包成为任务放置到主协程(main)的传送带上(信道),主协程依次处理自己传送带上的任务,进行简单的逻辑处理后分发给相应的牌桌(table)(把逻辑打包成为任务放置到牌桌的传送带);③ 各个牌桌的协程依次处理自己传送带上的任务,并把响应的发送任务给写协程(write);④ 写协程负责统一把数据返回给用户;⑤ 对于不同房间(room)中桌子的分配、借还等,由一个总的房间协程统筹进行管理。

有了上面的并发模型图,模块的划分就变得有依据也更合理,差不多花了两个月的时间,汇闲麻将就部署到预发布环境进行测试了。最后因为政策限制没有能正式发布,还是非常可惜的。。。

小结

“不要通过共享内存来通信,要通过通信来共享内存”;在设计并开发完汇闲麻将的后端业务逻辑后,感觉对这句话的理解更透彻了。当然,这里并不是强调锁没有使用价值,其实在一些场合下使用锁会更合理,就像《 浅谈 Golang 中数据的并发同步问题(三) 》中所描述的那样。

参考