Dedicated Server 游戏架构实战:单容器承载 48 名玩家,权威服务端设计与真实成本测算
我们把权威服务端从游戏后端中拆分出来,重写了匹配队列与房间分配逻辑,并按同时在线玩家人数核算了真实成本。文中给出单容器承载 48 名玩家的资源实测数据、按空闲会话数而非 CPU 占用率扩缩容的做法,以及区域选择、排空下线和何时用中继方案就已足够的判断标准。所有数字均来自一个 8 人竞技射击原型的线上实测。
内测之夜:40 名玩家、一个进程、一次崩溃
去年二月的一个周五晚上 21:00,我们组织了一场 40 人的封闭内测。这款游戏是围绕 8 人对局设计的,那天晚上有五局比赛同时在跑。23:40,三分之一的玩家同时掉线。最后留给我们的只有一行日志:OutOfMemoryException,下面挂着五个不同的 matchId。
原因不是某个 bug,而是架构本身。五局比赛跑在同一个进程里,同一个 Unity headless build 里。其中一局的内存泄漏,把另外四局一起拖死了。那天晚上我们明白了一件事:按房间隔离不是优化项,而是正确性要求。我们也试过给每个进程设内存上限,但真正死掉的仍然是那个共享进程。
接下来的三个月,我们把游戏服务端架构从头重写了一遍。下面的所有数字和被否掉的方案都来自那段时间的实测,全部出自一个 8 人竞技射击原型。引擎是 Unity 2022 LTS,传输层是我们自己在 UDP 之上写的可靠通道。
为什么权威服务端没有商量的余地
第一个版本里,移动由客户端计算,再把结果上报给服务端。第二次内测时,40 人里有 3 个人改了封包,把跑动速度翻了一倍。出问题的不是检测代码,而是架构:只要让客户端写坐标,反作弊就变成一场校验竞赛,而这场竞赛你赢不了。我们试过在服务端加速度上限,但每加一个新的移动技能,就得重新调一次这个上限。
在权威服务端下,客户端只发送输入。我们的 InputCommand 结构体包含 tick 号、移动向量、视角角度和按键位,一共 14 字节。服务端以 30 Hz 固定 tick 跑模拟并广播状态。客户端本地仍然做预测,但权威永远不会交到它手上。
代价是实打实的:现在每个玩家的物理都由服务端来算。实测中,一个 8 人会话占用一颗 3.4 GHz 核心的大约 38%,其中包含角色控制器、hit-scan 和可见性过滤。这一个数字后来成了所有扩容与成本计算的输入。而且 38% 是平均值而不是峰值,在混战最激烈的时候,同一个会话会冲到 55%。
游戏服务端和游戏后端是两回事
游戏服务端持有状态、生命周期很短,它挂掉时只会带走一局比赛。而游戏后端,也就是账号、背包、成长进度和好友列表,是无状态的、生命周期很长,它挂掉时所有人都会受影响。把两者塞进同一个进程,在第一个版本里替我们省下了两周,之后又让我们赔进去两个月。我用的判断规则是:如果一份数据比一局比赛活得久,它就不归游戏服务端管。
我们是这样划线的:比赛过程中,游戏服务端不向任何持久化数据库写入。比赛结束时,只往后端 POST 一个 MatchResult 请求体,里面是每名玩家的分数、时长、伤害和使用过的道具。这样就不会出现八台服务器在同一时刻抢写背包表,而是变成每分钟几百个可以排队处理的小请求。对于中途掉线的玩家,我们每 30 秒额外发一次中间记录,因此一次崩溃不会抹掉任何人的进度。
鉴权同样属于后端。玩家登录时会拿到一张有效期 120 秒的 sessionTicket,连同房间地址一起交给游戏服务端,游戏服务端再用一次调用向后端校验它。游戏服务端根本不持有任何数据库凭据。一台被攻破的游戏服务端够不到背包数据,最多只能毁掉那一局比赛。
匹配队列与房间分配是怎么跑起来的
匹配在我们这里是一个单独的服务:MatchQueue。玩家进入队列时,会带上分段评分、区域和队伍 ID 一起被记录下来。撮合器每 2 秒扫一次队列,把同一区域内评分相差在 ±100 以内的玩家分到一组,每 5 秒把这个区间放宽 50 分。到第 45 秒区间触顶,此时等待时间的权重高于匹配质量。
凑齐八个人之后,工作交给 RoomAllocator。另一个方案我们也试过:每局比赛现拉一个容器。Unity headless build 加载完场景进入就绪状态需要 6 到 9 秒,而这点时间足够把一个已经凑好的队伍拆散。于是我们改成每个区域常驻四个空闲会话保持热态,分配可以在 300 ms 以内完成。
如果热池被取空,新会话会在后台启动,玩家最坏也就是看到那 8 秒等待。不要把池子大小写死:我们这边 21:00 到 24:00 的同时在线人数是白天的六倍,因此池子会按小时曲线在 4 到 12 之间浮动。用固定池的结果,要么晚上队列越排越长,要么白天为闲置机器付钱。
单容器的会话管理与扩缩容做法
一个游戏服务端进程只跑一局比赛,比赛结束就退出。实测占用是每会话 180 MB RSS、平均 0.42 vCPU。一个 2 vCPU / 4 GB 的节点可以从容放下四个会话,挤一挤能放六个。六个会话乘以八名玩家,就是每节点 48 名同时在线玩家。我们试过塞得更满,到第八个会话时 tick 开始超过 33 ms,移动手感肉眼可见地变差。
不要把扩缩容绑在 CPU 使用率上。正确的指标是空闲会话数:freeSessions 掉到 8 以下就申请新节点,涨到 24 以上就标记一个节点准备排空。我们按 CPU 驱动的第一版,每当比赛结束、负载回落时,总想去关掉那些还有玩家在里面的节点。这个指标要按区域分开统计,否则法兰克福的富余会掩盖伊斯坦布尔的紧张。
下线永远走排空流程。节点不再接收新的分配,上面的比赛让它们自然打完。因为我们一局最长 20 分钟,所以最坏情况下的排空时间也是 20 分钟。出于同样的理由,竞价实例和可抢占机器我们只用在热池上;承载着正在进行的比赛的节点,不该是便宜的那一台。
日志和崩溃收集也属于这一层。每个进程往 stdout 按行写 JSON,每一行都带上 sessionId、matchId、buildId 和 tick 号。由于崩溃时进程会直接消失,收集器必须独立于进程运行,并在容器死掉之前把 Player.log 和崩溃转储推到外面。在把这套东西搭起来之前,有三次崩溃我们始终没查出原因。日志量大约是每会话每分钟 400 行,这个量级不做采样也完全存得下。
区域、成本,以及中继方案够用的边界
区域按 ping 选,而不是按玩家的偏好选。我们从布尔萨测到的往返时延是:伊斯坦布尔 18 到 22 ms,法兰克福 48 到 55 ms,北弗吉尼亚 128 到 140 ms。在 30 Hz 的 tick 下,一个 tick 是 33 ms,所以法兰克福能玩,而北弗吉尼亚对一款竞技射击来说不行。客户端登录时会向三个区域各发一个 UDP 探测包,然后带着最好的两个区域进队列。如果队伍成员分散在不同城市,就选共同最优的那个区域,由队伍里最差的那个 ping 拍板。
成本要按同时在线玩家算,而不是按机器算。一个 2 vCPU / 4 GB 的节点每小时大约 0.09 美元,承载 48 名玩家,满载时折合每玩家小时 0.0019 美元。但你永远不会满载:我们的平均上座率是 35%,把热池和每个区域的最低容量算进去之后,真实数字会涨到 0.006 美元。按 500 名同时在线、每天四小时高峰的曲线来算,一个月大约 270 美元。带宽账单占总额的 8%,每名玩家的上行大约 20 kbps。
对一个三人团队来说,这笔钱和真正的开销比起来根本不算什么。分配服务、排空逻辑、日志链路和崩溃收集,加起来花掉我们大约一个全职人月。如果你做的是没有竞技性的 4 到 6 人合作游戏,同时在线也就几百人,那么走中继的 listen server 就够用了。中继解决 NAT 问题,权威留在房主那边,你付出的唯一代价是房主优势。
我用的判断线很简单:只要牵涉排行榜、段位或经济系统,就必须上 dedicated server,而且必须是权威式的;否则先从中继起步。唯一能让后续切换变便宜的做法,是从一开始就把游戏逻辑拆成能在服务端跑的形态。我们的 GameSession 类不依赖任何 MonoBehaviour,同一份代码在 listen server 和 headless build 里都能跑。第一天就做好这个切分,把决策往后拖的成本几乎为零。