谈谈游戏服务器中RPC模块的设计

在过去的十多年中,我曾使用并设计过各种类型的RPC模块。尽管它们各有特色,但总免不了一些使用上的不便之处。

我的第一次RPC开发经历,可以追溯到十年前,那时我刚开始从事游戏开发,参与制作了一款TPS(第三人称射击)游戏。

当时的服务器架构采用分布式设计,并结合多语言开发。

整个分布式系统中,与玩家个人数据交互的进程被称为Info进程,相关的功能由Java实现。

Gateway和核心玩法逻辑则由C++编写。两者之间的通信主要依赖于RPC

RPC的具体使用方式已经有些模糊,但我依然记得它的基本形式,大致如下:

void CallRet(sockbuffer buf) 
{
    uint32 ret = buf.ReadInt32()
    uint64 uid = buf.ReadUint64()
    ...
}

sockbuffer buf;
buf.WriteInt32(args1)
buf.WriteInt8(args2)
rpc::Call(buf.Bytes(), CallRet)

这种早期的RPC设计可以说是非常简陋,甚至有些“原始”。

它的序列化方式完全依赖手工编码(我印象最深的是经常需要和Java程序那边反复确认参数的大小和顺序是否一致)。

此外,回调函数的设计也相当局限,甚至不支持闭包。

整个RPC消息机制是建立在普通的消息收发基础之上的。仅仅在消息协议中额外加了一个uint32 session字段。

每次调用rpc::Call时,系统会为该调用分配一个短期内不重复的session。分配的逻辑也很直接,只是用一个uint32 session_idx变量自增而已,确保在5秒内不会重复。

然后将session和对应的CallRet放到一个map中去,之后就让出了所有CPU

被调用方响应时,它会将对应的session字段一并返回。这样,调用方就能通过session找到对应的回调函数CallRet并执行。

为了处理超时情况,调用方还实现了一个简单的定时器,每秒检查是否有超时的session

如果发现某个session超时,就直接将其删除(这意味着超时的请求永远不会返回)。

虽然这种RPC的使用方式有些简陋,但对于刚入行的我而言,它的思路却让我醍醐灌顶。并在后面近十年里一直影响着我。


后来,我也简单接触过gRPC

但在实际使用中发现:它在发送请求协议后,会在当前的socket同步等待协议返回。

对于一个典型的“单线程多进程”游戏服务器而言,这种设计会显著降低并发处理能力。因此,我没有进一步深入研究gRPC

之后,我开始参与SLG游戏的开发。由于SLG的战斗逻辑通常是由服务器自动完成的,而这些战斗计算往往在高峰期(如攻城战)时会占用大量资源,容易导致其他操作(例如武将培养、抽卡等)出现阻塞。

为了解决这一问题,我决定将大地图的核心玩法逻辑从主进程中剥离,独立到单独的进程中运行。

既然采用多进程来拆分业务逻辑,自然需要引入RPC来简化进程间通信,提升开发效率。

于是,我结合之前的RPC经验,开始探索实现一个便捷、高效的RPC模块。

我借鉴了之前的RPC思路,最终设计出来的RPC接口如下:

uint32_t rpc_call(zprotobuf::wirep &req, void (*cb)(MsgHeader *m, uint32_t session));

在这次的RPC设计中,我修改了超时功能:如果消息超过5秒仍未返回,回调函数cb依然会被调用,但此时传递的参数m会被置为nullptr,以便调用方可以处理超时后的撤销逻辑或补偿措施。

在从零开始设计RPC模块的过程中,我遇到了一个以前被忽视的问题:如何有效区分双向通信中的请求与响应。

具体来说,如果AB两个进程只通过一条连接L通信,AL中读到的数据,到底是来自B的请求,还是对A先前请求的响应?

最终我选择了一个看似简单的方案:让AB之间各自建立一条独立的连接。

也就是说,由A主动建立的连接用于A Call B,而由B主动建立的连接用于B Call A

这个设计选择在后续引发了一个严重的问题:由于A和B的通信不再依赖同一条连接,因此消息的FIFO(First In First Out)性质被破坏了。

在某些特殊场景下,客户端默认我们提供的消息顺序与服务器上的事件发生顺序一致,而这种假设在当前设计中可能被打破。

这个问题非常隐蔽,尤其是在内网环境中。

等到问题真正暴露时,项目已进入功能开发的后期阶段,箭在弦上,只能尝试绕过这个限制来规避问题。

附带一提,这个大地图核心玩法的进程是用Lua编写的,其RPC模块通过将通信逻辑封装在coroutine中实现了同步式调用。虽然实现形式与C++略有不同,但其基本机制是一致的,因此不再赘述。


之后,我换了一份工作,开始使用Go语言开发游戏服务器。

当时公司的服务器框架仍然延续了“单线程多进程”的设计理念,不过进程的概念被goroutine所替代,主体业务逻辑依然运行在主goroutine上。

在这种架构下,分布式通信的实现方式也必须适配“单线程多进程”的运行模型,而gRPC的同步阻塞模式显然不适合这种设计。

因此,在开发跨服业务时,我们采用了纯粹的消息传递方式。

这种方式虽然简单,但也有明显的缺点:A进程向B进程发送消息后,还需要专门为B返回的响应编写一个单独的处理函数。

这种上下文的割裂会让代码变得不直观,特别是在需要两次甚至三次Call时,问题更加突出。

为了解决这个问题,我再次借鉴了之前session的设计思路,构建了一个适用于Go语言环境的RPC模块。最终的接口设计大致如下:

rpc.Call(req protobuf.Message, fn func(ack protobuf.Message))

这个RPC模块同样实现了超时逻辑:如果响应超过指定时间未返回,则会通过调用fn(nil)返回一个空值,以便调用方能够处理超时场景。

从表面上看,这个RPC与我过去的实现没有太大差别。

然而,由于跨服通信与游戏服之间的链接机制是固定的(两个进程之间始终只有一条链接),我不得不解决一个问题:如何在同一条链接上区分请求包和响应包。

实际上,即使链接机制允许修改,我也不太倾向于采用多链接方案。原因如前文所述,多链接容易导致消息顺序问题,特别是在对消息顺序有严格要求的场景下。

最初,我的想法是利用协议ID来区分请求和响应包。

因为请求包和响应包的协议ID一定是不同的,我只需要针对响应包的协议ID做标记(打桩),然后将这些消息流转到RPC模块进行处理即可。

大致实现方式如下:

router.register(xxx_cmd_id, rpc.dispatch)

通过这种设计,当链接上接收到xxx_cmd_id时,会自动调用rpc.dispatch,从而识别对应的session并触发相应的fn回调函数。

事实上,这种方式让我用了相当长一段时间,它运行稳定,表现良好。

然而,随着跨服业务的复杂度不断提高,打桩函数的维护成本也在逐渐增加。

每次新增或修改响应协议,都需要更新打桩逻辑,这让我不得不开始寻找新的请求与响应识别方案。

最终,我发现协议数据中有一个未使用的bit位。

于是,我决定将这个bit用于区分响应包和请求包:如果是响应包,就将这个bit置为1;当接收数据包时,先检查这个bit,如果为1,则直接调用rpc.dispatch处理响应,否则进入正常的消息处理流程。

这个简单的改动彻底终结了“打桩时代”。

总的来看,这版RPC的设计除了保留回调机制外,几乎解决了我在以往经历中遇到的所有痛点。(是的,从我用Lua开始,我就不喜欢回调式的RPC了,因为回调会传染)。


由于这次的RPC模块是在跨服机制的上层构建的,因此它不再,也无法直接关注底层的链接状态。

正因为如此,即便一个游戏服链接了几十个跨服进程,这个RPC模块的实例依然只有一个。

相比之前的设计,这次的思路完全不同。

在过去,我通常会为每个链接绑定一个独立的RPC对象,而这次“思维定式”的打破让我对基于session机制的RPC有了更为全局的认识。

我开始反思:RPC模块是否真的有必要关注链接?如果不再关心底层的链接状态,最终的设计会是什么样子?

我进行了几个月的探索与思考,最终找到了一种更抽象的设计思路。

在一个分布式的游戏服务器系统中,进程之间的操作必然会被隔离开,不可能直接操作同一份玩家或全服数据。

这一特性为我们提供了一个关键的设计契机:为每个进程分配一个唯一的标识。

假设这个标识称为workerid,我们可以基于此重新设计RPC接口,新的接口可能如下:

local ack = rpc.call(workerid, req)

当我们选择忽略链接的存在后,分布式系统中的服务发现与连接建立也可以独立成为一个独立的模块。

例如我最近的实验项目, 就使用了etcd来实现服务发现。

这种设计思路受到了一些边车模式(Sidecar Pattern)的启发,通过抽象链接管理,将其从业务逻辑中剥离出来。

从业务角度来看,我们无需过于关注链接的瞬时断开与重建,因为这些事件并不会对分布式系统的一致性产生任何实际帮助。

workerid与我们的业务密切相关,因为它决定了每个进程将处理分布式系统中的哪部分数据。

每个workerid代表一个特定的进程,它负责处理特定范围内的任务或数据。

因此,workerid不仅是进程的标识符,更是数据路由与处理的关键。

通过这种方式,我们能够将分布式系统的资源划分与调度与业务需求紧密结合,而链接则可以忽视。

发表评论

68 + = sixty nine