游戏服务器中的通信模型

本文聊聊游戏服务器中常见的通信模型和对比,讨论下常见的实现方案,最后分享下我们当前的实践。

常见的交互语义

在实践中,节点交互中常用的点对点通信方式,对应用层而言,可以大概分为以下几种:

  • 同步RPC
  • 同步请求
  • 异步消息
  • 异步请求
  • 发布订阅

同步RPC

RPC库通常有现成的轮子,比如gRPC,这种写法应该是开发者最喜欢的方式,以golang gRPC为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// A 线程
func handle (x,y,z int) int {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := &AddReq{X: x, Y: y}
ack, err := grpcClient.Add(ctx, req)
return ack.Result * z
}

// B 线程
func (b *B) Add(ctx context.Context, req *AddReq) (*AddAck, error) {
return &AddAck{Result: req.X+req.Y}, nil
}

例子很简单,A线程需要B线程发起请求(这里是一个简单的加法),然后再继续自己的逻辑,代码简洁明了。而诸如超时,错误传递等,gRPC都已经处理好了。同步RPC的缺点在后面讨论同步异步以及具体RPC框架的时候会讨论。

异步消息

基于异步消息的通信模式可以说非常古老了,从 C/S 到服务器进程/线程间,这种方案的优点是扩展性强(消息的优点),吞吐量好(异步的优点)。不管是集群/进程/线程/轻量级线程,可以用同一套通信方案,并且消息语义本身也很容易在各种通信设施上实现,如go channel,TCP,MQ等。像 Erlang 的通信模型就只有一套异步消息这一种。这样最大的好处就是完全屏蔽了目标 Process 的物理位置(同一进程/跨进程/跨网络),获得非常好的系统扩展性和灵活性。比如某个 Process 被重新部署到其它节点上,已有代码几乎无需任何更改。

谈了这么多优点,下面我们来聊聊它的不足(主要是相较同步RPC而言),以前面的AddService例子为基础,现在我们尝试将其换成异步消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// A 线程
func handle(x,y,z int) {
// 需要将请求数据上下文随消息一并发送
// 由于是异步消息投递,低层通常是不提供超时机制的
Send2B(&AddReq{X:x,Y:y,Z:z})
}

// B 线程
func handleAddReq(req *AddReq) {
result := req.X + req.Y
// 需要显式指明响应路径,并且拷贝B根本不应该关心的请求数据上下文z
Send2A(&AddAck{Result:result,Z:req.z})
}

// A 线程
func handleAddAck(ack *AddAck) {
// 从ack中取出处理结果和当时的请求上下文
result = ack.result * ack.Z
}

以上暴露了异步消息交互的几个问题,在我的理解中,一个完整的异步请求(期待对端返回响应,而非单纯投递一条消息)需要考虑到如下四个部分:

  1. 消息路由和处理: 即AddReq AddAck消息如何序列化传输,如何映射到指定响应函数等,是消息交互系统的基础支撑
  2. 请求数据上下文: 指只有发送方需要用于后续处理,而接收方不关心的内容,即上例中的 z
  3. 消息响应路径上下文: 指请求完成处理后,如何指明响应路径。上例中,对响应方B,它在响应AddReq时,需要显式指明响应到A。
  4. 超时机制: 用于处理对端无响应或慢响应的情况,避免消息黑洞(消息QoS得不到保证)

相比同步RPC,单纯的异步消息框架,以上2,3,4都需要应用层关心和维护:

  • 请求数据上下文: 在上例中,请求方A需要将请求上下文与请求内容一起发送给服务方B(如AddReq{x,y,z}),然后B再原封不动返回回来,这种方案一方面导致代码复用性很差,比如当AddReq有多种上下文时(如AddReq(x,y)*z, AddReq(x,y)-a-b),则很难复用 AddReq和handleAddReq。当 B 是 DB 这类公用模块时,这类问题尤其突出。另一方面是带来了额外的消息负载开销(B根本不关心A的请求上下文)。最后一方面是这种方案很难实现异步超时(如果B没有响应A,那么A的超时处理中将获取不到当时的请求上下文)。
  • 消息响应路径上下文: 在上面的例子中,我们是在 handleAddReq 中直接调用 Send2A(&AddAck{Result:result}) 的,这意味B假设AddReq是来自于A的,并显式指明响应路径,那么当 C,D也会请求 B 时,就需要定义 AddReqForC, AddReqForD 请求,或者在AddReq中添加标识请求方来源的字段,让请求方来填。这种将响应路径(也是请求来源)绑定在消息内容上的做法,不利于代码复用和解耦。
  • 超时机制: 大部分的异步消息交互系统是不提供超时机制的,因为超时本身是请求-响应模式中的概念(对响应有预期),是更上层考虑的问题(如TCP和HTTP的关系)。但是在请求-响应语义中,超时又是必要的,比如错误处理,延迟统计,流控降级等

总之,纯异步消息框架对于请求-响应式的业务场景缺乏更上层的基础设施支撑,开发起来是不够友好的。

异步请求

那么有没有办法同时兼顾异步消息交互的扩展性以及请求-响应的便利性呢,异步请求就是两者的结合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// A 线程
func handle(x,y,z int) {
self.AsyncCb(targetB, &AddReq{A: x, B: y}, z, 3*time.Second, handleAddAck)
}

// AckCtx由异步回调框架提供,包含响应消息本身,请求错误(超时,网络故障等), 请求上下文等
func handleAddAck(ackctx *AckCtx) {
if ackctx.Err != nil {
// 处理超时,对端无响应等框架性错误
}
result := ackctx.(*AddAck).Result * ackCtx.Ctx.(int)
}

// B 线程
// ReqCtx由异步请求框架提供,包含请求消息本身,响应路径,消息唯一ID等
func handleAddReq(reqctx *ReqCtx) {
req := reqctx.Req.(*AddReq)
// 直接返回,无需关心请求上下文,响应路径等
reqctx.Reply(&AddAck{Result: req.A + req.B})
}

异步请求方案通过ReqCtx和AckCtx来维护请求-响应语义,通过回调来处理响应。对请求方而言,框架底层为其处理了超时和请求上下文管理,对服务方B而言,它也无需再关心请求上下文,响应路径。

异步请求语义通常是框架层提供,上例中的异步回调机制只是其中一种实现方案,简单聊聊它的一些特性:

  1. 请求语义: 设计非对称的请求-响应协议,并通过唯一请求ID进行请求、响应的对应
  2. 上下文管理: 请求方统一管理请求ID到请求上下文(包括请求数据上下文、回调函数、超时时间等)的映射
  3. 响应路径: 框架层对响应方屏蔽消息来源,响应方不再关注请求数据上下文和响应路径,专注处理请求并响应结果
  4. 回调机制: 请求方收到响应后,通过唯一ID找到请求上下文,回调到应用层(并移除请求上下文)
  5. 超时机制: 请求方通过ticker or timer来触发超时(并移除请求上下文)
  6. 错误机制: 将请求过程中的各种错误(如无效地址,序列化错误,网络错误,甚至对端panic)尽早地反馈给请求方

本质上来说,请求-响应和纯异步消息的最大区别在于前者是非对称的(请求走handler路由,响应走回调),而实际开发中为了更好的数据一致性和错误处理,很多场景的通信模型都是适合请求-响应式的(如扣除资源,检查条件等),因此站在实用主义的角度来说,架设一套交互语义是有必要的。

这里顺便提一下为什么我们将请求数据上下文单独管理,而不直接使用函数闭包:

1
2
3
4
5
func handle(x,y,z int) {
self.AsyncCb(targetB, &AddReq{A: x, B: y}, func (ackctx *AckCtx) {
result := ackctx.Ack.(*AddAck).Result * z
})
}

这是因为当触发回调函数时,闭包所引用的上下文可能已经失效了,比如z可能是玩家的某个部队的引用,但是异步回调时,玩家的该部队已经解散了,但回调却还在使用,造成写丢失的问题(还很难Debug)。因此我们在实践中限制了异步回调必须分段写,请求数据上下文必须是简单值(如玩家ID,部队ID等),在分段的回调函数中,去通过ID重新获取相关数据。

同步请求

同步请求指基于消息的同步阻塞的请求-响应语义,HTTP协议就是一个典型的基于文本消息的同步请求协议,只不过基于性能和消息顺序性的考量,游戏服务器能直接使用HTTP的场景有限。如果要自己实现同步请求语义的话,可以基于异步回调机制封装,形式上,可以是同步回调,也可以是类似RPC的返回值, 同步请求不需要考虑上下文和回调的维护,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 返回值
func handle (x,y,z int) {
ackctx := SyncRequest(&AddReq{X: x, Y: y}, 3*time.Second)
result := ackctx.Ack.(*AddAck).Result * z
}

// 同步回调
func handle(x,y,z int) {
self.SyncCb(targetB, &AddReq{A: x, B: y}, z, 3*time.Second), func (ackctx *AckCtx) {
if ackctx.Err != nil {
// 处理超时,对端无响应等框架性错误
}
result := ackctx.(*AddAck).Result * ackCtx.Ctx.(int)
})
}

对B而言,它的实现和异步回调一样,因为它不关心请求方是同步还是异步的,同步请求和同步RPC看起来比较类似,但由于其基于消息,有更强的扩展性和可移植性,很容易适配各种底层传输方式。

发布订阅

发布订阅本质是多对多的异步消息通信模式,逻辑层的事件通常就基于发布订阅来分发。但是节点内的事件分发和跨节点的事件分发可能还有些区别:

  • 节点内: 由于性能高,可以做到细粒度灵活控制,比如基于单个Event去注册,订阅方可能是任务,活动,排行榜等逻辑模块,这些模块可能是跨线程的
  • 节点间: 由于游戏中的Event太多太细,每个Event一个Topic是不现实的,否则单个活动开启,就可能导致瞬间订阅上百个Topic,因此跨节点的事件分发主要靠发布方来做粗过滤,然后发到订阅者的Topic上

因此由于游戏服务器对性能的要求,通常节点内会自己实现一套发布订阅组件,使用细粒度Event,而跨节点多对多的模式用得相对较少,Topic大部分时候被用作对端虚拟地址抽象,具体执行的还是点对点的链路。

同步 vs 异步

尽管同步代码简单直观且易于维护,但为了构建健壮,可靠的后端系统,对于同步的选择是应该慎之又慎的。为了方便讨论同步可能带来的问题,我将同步请求大概分为三类,然后讨论下这些情景下用同步可能导致的一些问题:

  1. 没有设置超时或不支持超时的同步请求
  2. 带超时但非关键的同步请求
  3. 带超时且关键的同步请求

不带超时的同步请求的问题很明显,就是请求方无法对对端的响应有一个最坏预期,即有可能对端挂掉了,永远也不响应,那么请求方就永远被阻塞了,最终导致服务无响应、资源泄露、甚至雪崩。很多时候我们在实现同步语义的时候会忽略超时处理,或者说对对端的可用性作出过高的假设。要在语言机制上实现同步语义是很简单的,比如Go的res := <-chanRet,Erlang的receive Answer -> ok,但没有考虑边界情况的后果也可能是很严重的。

再来看看带超时的非关键同步请求,非关键请求是指请求方不必等该请求完成之后,再处理接下来的任务。即如果这个请求是异步的,那么其实请求方也可以先处理接下来的消息。比如地图线程在发起一个DB请求加载某个玩家数据时,这个过程中地图线程其实可以先处理来自其它玩家的请求。在这种情况下,很明显的,同步请求相比异步,降低了地图线程的吞吐量。前段时间公司一个项目出现CPU Load上不去,而Erlang消息队列有堆积的情况,排查了很久,发现是日志库log4erl里面有同步调用,导致写日志的API其实是有阻塞的,然后基本所有的逻辑线程都会写大量日志,导致CPU不能跑满。

最后来看看带超时且关键的同步请求,举个例子,玩家登录的时候,玩家的agent线程会向平台去认证鉴权,即使这个鉴权过程是异步的,agent线程也不能处理接下来的任务,因为玩家还没有鉴权成功,它发来的后续消息是没有意义的,agent要么将接下来收到的消息丢掉,要么将其缓存下来,等鉴权完成再处理。在这种情况下,你可能会觉得用同步向平台鉴权总不会有什么问题了吧,既不会降低吞吐量,也不会有永久阻塞的问题,然而我们也有项目躺过坑,玩家多点登录时,新的agent会同步等老的agent走完下线流程之后,再处理后续登录逻辑(这个地方的同步调用没有设置超时),然而如果老的agent在阻塞处理某些请求(这个请求的超时可能比较长),并无法即时响应登出请求,那么新的agent也会阻塞,然后玩家觉得几秒钟没登录上,可能又会再次重启游戏重新登录,这个时候新建的agent仍然会继续阻塞,然后agent数量就会暴增,最终导致OOM。当然,这个事故的部分原因是没有正确设置超时,但也从另一方面揭露了关键同步请求相比异步的缺陷: 虽然异步请求过程中,agent也不能处理后续逻辑消息,但起码agent是可响应的,可响应意味着可以处理一些如终止消息,系统消息等高优先级的任务。前面定义的所谓关键二字,其实是对同等优先级的任务而言的,而往往在实践中,总有意外或者更重要的事情发生。

另外,关于同步请求的一个周知问题就是环形依赖,即A同步请求B,B又直接或间接同步请求A,同步意味着强依赖,随着逻辑复杂度的提升,理想的单向依赖会很难保证和检查,一旦出现环形依赖,轻则请求失败,伴随系统吞吐量降低(有超时的情况),重则环中的线程全部无响应(没做超时的情况)。因此对于以上的三类同步请求,环形依赖都不会带来好结果。

讲了这么多同步的缺点,不是说完全不用同步,而是说慎用同步,要理解同步可能带来的边界问题是什么,比如服务器启停服流程,各种数据模块的加载/保存可能会有顺序依赖,如果做成异步,可能需要维护非常复杂的状态机,并且代码维护成本也比较高,而同步则可以获得清晰的执行流程和错误处理。

相比同步而言,异步看起来健壮性更好,但是也更复杂,其中一个典型的问题就是数据一致性问题: 比如前面讲的agent在异步执行鉴权操作,通常就需要做状态机保护,确保在鉴权完成前,agent不会开始处理后续逻辑消息。又比如A的异步请求还没有响应,后续处理的请求跟前一个异步请求有数据相关性,就可能导致数据不一致。我在游戏服务器中的数据一致性中也有一些讨论。

对异步请求-响应而言,超时也是一个需要考虑的问题,如果没有做超时,对同步而言的代价是可能永久阻塞,而对异步的代价则是”消息黑洞”,即请求方在发出异步请求之后,如果对端没有响应,那么这个请求就没有后续处理了,如果日志记录得不好的话,可能都很难追溯到这个请求。

总之,异步很多时候是让服务器不出现大问题(无响应/雪崩/系统吞吐量变低等),但同时也带来了开发复杂度,以及一些”小问题”(请求沉没/数据不一致/逻辑错误等)。

实现机制

前面基本讨论的都是交互语义,(同步,异步,RPC,回调),这里聊聊常见的节点交互实现方案,由于节点内的线程/协程通信机制通常和语言相关,这里主要关注跨节点交互方案。

HTTP

HTTP不用过多介绍,它实现的是同步请求语义,它的主要优势在于没有连接上下文,因此对无状态服务而言,可以透明横向扩展。通常第三方服务最常用的协议就是HTTP。但对游戏业务而言,HTTP的性能是一道过不去的坎,毕竟游戏服务器不像第三方服务那样能够做到无数据状态。

TCP

TCP应该是最常见也是最底层的通信方案了,它的主要优势就是高性能和灵活性,应用层可以根据需求自由实现编解码,路由,交互模式等。TCP的缺点主要有几点:

  1. 要造的轮子比较多: 数据编解码,加密,断线重连,流控,M对N的消息分发等等
  2. 网络拓扑的维护: 由于TCP是点对点的,在跨服交互的场景下,得谨慎维护网络拓扑,一方面尽可能避免全联通,另一方面复杂的拓扑依赖不利于维护和扩展

TCP适用于网络拓扑相对稳定的场景,对于比较灵活的网络拓扑结构(如动态匹配机制,动态发布订阅模型等),要么全相联,要么需要维护一套复杂的动态连接管理机制。

UDP/QUIC

在部分延迟容忍度特别低的游戏中,可能会使用UDP作为C/S协议,然后应用层实现一定程度的传输可靠性。对SLG而言,TCP性能和延迟还处于可接受访问内,而至于服务器集群内部,通常由于局域网环境相对稳定,并且消息顺序性敏感,因此基本都是直接用TCP。

放到这里提一下是因为近几年Google基于UDP封装的应用层可靠传输协议QUIC越来越火,各个大厂纷纷跟进,QUIC目前还不够成熟,主要还处于巨头摸索阶段,要推广还有不少问题(比如UDP可能被拦截或被限流),可以持续关注下,考虑未来借助QUIC来进一步提升游戏客户端的弱网延迟和断线重连体验。

RPC

如HTTP实现同步请求语义一样,RPC框架主要专注实现同步RPC语义,RPC框架通常基于HTTP或TCP,大部分RPC框架都是同步的,以最流行的gRPC框架为例,虽然 gRPC 支持异步,但还不够易用,也比较依赖应用层的封装,并且gRPC golang生成的代码不支持异步调用(犹如net包不提供异步API一样,主要依赖应用层开goroutine去封装异步),因此异步RPC这种通信模型实践中基本不会用到。另外,gRPC 的双向Stream通信在解决传统RPC中只能一次请求一个返回值,并且不保证顺序性的问题时,其实也实现了异步消息语义(如果传输的消息中包含二进制,逻辑层再自己做一层编解码的话),但在实践中建议酌情使用,因为这本质是将gRPC和HTTP/2当做TCP来用,上层的消息编解码,消息路由都得自己做,底层仍然可能有全联通等问题,性能和扩展性还不如TCP好。

RPC框架的通用缺点在于耦合过重(当然,这也是它足够易用的原因),一方面是同步调用,另一方面是函数调用本身,已经耦合了消息编解码,消息路由,方法名,甚至并发处理模型(golang gRPC中,每个请求会开一个goroutine)等,这导致其在灵活性和扩展性上会相对弱一些(比如想要基于其实现其他语义,或者适配到其他已有交互语义)。

实践中,RPC框架在游戏后端中的应用场景比较有限,通常用于对接支付这类相对独立的外部服务。

MQ

大部分的MQ底层都基于TCP来传输,对应用层提供异步消息,作为中间件单独存在,MQ的优势很多,比如异步,解耦,削峰,M对N的消息分发,Topic本身的灵活性等等,这里聊聊它应用在游戏服务器的利弊:

MQ的优势:

  1. 将网状拓扑简化为星形拓扑,避免了全联通,同时也能灵活适应各种动态的拓扑调整(topic)
  2. 削峰对于游戏中某些峰值场景来说,也比较有用
  3. Driver层通常有一些现成的轮子可用,如流控,加解密,编解码等

MQ的不足:

  1. MQ通常只提供异步消息语义和发布订阅语义,需要封装其他语义,虽然如nats也提供同步请求语义(request-reply),但经验上不建议过度依赖中间件专有特性,尽量做到组件可替换
  2. MQ是弱节点状态耦合,强状态耦合场景需要自己实现,如服务发现,对端状态感知,配置共享等
  3. 需要MQ提供自己的QoS保证(最少一次/最多一次/精确一次),但出于性能和可靠性的考量,在实践中,也不建议过度依赖MQ Qos,而是应用层来实现QoS

MQ适用于网络拓扑比较灵活的场景,比如对SLG这类后期主要依赖跨服来支撑生态的游戏而言,MQ能充分发挥它的解耦和灵活性优势,我在这里也提到了一些MQ相关的实践。

实践

最后简单聊聊我们当下的一些实践,首先说明下我们目前的主要游戏类型是SLG,主要的挑战在于性能(地图战斗,行军均由服务器跑桢)和各种跨服交互(跨服联盟,跨服活动,GvG,KvK等)。技术选型的优劣,只有落实到具体业务挑战上才有标准。

语义层面,我们目前的主逻辑通信模型基本都是基于消息的,主要使用异步消息,同步请求,异步请求三种交互语义,并尽可能让节点内交互和节点间交互使用同一套API(屏蔽底层实现和差异)。

实现层面,我们目前主要使用MQ(nats)来做节点间的通信,以适应游戏服务器后期易变的网络拓扑。虽然MQ天然提供发布订阅,但我们尽量将其屏蔽到底层,将发布语义封装为异步消息语义,将订阅语义封装为注册语义,对应用层而言,主要还是点对点的通信,然后基于这之上再封装同步请求和异步请求语义,并且在driver层做好请求-响应的延迟统计。对于已有的第三方同步接口(如平台HTTP,DB API等)均由单独的proxy代理(内部适当使用worker pool,负载分配等),对逻辑线程提供异步请求接口。对于一些强状态场景,如网关拉取可用服务器列表,则借助ETCD这类组件来做配置共享。

开发层面,除了服务器启停流程之外,逻辑线程全异步交互,异步请求的一些实践则需要团队通过规范约束和CodeReview不断加强,如可变的闭包上下文,数据一致性,超时设置,错误日志规范等。