惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LangChain Blog
N
News | PayPal Newsroom
WordPress大学
WordPress大学
T
Tailwind CSS Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Last Week in AI
Last Week in AI
博客园 - Franky
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
H
Hacker News: Front Page
有赞技术团队
有赞技术团队
Scott Helme
Scott Helme
博客园_首页
Hugging Face - Blog
Hugging Face - Blog
博客园 - 司徒正美
V
Visual Studio Blog
S
Securelist
Jina AI
Jina AI
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
腾讯CDC
P
Palo Alto Networks Blog
K
Kaspersky official blog
IT之家
IT之家
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
大猫的无限游戏
大猫的无限游戏
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Security Latest
Security Latest
Webroot Blog
Webroot Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
V
Vulnerabilities – Threatpost
W
WeLiveSecurity
T
Threat Research - Cisco Blogs
博客园 - 【当耐特】
Cisco Talos Blog
Cisco Talos Blog
P
Proofpoint News Feed
Cyberwarzone
Cyberwarzone
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
月光博客
月光博客
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
酷 壳 – CoolShell
酷 壳 – CoolShell
Google DeepMind News
Google DeepMind News
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
L
LINUX DO - 最新话题
Latest news
Latest news
T
Tenable Blog
The Register - Security
The Register - Security
Recorded Future
Recorded Future
博客园 - 叶小钗
人人都是产品经理
人人都是产品经理
爱范儿
爱范儿

重归混沌的BLOG

给silly实现了一个ernro模块 | 重归混沌的BLOG 给silly实现了一个ernro模块 | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 如何基于LanguageServerProtocol来编写lint工具 谈谈游戏服务器代码抽象 最近碰到的一个分布式一致性问题 谈谈游戏服务器的自动化测试 对Raft协议的一点理解 使用mmap来学习/proc/pid/smaps 2023(完) 再次实现了一个Lua性能分析器 终于给Silly的定时器增加了取消功能 一次虚拟内存排查经历 游戏服务器分布式数据的一种同步的思路 为silly增加了互斥锁 2022(完) Go语言之闭包篇 一例误用unsafe包引起的内存问题 Go语言之内存篇 初识Go语言 重新抽象图形API 给Lua实现了一个数学库 谈谈跨平台图形API的抽象 寻路和Flocking算法的结合 行为树的一种高效实现 内测过程中Shader出现的问题 彻底解决多国语言 谈谈数据库的选型 再谈Lua热更新(终) 初窥Rust 关于游戏服务器的服务拆分 ECS的初步实现 ECS初探 屏幕空间(SreenSpace)的想象力 一些对辐射度量学的理解 深度缓冲和半透明渲染 Mysql的间隙锁 更新一些GPU相关知识 2020 地形渲染之爬过的坑 Lua5.3 GC源码阅读(5) 实现一个数据库存储队列 再学计算机图形学入门 再谈分布式服务架构 游戏上线一个月后的反思 一次并发Bug 双向链表的三种实现 谈谈随机数的使用 再谈性能优化 2019 Lua中的函数式编程 重构登录逻辑 Unity资源管理(续) 谈谈Unity的资源管理 一次关于Cache的性能分析 历史之2018 DC3算法 移动平台native代码遭遇的坑 从CPU层面谈谈优化 开卷有益(UNIX编程艺术篇) GC竞争问题 通过Mesh投影来实现贴花系统 谈谈我对数据同步的理解 又一个类型提升引起的Bug Lua5.3 GC源码阅读(4) Lua5.3 GC源码阅读(3) Lua5.3 GC源码阅读(2) Lua5.3 GC源码阅读(1) 三角形光栅化时遇到的坑 一次git事故 再见2017 又一个lua调试器 客户端缓存落地方案 Paxos算法 HTTP服务器的特点 一次性能优化经历 关于CPU分支预测 C程序中让两个不同版本的库共存 实现了一个AOI模块 一个高可伸缩的游戏服务器架构 关于网络协议封装的一些新想法
谈谈游戏服务器中RPC模块的设计
重归混沌 · 2024-11-29 · via 重归混沌的BLOG

在过去的十多年中,我曾使用并设计过各种类型的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不仅是进程的标识符,更是数据路由与处理的关键。

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