聊聊GS引入MQ的一些实践

在目前这套项目架构诞生初期,基于当时的游戏类型和项目需求,架构做得相对简单,设计上尽可能通过goroutine而不是节点来并发,节点间用ETCD做服务发现,用gRPC做节点通信,在单向依赖,弱藕合的情况下,基本能够满足需求。对于个别强耦合的节点交互,使用gRPC Stream来建立双工连接。

随着游戏类型和业务需求的变更,跨服功能增多,节点划分越来越细,藕合越来越重,网络拓扑也越来越复杂。gRPC Stream不再能很好地胜任。

因此我们考虑用一套新的节点交互方案,大概有两个思路:

  1. 写一套完备的TCP网络库(包含服务发现,自动重连,编解码,心跳,流控等),用于统一节点间甚至Gateway与Client间的网络交互
  2. 使用MQ解耦集群内节点交互,将网状网络化为星形网络,简化网络拓扑

在对方案一进行几天的尝试后,我们最终放弃了TCP方案,主要有以下几个问题:

  1. 网络拓扑完全交给了应用层去维护,开发者需要谨慎规划和约束,避免形成全联通
  2. 由于不是全联通网络,A->B的消息可能需要经由一个甚至多个中间节点路由才能到达,并且这类路由信息只能逻辑层维护
  3. 某些业务场景下,节点路由和依赖可能是动态的,如跨服匹配战场,此时需要动态建立/销毁连接以维护动态路由

就前面几个问题来说,MQ是更好的解决方案,相比TCP,它有以下优势:

  1. 将 0-N 跳的路由网络的模型,统一为一跳,即通过中间件即可直达任意节点,在路由和全联通之间找到一个平衡点
  2. 发布订阅模型,为应用层提供了非常大的灵活度: 单向依赖/双向依赖,扇入/扇出,负载均衡,批量发布(主题匹配)等

消息中间件能够比较好地向应用层屏蔽节点路由的问题,但它并不能完全替代ETCD+gRPC,两套方案可以在不同的应用场景搭配使用。

在对几种主流消息中间件进行评估之后,我们目前选定nats,它的优点是基于golang编写,轻量级,高性能,低延迟,缺点是不支持消息持久化,即最多一次投递语义,nats-streaming基于nats增加了消息持久化,即最少一次投递语义,相应的也有更完备的流控机制。由于游戏服务器对消息时延敏感,并且大部分消息有状态和时效性,因此目前打算直接用nats,关键逻辑自己做消息QoS或容错机制(官方也推荐nats,由应用层而不是中间件去做QoS)。

从设计上而言,在nats之上封装应用层MQ API,不依赖nats特有功能(如Request-Reply),只使用消息中间件的通用语义(Publish/Subscribe),解耦组件以实现必要时透明替换,应用层对MQ API的使用主要分两种:

  1. 节点通信: 应用在藕合较强的逻辑节点间,在这里Topic类似于节点公开通信地址,MQ起到的作用类似TCP。这情况情形下,对MQ封装通用消息语义: Send / Request / Async-Request,前两种语义容易理解,即异步投递和同步请求,第三种是异步请求,通过回调的方式处理响应,并统一保存请求上下文,适用于异步RPC情景。更进一步,这一层消息语义封装不应该依赖于MQ封装,即可以透明将这层语义的底层实现由MQ换成TCP或其它传输层,同时应该向应用层屏蔽掉Topic,提供类似EndPoint或Peer的抽象概念。
  2. 发布订阅: 消息中间件的常规应用,但是也需要封装,一方面是为了解耦屏蔽nats,另一方面是构建应用层对订阅分发的优化,就我们目前的实践而言,建立了一个固定大小的mq worker pool,以Topic Hash为请求分配worker,每个worker内部做二级分发(优化同一个节点对同一个Topic的多次订阅),序列化/反序列化等,worker底层复用同一个nats client,对逻辑层提供足够易用的异步发布订阅接口。

关于MQ的进一步实践我们还在摸索,目前的体会是,消息中间件和网关一样,都是游戏服务器架构的基础设施,前者对内简化服务器节点网络拓扑,后者对外屏蔽服务器内部网络拓扑,共同提升服务器的可扩展性。