《MMO 服务器开发》序:为什么还要从零写一个 MMO 服务端

《MMO 服务器开发》卷一序言:在框架随手可得的今天,为什么还要从零写一个 MMO 服务端。文章用三个真实场景说明「自己写一遍」换来的判断力,讲清本套书为什么选 Go 主线加 Java 对照、这一卷写什么不写什么、它和站内既有游戏服务端专题的分工,并给读者一段开始前的鼓励。

一个理所当然的疑问

打开任何一个游戏服务端框架的仓库,你都能在十分钟内跑起一个能连上、能收发消息的示例。skynet 有现成的 Lua 骨架,pitaya 有 Go 的分布式样板,Pomelo 有 Node 的全套方案,公司里往往还有一套用了五年的自研框架。既然如此,为什么还要从零写一个?

这个问题问得很好,值得认真回答,而不是用「学原理很重要」这种话糊弄过去。这篇序会用几个具体场景说明:从零写一遍到底换来了什么,为什么值得,以及它不换来什么。

会用一套框架,和懂一套框架,是两回事

先看三个场景。它们都不是「理论上可能发生」,而是几乎所有做过 MMO 服务端的人都会撞上的墙。

场景一:三千人在线,延迟从 40ms 涨到 400ms

你的服务端平时跑得好好的,压测到三千人开始出现延迟飙升,玩家开始反馈「走路卡」。你去查资料,有人说「网关要加机器」,有人说「逻辑要拆出去」,有人说「把广播改成 AOI」。你都试了,有的有效有的没效,但你并不知道为什么——你不知道这几千条连接的消息在 Go 的调度器里是怎么排队的,不知道一次广播到底分配了多少次内存,不知道延迟是卡在网络收包、卡在业务处理,还是卡在 GC。

这不是你的错。框架把这些都藏起来了,藏得很好——好到你不需要知道也能上线。直到它撑不住为止。

写过一遍内核的人,在这时会做一件不同的事:他会先问「这条连接的读 goroutine 有没有被阻塞」,因为这决定了收包是不是全卡在一处;他会看「一次广播是复制一份还是引用一份」,因为这决定了每秒钟有多少内存等着被回收;他会量「单个 Tick 处理完所有消息要多久」,因为这决定了世界时钟还能不能稳住。同样是「延迟飙了」,一个在猜,一个在量。

场景二:玩家重复点击,同一份奖励发了两次

第二个场景更隐蔽。你的客户端做了防重复点击,但网络抖动让同一个「领取奖励」请求到了两次。测试时一切正常,上线后却偶尔有玩家背包里多出一份奖励。

程序没报错。数据库没违反约束。它只是安静地把同一件事做了两遍。这类 bug 在单机时代几乎不存在,在网络服务里却天天发生——因为「至少一次投递」是分布式系统的默认语义,而「恰好一次」需要你自己去构造。

写过一遍的人会知道:幂等不是加个 if 判断就完事,它需要一个有界的去重窗口、一个能识别「同一个业务请求」的指纹、以及一套在窗口滑出之后仍然安全的兜底逻辑。这些设计只有自己写过一遍、被重复发奖坑过一次,才会真的长在手上。

场景三:外挂玩家瞬移,你的服务端照单全收

第三个场景最伤产品。某个玩家的客户端被改了,移动包里的坐标可以一秒跨过半个地图。你的服务端收到就广播——因为它默认相信客户端说的话。

这类问题的根源不在「有没有做校验」,而在「有没有想清楚权威边界在哪里」。一个玩家移动时,哪些数据是客户端可以提议的、哪些必须服务端裁决?位置是客户端说的还是服务端算的?如果服务端算,它拿什么算——上一次的位置、速度上限、还是地图的碰撞信息?这些问题没有标准答案,但每一个都需要你在写代码时做出决定。

框架不会替你决定这些。 它给你 onMessage 回调,剩下的是你自己的事。

三个场景的共同点

把这三个场景放在一起看,会发现它们指向同一件事:出问题的都不是业务逻辑,而是业务逻辑脚下的那层地基。

延迟飙升,不是技能算错了,是消息在调度器里排队;重复发奖,不是概率表有问题,是「至少一次投递」这个语义没有被幂等层接住;瞬移外挂,不是战斗公式有洞,是权威边界划错了地方。

这层地基有个特点:它在一切正常的时候完全隐形,在出事的时候又完全致命。 你写业务代码时可以一整天不去想它,但一旦它塌了,你会发现自己连从哪里开始查都不知道——因为你从没看过它长什么样。

而它恰恰是最不容易从文档里学到的东西。框架文档会告诉你「怎么注册一个消息处理器」,不会告诉你「消息在进入处理器之前经过了几个 goroutine、几次内存分配、几个锁」。这些信息只存在于源码和你的实测数据里。

这就是「从零写一遍」的全部理由:把那层隐形的地基,变成你能看见、能测量、能推理的东西。

这一卷的三个约定

为了让「从零」不流于口号,本卷有三个贯穿始终的约定。你读的时候可以拿它们当尺子。

约定一:每一节都至少有一块能真跑的代码。 不是伪代码,不是「示意如下」,而是从 // file: 到 // verify: 都完整、在 Go 1.26 上 go test 能跑绿的真实文件。凡是不能跑的(比如需要公网环境、需要真机),正文会显式标注「本节需真实环境,命令行不可复现」,绝不含糊过去。

约定二:结论由数字支撑,不由形容词支撑。 本卷不会写「这样写性能更好」就结束,而会给出实测的延迟分布、吞吐量或内存增量。「更好」必须能被量化,量化之后还必须承认它的代价——比如时间轮把定时器挂载降到 O(1),代价是精度退化到一个 tick。

约定三:不回避取舍。 每一个设计决定都至少有一个反面。长度前缀让拆包变简单,代价是多 4 个字节;服务器权威让外挂变难,代价是玩家要忍受延迟补偿;状态同步让服务端说了算,代价是带宽。本卷会把取舍摆出来,而不是只讲好处。

约定四:每个结论都标注适用边界。 「时间轮适合海量短周期定时器」这句话是有前提的——定时器数量大、精度要求不高。如果你只有几百个定时器,用最小堆反而更简单。本卷在给出任何结论时,都会尽量说清它成立的条件,避免你把一个局部最优当成普遍真理。

一条消息的一生

为了让「从零」变得具体,先看一条消息在你的服务端里要走过多少道关。假设玩家在《晨曦大陆》里点了一下地面,客户端发出一个「移动请求」。

第一步,网卡到内核。 字节流到达内核的接收缓冲。此时它还只是一串没有边界的字节——TCP 是流,不保证你一次 read 就能读到一条完整消息,也不保证两条消息不会被粘在一起。这就是粘包与拆包要解决的问题,第 3.2 节整节都在处理它。

第二步,内核到应用。 Go 的 netpoller 告诉运行时「这条连接可读了」,调度器唤醒那条连接对应的读 goroutine。读 goroutine 从缓冲里读字节,按长度前缀切出一个完整的帧。这一步是第 2 章与第 3 章的交界:I/O 模型决定谁被唤醒,协议格式决定怎么切。

第三步,解码。 切出来的帧是一个 []byte,最前面是固定头(长度、命令号、序号与标志位),后面才是负载。解码器把负载还原成「玩家 ID、目标坐标」这样的结构化数据。这一步在第 4 章,关键是不要多分配——每多一次拷贝,每秒几十万条消息时就是几十万次 GC 压力。

第四步,校验。 解码出来的坐标不能直接信。服务端要检查:这个玩家上一次在哪、过了多久、按他的移动速度上限能走多远。如果目标点在墙里、或者一秒跨了半个地图,这条消息就该被拒绝。这一步在第 15 章,它是反外挂的第一道也是最重要的一道闸门。

第五步,投递到场景。 通过校验的移动请求被投递到玩家所在的场景实体。实体不是被别的 goroutine 直接调用的,而是通过邮箱接收消息——这样同一个实体的所有状态变更都串行发生,不需要为它加锁。这一步在第 10 章。

第六步,模拟推进。 场景按固定 Tick 推进(比如每秒 10 次),在这一次 Tick 里处理邮箱中所有待处理消息,算出玩家的新位置,更新 AOI 索引。这一步在第 9 章与第 12 章:Tick 是逻辑时间的单位,AOI 决定了谁需要知道这次移动。

第七步,生成广播。 玩家移动了,AOI 里所有「能看到他」的其他玩家都需要收到通知。这里要做兴趣过滤——只发给视野内的玩家,而不是全服广播。快照还是增量、全量还是差分,是第 12 章的核心取舍。

第八步,编码与回程。 广播消息被编码回字节流,经过各自的连接写出去。写操作可能阻塞(对端接收窗口满了),所以要有一个有界的发送队列和背压策略——队列满了是丢弃、是断开、还是降级,是第 10 章要回答的问题。

第九步,定时器。 与此同时,这个玩家的「移动后摇」「技能冷却」这类短周期定时器在被注册和触发。如果每条连接一个 time.Timer,十万玩家就是十万个堆节点;时间轮把它们压成 O(1) 挂载。这一步在第 9 章。

第十步,幂等兜底。 如果客户端因为超时重发了这条移动请求,服务端不能把它当两次移动处理。命令号 + 业务指纹的去重窗口负责把重复请求挡在外面。这一步在第 11 章。

十步走完,玩家才在屏幕上看到自己动了一下。 这条链路里任何一步做错,都会在某个规模上暴露成延迟、错位或数据异常。本卷的全部内容,就是把这十步一步一步写出来。

这就是「从零」的颗粒度。 不是「实现一个服务端」这么粗,而是「这条消息在第三步多拷贝了一次吗」「第五步的邮箱有没有上界」「第九步的定时器是 O(log n) 还是 O(1)」这么细。

关于 aegis 和《晨曦大陆》

本卷有一个贯穿始终的具体项目,避免所有例子都飘在半空。

服务端叫 aegis,是一个从空目录开始、一章一章长大的 Go 项目。第 1 章它还不存在,第 17 章它会变成一个能登录、能移动、能广播的最小服务端。每一章都往它身上加一个零件:第 3 章加协议,第 5 章加可靠 UDP,第 7 章加会话表,第 9 章加时间轮,第 12 章加同步模型。到卷末你能看到一条完整的「从零到一个能跑的骨架」的轨迹。

游戏叫《晨曦大陆》,是一个开放世界的多人在线小游戏:玩家在同屏里走动、互相看见、聊天、组队、进副本、打一场跨服战场。它存在的意义只是给 aegis 一个具体的用例——有移动就有 AOI,有战斗就有延迟补偿,有组队就有跨服,有战场就有反外挂。它的范围被钉死在「多客户端能连上 → 同屏互相看见 → 少量玩法闭环 → 跨服能打一场」,产出截图、回放与压测数据就算验收。

说清楚:《晨曦大陆》不是一个能发行产品,aegis 也不是一个能直接上线的服务端。 它们是教学脚手架。你学完之后应该把它们丢掉,用学到的判断力去做你自己项目里的技术选型。

怎么读这一卷

如果你是第一次接触游戏服务端,按顺序从头读。第 1 章先把「一个 MMO 服务端长什么样」讲清楚,第 2、3 章把最基础的两块(I/O 模型、协议)打好,后面的章节才有立足点。不要跳过第 3.2 节的粘包拆包——那是后面所有网络代码的基础。

如果你是有后端经验的开发者,第 2 章可以快速扫过(Reactor 与 goroutine 调度器你大概率已经熟),重点放在第 3 章往后。第 12–14 章的实时同步三章是 MMO 与普通后端差异最大的地方,值得慢读。

如果你是已经踩过坑的服务端从业者,可以直接跳到第 11 章(幂等)、第 15 章(权威边界)和第 16 章(协议防护)——这三章处理的是最常出事故的地方。第 17 章的装配与压测也建议动手做一遍,它的数字可以和你自己项目的数字对照。

不管走哪条路,都请把代码敲一遍。 读代码和写代码之间隔着一条鸿沟:读的时候你觉得「哦,原来是这样」,写的时候才会撞上「为什么这里编译不过」「为什么这个测试挂了」。本卷的每一块代码都标注了层级和验证命令,就是为了让你能真跑起来。

从零写一遍,换来的是什么

上面三个场景指向同一件事:从零写一遍,换来的不是「会写框架」,而是「能对系统做出判断」。

具体说,它换来三样东西。

第一,一套能解释现象的模型。 当延迟飙升时,你脑子里有一条从网卡到业务逻辑再到回包的完整链路,你能沿着它逐段排除,而不是在搜索引擎里试关键词。

第二,一种对成本的直觉。 你知道一条长连接要多少内存,一次广播要多少次分配,一个定时器挂上去要多少纳秒。这种直觉没法从文档里读出来,只能自己量出来。

第三,一套自己踩过的坑。 顶号时旧连接的断开事件把新会话误删了、时间轮回调里再注册定时器死锁了、长度前缀没设上限被一个畸形包打爆内存了——这些坑你必须踩过一次,才会在下次写代码时下意识地绕开。

从零写一遍,换不来的是什么

说句实话,免得你期望过高。

它不让你成为一个更好的「框架使用者」。 你不需要为了用 pitaya 而先手写一遍 pitaya,就像你不需要为了开车而先造一台发动机。

它不产出能直接上线的服务端。 本卷写出的 aegis 是一个教学项目:它的协议是简化的、它的持久化是到卷三才认真做的、它的运维能力是到卷四才补的。不要把它直接部署到生产环境。

它不教你游戏玩法。 战斗公式、掉落概率、经济系统怎么设计——这些是策划和玩法程序的事,本卷只在需要举例时点到为止。

它也不覆盖所有技术栈。 本卷主干是 Go,Java 只作为对照出现。如果你的项目用的是 C++ 或 Erlang,本卷的思路仍然成立,但代码需要你自己翻译。

为什么是 Go 主线加 Java 对照

选 Go 做主干的理由很直接:goroutine-per-connection 让网络代码读起来像同步代码,标准库自带 net、encoding/binary、crypto/*,从零写协议栈不需要引入任何第三方库——读者能看见每一行在做什么,这对「从零」这个目标是决定性的。

但只写 Go 会有一个问题:你会以为 Go 的做法是唯一正确的做法。 工业界真正在用的游戏服务端,Go 与 Java 各占半壁:Go 侧有 pitaya、leaf,Java 侧有 Netty 生态,很多大型 MMO 的服务端就是 Java 写的。

所以本卷在十个左右的地方设了「双栈对照」小节,把同一件事在两种语言里各写一遍:并发模型(goroutine 对比 Java 21 的虚拟线程)、内存视图(Go 切片对比 java.nio.ByteBuffer 的零拷贝视图)、序列化、RPC、GC 行为。每一处都给出可判定的选型结论——什么场景选哪个,而不是「都挺好」。

本机只装了 Java 8,没有虚拟线程,所以所有 Java 代码都经 Docker 拉取的 JDK 21 真跑,命令写在正文里,你可以原样复现。

这一卷写什么,不写什么

写:协议设计与编解码、粘包拆包、二进制序列化、可靠 UDP 与 KCP、WebSocket 长连接、心跳与重连、会话表与网关、消息路由、时间轮、Actor 邮箱与背压、幂等去重、状态同步与帧同步、客户端预测与服务端校正、时间同步与延迟补偿、服务器权威与反作弊、协议 fuzz 与封包加密。最后把它们装成一个能登录、能移动、能广播的最小服务端,并压出第一组真实数字。

不写:玩法系统(背包、战斗、公会、排行榜——那是卷二)、分布式与跨服(服务发现、分库分表、跨服战场——那是卷三)、压测平台、可观测性、GM 后台与运营(那是卷四)。也不写图形渲染与客户端表现(站内已有一套四卷本的《游戏引擎实现原理》)。

本卷在四卷里的位置

《MMO 服务器开发》一共四卷,本卷是第一卷。四卷的关系不是并列的四本书,而是同一个 aegis 的四次长大:

卷一句话结束时 aegis 是什么样
卷一 网络与并发内核(本卷)把地基造出来能登录、能移动、能广播的最小服务端
卷二 单服架构与玩法系统把玩法装进去《晨曦大陆》单服可玩:AOI、战斗、背包、公会、排行榜
卷三 分布式与跨服把规模做上去分区分服、跨服战场、分布式 ID、分库分表
卷四 工程化与运营把它交付出去压测、调优、可观测性、部署、GM 后台、容灾

为什么把网络与并发放在第一卷,而不是先讲玩法。 因为 MMO 与普通后端最大的差异就在这一层。玩法系统(背包、任务、公会)在任何一个后端项目里都能学到,但「同屏几千人如何广播」「弱网下如何同步」「如何防止客户端作弊」是游戏服务端独有的、也是最能拉开水平的部分。先把这一层打通,后面三卷才站得稳。

四卷可以分开读吗。 卷一和卷二相对独立,你可以读完卷一直接去做自己的项目。卷三依赖卷一(要用到网关、会话、路由)和卷二(要有场景与实体),卷四则依赖全部三卷。如果你只打算读一卷,读卷一。

这一卷和站内既有内容的分工

站内 game/server/ 树下已经有相当多的服务端内容:practice/ 目录下 170 篇架构设计文,按月归档的 110 篇,还有 skynet/ 与 pitaya/ 两个框架专题。书架里也有一本《游戏服务端编程实践》。它们和本卷不重复,分工如下。

站内既有内容本卷
形态架构方案与取舍:分层图、数据模型、幂等策略、容灾演练从零实现:每一节都写代码、每一节的代码都真跑
技术栈多语言混杂的综述(Go / Java / Lua / Rust 片段)Go 主线 + Java 对照,两条都真跑
验收无(图 + 文字)端到端机器人断言 + 真实压测数字
游戏类型收尾于 SLG(异步、弱实时)MMO(同屏、强实时、分区分服跨服)

一句话:站内既有的文章回答「该怎么做、为什么这么选」,本卷回答「怎么做出来、做出来跑多快」。 两者互补。你完全可以先读站内的架构文建立判断,再来本卷把它实现一遍。

关于术语:本卷怎么用这些词

中文技术写作里,「服务器」「服务端」「节点」「进程」经常混用,本卷约定如下,避免歧义:

词本卷的含义
服务端整个后端系统的统称,与「客户端」相对
服务器一台物理机或容器实例;「服务器」是部署单位
节点 / node一个运行中的进程,比如一个网关进程、一个场景进程
服 / 区服玩家能选择进入的一个逻辑世界,比如「晨曦一区」
连接一条 TCP / UDP 会话,对应一个客户端的网络通路
会话一条已登录的连接,与某个账号、某个角色绑定

另外两个高频词需要单独说:

  • AOI(Area of Interest,兴趣区域):决定「谁能看到谁」的空间索引。第 12 章和卷二会反复用到。
  • Tick:逻辑时间的推进单位。一次 Tick 处理一批消息、推进一次模拟。它的频率决定了服务端「思考」的粒度。

如果你在别的资料里看到这些词有别的含义,以本卷的约定为准即可——术语一致比术语正确更重要,因为全书都用同一套。

你会撞上的第一个坎

按经验,读这套书的人有九成会在同一个地方卡住。提前说,省得你怀疑自己。

第一到第三章,你会觉得「怎么什么都没发生」。 你跟着写了帧格式、写了编解码、写了粘包拆包的处理,跑起来一看——终端里只有一堆 [recv] cmd=0x0001 len=12 这样的日志。没有画面、没有交互、没有任何「游戏」的样子。

这是正常的,而且必须如此。 前三章在建地基,地基本来就不该有画面。真正的乐趣出现在第 5 章(看到 KCP 在丢包时仍然按时送达)、第 9 章(十万定时器 O(1) 挂载)、第 17 章(三个客户端连上来互相看见)。但那些乐趣是建立在前面「什么都没发生」的章节之上的。

如果你实在想早点看到东西,可以这样:先跳到第 17.2 节,看一遍最终的端到端演示是什么样子,再回到第 1 章开始读。知道终点长什么样,会让前面那些枯燥的章节变得有方向感。

第二个坎是环境。 Docker 拉不到 JDK 镜像、somaxconn 太小导致压测连接被 RST、go test -race 报出一堆你从没见过的竞争警告——这些都在前言 的踩坑清单里写清楚了,遇到时先去那里对照,不要一个人硬扛。

第三个坎是「想跳」。 读到第 4 章觉得「二进制编解码我已经懂了」,于是跳到第 12 章。结果第 12 章的状态同步依赖第 4 章的编码格式、依赖第 3 章的帧结构、依赖第 10 章的邮箱模型,你发现自己寸步难行。本卷的章节是有依赖顺序的,第 12 章之后的内容几乎都会用到前面的东西。要跳,就整段跳(比如整个第四部分),不要拆散。

这套书不适合谁

把话说全,免得你读到一半觉得被骗。

不适合想找「一键方案」的人。 如果你要的是一份「照着抄就能上线」的清单,市面上的框架文档比这套书合适得多。本卷给的是零件和判断力,需要你自己组装。

不适合完全不写代码的人。 本卷每一节都有代码,不做「纯概念科普」。如果你不打算动手,读起来会很吃力,收获也很有限。

不适合只想做玩法的人。 如果你关心的是「战斗公式怎么设计」「经济系统怎么平衡」,本卷帮不上忙——那些在卷二,而且即便在卷二也只是点到为止。本卷从头到尾都在讲底座。

不适合追求「最新最热」的人。 本卷刻意不追新:协议用最朴素的长度前缀,序列化手写二进制,RPC 到卷三才出现。这些选择有些「老派」,但它们稳定、可验证、能看清本质——这正是「从零」需要的。

最后,说一段不是技术的话

写服务端这件事,最难的不是某一章的算法,而是在看不见结果的时候坚持把地基打扎实。前几章你写出来的东西,屏幕上什么都没有——只有一个在终端里打印收发包计数的循环,一堆看不懂的十六进制偏移。你会怀疑:这到底有没有用?

有用。当你走到第 5 章,第一次看到「同样一条消息,TCP 因为前面丢了一个包而卡了 200 毫秒,KCP 却把它按时送到了」;当你走到第 9 章,第一次看到十万个定时器在时间轮里 O(1) 挂上、准时触发;当你走到第 17 章,看到三个客户端连上来、各自走动、彼此都能看见——你会明白,前面那些「什么都没发生」的章节,恰恰是让这一切成立的原因。

如果读完这一卷,你再看任何一个服务端框架的架构图时能说出「这一层大概在做什么、为什么这么分、它的瓶颈会在哪」,这一卷就成功了。 你不需要成为服务端专家,但你会成为一个不再被框架牵着走的开发者。

最后提醒一句:这套书不是用来「读完」的,是用来「做完」的。 如果你只是从头到尾读一遍、从不打开编辑器,那么你和读一篇长文没有区别。真正让你变强的,是你在第 3.2 节亲手处理过一次「两个包粘在一起」、在第 9 章亲手看到过一个定时器因为重入而死锁、在第 17 章亲手用机器人客户端压出第一组数字。这些时刻不会出现在阅读里,只会出现在你敲键盘的时候。

所以,找一个安静的下午,打开终端,go mod init aegis,然后从第 1 章开始。

祝阅读愉快,也祝编码顺利。

阅读下一节:前言

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标