在过去的十多年中,我曾使用并设计过各种类型的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
模块的过程中,我遇到了一个以前被忽视的问题:如何有效区分双向通信中的请求与响应。
具体来说,如果A
和B
两个进程只通过一条连接L
通信,A
从L
中读到的数据,到底是来自B
的请求,还是对A
先前请求的响应?
最终我选择了一个看似简单的方案:让A
和B
之间各自建立一条独立的连接。
也就是说,由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
不仅是进程的标识符,更是数据路由与处理的关键。
通过这种方式,我们能够将分布式系统的资源划分与调度与业务需求紧密结合,而链接则可以忽视。