第24章 · Dedicated Server:构建、部署与 Session

📑 目录

「远港 Online」第一次在 Linux 上跑起 Dedicated Server 的那个晚上,进程活着、端口听着、日志干干净净,客户端却死活连不上。三个小时后真相浮出:Cook 时漏了主地图,服务器加载默认关卡失败即退出,而拉起脚本里的 while true 又以每秒三次的频率把它原地复活——它在反复自杀,PID 都来不及换。这类事故在 PIE 里永远不会发生,因为编辑器替你把整个世界都准备好了。这一章就把编辑器给的幻觉一层层拆掉。

本章地图

mindmap
  root((Dedicated Server))
    构建裁剪
      源码版引擎编译 Server Target
      剜掉渲染/音频/输入
      Cook 与打包流水线
    启动与联调
      命令行与 ini 覆盖
      1服2客户端三进程
      日志分离与版本校验
    Session
      OnlineSubsystem 抽象层
      从 open IP 到房间制
      GameSession 职责
    Docker 化
      镜像三层策略
      探针与健康检查
    性能画像
      tick 预算三段账
      承载估算公式
      tick 率×人数取舍
    运维补课
      systemd 进程守护
      logrotate 日志轮转

PIE 是温室:服务器进程把你熟悉的 UE 剜掉一半

在 PIE(Play In Editor)里点"服务器 + 2 客户端",一切顺得让人安心:三个 World 挤在同一个编辑器进程里,渲染器照开,音频照响,断点照下。这套温室环境适合调逻辑,但它掩盖了一个事实——真正的服务器进程和你熟悉的那个 UE,已经不是同一个物种了。

Dedicated Server 构建出来的进程,做的第一件事就是把引擎里为"给人看"准备的系统整排剜掉。渲染线程不启动,RHI(Render Hardware Interface)换成空实现 NullRHI,整张 GPU 管线——Nanite、Lumen、材质编译、纹理流送——全部不存在;音频设备挂 Null Audio Device,Sound Cue 加载了也无处可播;Slate 与 UMG 不初始化,没有一个像素会被画出来;输入系统同样缺席,服务器上没有"按键",只有从网络收进来的移动与 RPC 请求。

这是成本核算,不是删减癖。一个带渲染的进程在「远港 Online」的灰盒地图上常驻内存大约 3-4 GB,启动要几十秒;裁掉这些系统之后,服务器进程的常驻内存落到 1 GB 上下(数量级,取决于地图与 Actor 规模),冷启动压缩到十秒级。容器里要并排跑几十个实例,这个差距直接折算成机房租位费。

代价也要说清:所有依赖渲染数据的代码在服务器上行为会变化。Line Trace 还能用,因为物理场景还在;但任何"从屏幕坐标反投影""读渲染目标""基于材质参数做逻辑"的写法,在服务器上要么返回空要么直接崩。在 C++ 里用 GetNetMode()IsDedicatedServerInstance() 把这类代码挡在门外,是联机改造的第一轮体力活。

Server Target:必须用源码版引擎才能编译

这是 UE 网络开发的第一道门槛,先记死:编译 Dedicated Server 目标需要源码构建的引擎。启动器版(Epic Games Launcher 装的二进制版)不带服务器构建所需的工具链支持,生成的项目文件里根本不会出现 Server 配置。这一条在 5.8 依然成立,具体操作以官方文档"Setting Up Dedicated Servers"词条为准。

有了源码版引擎,给「远港 Online」补一个 Server Target 只需要在 Source/ 目录下加一个 Target.cs:

// Source/YuanGangServer.Target.cs —— 服务器构建目标
using UnrealBuildTool;
using System.Collections.Generic;

public class YuanGangServerTarget : TargetRules
{
    public YuanGangServerTarget(TargetInfo Target) : base(Target)
    {
        Type = TargetType.Server;                 // 关键:告诉 UBT 这是服务器
        DefaultBuildSettings = BuildSettingsVersion.Latest;
        IncludeOrderVersion = EngineIncludeOrderVersion.Latest;
        ExtraModuleNames.Add("YuanGang");         // 游戏主模块
        bUseChecksInShipping = true;              // 线上保留 check,崩溃早暴露
    }
}

Type = TargetType.Server 这一行决定了整个编译走向:UBT(Unreal Build Tool)据此定义 UE_SERVER=1,引擎里大量 #if UE_SERVERWITH_CLIENT_CODE 包围的代码路径随之切换。bUseChecksInShipping 是反直觉但划算的选择——服务器崩一次的成本远高于 check 带来的几个百分点开销。

构建与打包走 UAT(Unreal Automation Tool)一条命令:RunUAT BuildCookRun -project=YuanGang.uproject -server -serverplatform=Linux -serverconfig=Shipping -cook -build -stage -pak -archive。三个参数最容易被漏掉:-server(否则打出来的是客户端)、-serverplatform=Linux(在 Windows 上交叉编译 Linux 服务器需要装对应工具链)、以及地图清单——Project Settings → Packaging 里的 "List of maps to include",漏了主地图就是开篇那场事故的剧本。

整条流水线长这样:

flowchart TD
    A[源码版引擎 5.8] --> B[编译 YuanGangServer Target]
    B --> C[UAT Cook: -server -serverplatform=Linux]
    C --> D[Stage + Pak 打包]
    D --> E{冒烟检查}
    E -->|地图缺失/启动即退| C
    E -->|通过| F[拷贝进 Docker 镜像层]
    F --> G[推镜像仓库]
    G --> H[部署到节点]
    H --> I[探针接管: 健康检查与重启]
    style E fill:#f5e6e0,stroke:#b5543a

冒烟检查那一格省不得。服务器进程没有窗口、没有画面,它唯一的表达方式就是日志。每次出新包,先在裸机上跑一次、确认日志里出现地图加载完成与 LogNet 的监听行,再进镜像——这一步能把 80% 的"部署完连不上"拦截在本地。

顺带澄清一个常见误会:服务器包之所以小,不是因为 Cook 内容被大幅裁掉。贴图、模型、音频文件在服务器 pak 里照样躺着——引擎不会在 Cook 时替你判断"服务器用不上这张 4K 贴图"。省下来的是运行时(不加载渲染资源、不建 GPU 缓冲),磁盘上该占的照占。如果磁盘与镜像体积敏感,可以在 Project Settings 的 Packaging 里用 "Directories to never cook" 或专门的资产管理规则,把纯表现层的资产从服务器 Cook 中排除;「远港 Online」的做法是给高分辨率贴图与过场 Sequencer 打 clientonly 标签,服务器包瘦了将近一半。另一个体积大头是符号文件:Shipping 构建的调试符号(Linux 下的 debug info)与二进制分离存放,线上镜像只带 strip 过的二进制,符号文件归档进制品库——真出了崩溃,拿符号文件回解调用栈,这是第七章运维补课要用到的原料。

命令行就是服务器的 UI:端口、地图与 ini 覆盖

客户端玩家有设置界面,服务器没有。服务器进程的全部"配置界面"由两样东西组成:启动命令行,和 ini 文件。理解它们的优先级关系,比在编辑器里点任何面板都重要。

优先级从低到高大致是:引擎自带 BaseEngine.ini → 项目 DefaultEngine.ini → 平台目录下的 ini → 命令行 -ini 覆盖。命令行永远赢。一条典型的覆盖写法是 -ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:NetServerMaxTickRate=64,它把服务器网络 tick 率顶到 64Hz 而不动项目文件——同一套 pak,灰度机开 64 tick,常规机开 30 tick,靠命令行分档。

常用启动参数按出场频率排:

参数作用备注
首参:地图名服务器启动即加载并监听的地图写错名字=静默退出
-port=7777监听 UDP 端口多实例必须错开
-stdout -FullStdOutLogOutput日志走标准输出Linux/容器必带
-abslog=<路径>日志另写一份到文件三进程联调救命参数
-multihome=<IP>多网卡机器指定绑定地址云主机双网卡常见
-logWindows 下弹日志控制台窗口本地调试用

还有一个开发期的偷懒路径:不编 Server Target,直接用编辑器二进制加 -server 参数跑一个"伪服务器"。它能跑,官方开发流程里也认可用它调逻辑,但别拿它上线——它拖着编辑器模块,内存大、启动慢,而且行为与真 Server 构建有细微差别。假服务器的意义是省编译时间——让它省打包流程是交学费的姿势。

一台机器跑多个服务器实例时,端口就是实例的身份证。「远港 Online」的本地约定是 7777、7778、7779 依次排开,写进启动脚本——别靠人记。云主机上还有一道暗坎:安全组与防火墙只放行了 TCP 是经典翻车,UE 游戏流量走 UDP,安全组规则必须显式放行 UDP 7777 起的端口段。多网卡机器(一块内网一块公网很常见)不带 -multihome,NetDriver 可能绑到内网地址上,公网玩家永远连不进——日志里监听行照常出现,毫无破绽,这是本章开篇事故的姊妹篇。

本地三进程联调:一台机器上的最小战场

理论讲完,直接给能抄的作业。目标:一台开发机上,1 个服务器 + 2 个客户端,日志各自分开,10 分钟内确认复制系统在真进程间工作正常——而不是在 PIE 温室里。

前置条件:已经打出一份 Server 包和一份 Client 包(可以是 Windows 本地包,开发机是 Windows 就都用 Win64),两边必须是同一个构建版本。UE 会做网络版本校验,版本不一致的客户端连上来就被踢,服务器日志里留下一行 version mismatch,新手能对着这行字查一下午。

# 终端 1 —— 服务器:主地图,7777 端口,日志独立写文件
YuanGangServer.exe /Game/Maps/Farport_Main -port=7777 ^
  -log -abslog=logs/server-7777.log

# 终端 2 —— 客户端 1:窗口模式,直连本机 7777
YuanGang.exe 127.0.0.1:7777 -windowed -ResX=1280 -ResY=720 ^
  -log -abslog=logs/client-1.log

# 终端 3 —— 客户端 2:同样直连,日志换文件名
YuanGang.exe 127.0.0.1:7777 -windowed -ResX=1280 -ResY=720 ^
  -log -abslog=logs/client-2.log

三个 -abslog 各写各的文件,这是整个联调里最值钱的习惯。混在一个日志里看三个进程的 LogNet 交错输出,谁都扛不住。Windows 下用 ^ 换行,Linux 换成 \;Linux 服务器记得把 -log 换成 -stdout -FullStdOutLogOutput

启动顺序有讲究:先起服务器,等到日志里出现地图加载完成、NetDriver 开始监听的那几行,再放客户端进来。客户端连接在 UE 内部走的是一段短握手,状态变迁如下:

stateDiagram-v2
    [*] --> 解析地址: 命令行带 127.0.0.1/7777
    解析地址 --> 发送Hello: UDP 首包
    发送Hello --> 等待Challenge: 服务器回应质询
    等待Challenge --> 版本校验: 回 ChallengeResponse
    版本校验 --> 被踢下线: FNetworkVersion 不一致
    版本校验 --> 欢迎加入: 校验通过
    欢迎加入 --> 加载地图: 服务器下发地图名
    加载地图 --> 完成Join: Join succeeded
    完成Join --> [*]: PostLogin 生成 PlayerController
    被踢下线 --> [*]

联调通过的验收信号很明确:服务器日志出现 Join succeeded 两次,GameMode 的 PostLogin 被触发两回,两个客户端窗口里能互相看到对方的角色在动。如果只有一个客户端能看到另一个,先查复制与 relevancy(见第 21 章),别查网络——能连上说明传输层已经没事了。

这套三进程环境,是后面所有章节的地基:Session 调试靠它,GAS 预测回滚靠它,第 25 章拆 Zone 之前的每一次联调都靠它。把它做成三个脚本放进仓库根目录,团队人手一份。

联调翻车通常不出在"连不上",而出在连上之后的怪现象,按出现频率列三个:一是版本暗坑——服务器是中午的包、客户端是下午的包,功能看着都正常,个别复制的属性却对不上,排查半小时才发现 changelist 差了一位;规矩是同一个 CI 任务同时产出 Server 与 Client,永远成对部署。二是窗口抢占——两个客户端都全屏启动,第二个把第一个顶到后台,输入焦点乱跳,-windowed 不是可选项。三是日志互相覆盖——两个客户端共用默认日志路径,后启动的把先启动的日志截断,排查复制问题时对着半个世界发呆。这三条没有一条与网络编程有关,但每条都真实吃掉过团队的下午。

Session:把"输入 IP"升级成"房间",但别写死平台

open 127.0.0.1:7777 能连上服务器,但这不是玩家该有的体验。玩家要的是:点"创建房间",朋友从列表里看到,点一下进来。房间从哪来?从 Session 来。

先把"输入 IP"这条路线的天花板摆清楚。它假设玩家知道服务器的地址——运营环境里 IP 会变、实例会漂移,把地址贴在公告栏里是石器时代方案;它没有身份概念,连上就是匿名者,封禁、权限、好友关系全部无从谈起;它没有状态查询,"这个房间现在几个人、什么模式、还要不要人"必须连进去才知道;平台审核也过不去,主机平台的第一方合规明确要求走它们的会话与组队通道。这四条缺任何一条,"游戏"都退化回"技术演示"。

Session(会话)在 UE 里是一个逻辑概念:一份挂在平台侧的"房间登记表",里面写着房主是谁、几个位子、用什么地图、密码多少,以及最关键的——连接字符串。服务器创建 Session 相当于贴出告示,客户端搜索 Session 相当于逛列表,加入 Session 的实质动作是拿到连接串、然后替你去执行那句 open。Session 本身不传游戏数据,它只是通讯录。

OnlineSubsystem:一层值得敬畏的抽象

UE 把 Session 能力收在 OnlineSubsystem(OSS)这层抽象之下,对上暴露 IOnlineSession 接口,对下挂可插拔的后端:NULL(本地与 LAN 调试)、Steam、EOS(Epic Online Services)、以及各家主机的第一方实现。核心调用就四个:CreateSessionFindSessionsJoinSessionDestroySession

sequenceDiagram
    participant A as 客户端A(房主)
    participant OSS as OnlineSubsystem
    participant P as 平台后端(Steam/EOS)
    participant S as Dedicated Server
    participant B as 客户端B

    S->>OSS: 启动后 CreateSession(地图, 8人)
    OSS->>P: 注册房间登记表
    A->>S: open 连接串(直接进服)
    B->>OSS: FindSessions()
    OSS->>P: 拉取房间列表
    P-->>B: 返回(含连接串)
    B->>OSS: JoinSession(选中的房间)
    OSS-->>B: 解析出 IP:Port
    B->>S: open IP:Port
    S-->>B: 握手通过, PostLogin

注意时序图里那条容易被误解的线:B 加入房间后,真正建立游戏连接的动作仍然发生在 B 与 S 之间。平台后端只参与了"登记"和"查询"两步。想明白这一点,"Session 挂了游戏会不会断"这种问题自己就有答案了——不会,已建立的连接与平台无关。

抽象层的意义,用一次事故就能讲透。某项目在 Demo 阶段图省事,代码里直接调 Steam API 开房间、查好友。半年后接跨平台,EOS 进来了,PS 的第一方系统也进来了,所有写着 Steam 字样的调用点都要刨出来重做——三十多个文件,排期两周。如果一开始走 IOnlineSession,换后端只是改 DefaultEngine.ini 里的 NetDriverDefinitionsOnlineSubsystem= 配置行。写死平台省下的三天,半年后连本带利还两周,这是接口存在的全部理由。

还有一层更现实的好处:NULL 子系统让 Session 流程在本地可测。两台进程都配 NULL,A 建房、B 搜索、B 加入,整条链路在开发机上十秒跑完,不依赖任何平台账号。CI 里的自动化联机测试也靠它。

GameSession:服务器侧的房间管家

引擎给了 AGameSession 这个类作为服务器侧的 Session 代理,它管的事很具体:房间名、最大人数、审批加入请求、封禁名单、比赛状态切换。「远港 Online」的战场服务器用自定义 GameSession 派生类,把"8 人小队战场""bUsesPresence 允许好友跟随"这些参数收敛在一个地方,而不是散落在 GameMode 里。

classDiagram
    class IOnlineSession {
        <>
        +CreateSession()
        +FindSessions()
        +JoinSession()
        +DestroySession()
    }
    class OnlineSubsystemNULL
    class OnlineSubsystemSteam
    class OnlineSubsystemEOS
    IOnlineSession <|.. OnlineSubsystemNULL
    IOnlineSession <|.. OnlineSubsystemSteam
    IOnlineSession <|.. OnlineSubsystemEOS
    class UGameInstance {
        +GetOnlineSession()
    }
    class AGameSession {
        +MaxPlayers
        +ApproveLogin()
        +HandleMatchHasStarted()
    }
    class AGameModeBase {
        +GameSessionClass
    }
    UGameInstance --> IOnlineSession : 经引擎层路由
    AGameModeBase --> AGameSession : 持有
    AGameSession ..> IOnlineSession : 读写房间状态

到「远港 Online」联机这一层,房间制的落地顺序建议是:先 NULL 子系统把 Create/Find/Join 全流程打通,再接 EOS 拿跨平台身份,Steam 作为发行渠道的最后一块。反过来做——先接 Steam 再抽象——就是上面那个两周排期的故事。

创建房间时塞给 FOnlineSessionSettings 的字段,决定了这个房间在平台列表里长什么样。高频使用的几个:

字段含义「远港 Online」战场房的取值
NumPublicConnections公开位子数100(对应承载估算的上限)
bIsLANMatch是否仅局域网本地联调 true,线上 false
bUsesPresence是否允许好友跟随/加入true,好友能"加入游戏"
bShouldAdvertise是否出现在公开列表战场 true,私人训练房 false
Settings 自定义键值地图名、模式、平均段位等map=harbor_war, mode=conquest

自定义键值是匹配系统的口粮:FindSessions 返回后,客户端按 mode=conquest 过滤、按段位区间排序,就是最原始的匹配。至于真正的撮合匹配(Matchmaking)——跨服凑人、队伍均分、退回重排——那是 Session 之上的一整层服务,EOS 与 Steam 各有现成的撮合接口,自建则是第 25 章网关层的邻居。「远港 Online」原型期用"列表 + 过滤"顶住,撮合服务留到人口真的上来再建,匹配系统在日活三位数时是负资产。

Docker 化:把服务器塞进镜像,分三层最顺手

服务器能在裸机跑起来之后,下一步是让它变成可复制、可调度、可回滚的部署单元。Docker 是天选工具,但 UE 服务器的镜像有个反直觉点:包很大。「远港 Online」的 Server 包解压后接近 2 GB(Cook 内容占大头),镜像分层策略直接决定 CI 推送与节点拉取的速度。

三层策略,按变更频率从低到高叠:

  • 运行时基座层:系统依赖(glibc、libstdc++、时区证书),几个月变一次。节点上这层缓存命中率极高。
  • 二进制与 Cook 内容层:每次出包都变,2 GB 的大头在这里。它必须独立,否则改一行配置就要重推全量。
  • 配置与启动层:ini 覆盖、启动脚本、环境变量,热更最频繁,往往只有几 KB。
# ---- 第一层:运行时基座(变更以月计)----
FROM ubuntu:22.04 AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
      libstdc++6 ca-certificates tzdata \
    && rm -rf /var/lib/apt/lists/*
# 缺的 so 以构建机上 ldd YuanGangServer-Linux-Shipping 的输出为准

# ---- 第二层:服务器二进制 + Cook 内容(每版本都变)----
FROM base AS binary
WORKDIR /opt/yuangang
COPY staged/LinuxServer/ ./
RUN chmod +x YuanGangServer.sh

# ---- 第三层:配置与启动脚本(变更最频繁)----
FROM binary
EXPOSE 7777/udp
COPY config/ /opt/yuangang/config/
ENTRYPOINT ["/opt/yuangang/config/entrypoint.sh"]

EXPOSE 7777/udp 那行别写成 TCP——UE 的默认游戏流量走 UDP,这是新人部署翻车的高发点。另一点:entrypoint 脚本里用 exec 起服务器,让 UE 进程成为 PID 1,否则 K8s 发的 SIGTERM 会被 shell 吃掉,优雅停机永远等不到。

镜像安全上还有两条生产纪律。一是别用 root 跑:Dockerfile 末尾建一个普通用户并 USER 切过去,游戏服务器被远程执行漏洞打穿的案例不是没有,最小权限是给攻击者加的第二道门槛。二是写路径要显式:UE 运行时会写日志、崩溃报告与 Saved 目录,容器里给这些路径挂可写卷(emptyDir 即可),根文件系统保持只读;反过来不挂卷的常见症状是服务器起来后一切正常,崩溃一次想看日志,容器里空空如也。镜像体积方面,除了前文的符号分离,Cook 内容里 -nodebuginfo、压缩 pak(-compressed)都是常规操作;2 GB 的包压完通常能再让出两到三成,节点拉镜像的时间就是这么省出来的。

探针:UE 没有 HTTP 健康端点,自己造三个信号

Web 服务的探针好写,UE 服务器没有现成的 /healthz。生产上可用的信号由弱到强有三档:进程存活(太弱,死锁时进程还活着)、日志心跳(GameMode 里每 30 秒打一行心跳日志,探针检查日志文件的时间戳)、UDP 回声(在服务器里挂一个极小的 UDP responder,收到 magic 字节就回一个字节)。三档组合,映射到 K8s 的三个探针:

  • startupProbe:服务器冷启动要加载地图,10-30 秒很常见。用 startupProbe 顶住这段时间(比如每 5 秒探一次、允许失败 12 次),别让 liveness 在启动期把它误杀。
  • livenessProbe:日志心跳超过 90 秒没更新,或 UDP 回声连续失败,判定死亡,重启。这一步是自愈的最后一道。
  • readinessProbe:单独一个语义——"还能不能进人"。满员、正在换图、即将维护,都置 not ready。流量(或匹配系统)据此停止往这个实例塞新玩家,但不重启它。readiness 失败就重启,是把满员房间整锅端掉的经典蠢事。
「远港 Online」战场服 · K8s 节点部署拓扑K8s Node(4 vCPU / 16 GB)Pod · server-7777镜像层 1:运行时基座(月更)镜像层 2:二进制 + Cook 内容(版本更)镜像层 3:配置 + entrypoint(随时更)YuanGangServer 进程 (PID 1)监听 UDP 7777 · 心跳日志 30s/行UDP 回声 responder 供探针Pod · server-7778(同构实例)结构与 7777 完全一致,仅端口与实例 ID 不同。单节点建议实例数 =CPU 核数 ÷ 单服占用,内存留出 20% 峰值余量。玩家(UDP 流量)kubelet(三探针)

如果团队已经在 K8s 上,看一眼 Agones——Google 开源的游戏服务器生命周期管理框架,它把"房间分配、玩家计数、优雅缩容"这些游戏特有的语义做成了 CRD。自己拿裸 Deployment 管有状态房间服,运维会在第三个月开始掉头发。Agones 与网关层怎么配合,留到第 25 章展开。

性能画像:tick 预算的三段账与 100 人战场的算术

服务器进程的每一帧,干的活可以分成界限分明的三段:收包(NetDriver 读入站数据、执行 RPC 与移动更新)、逻辑 tick(GameMode 规则、AI、物理、GAS 结算)、复制发包(对每个连接算 relevancy、序列化变更属性、写出站)。NetServerMaxTickRate 决定这个循环每秒转几圈,每圈的预算就是 1000ms 除以 tick 率。

gantt
    title 64 tick 下每帧 15.6ms 的预算分配(数量级示意)
    dateFormat X
    axisFormat %s ms
    section 收包
    NetDriver 读入站/RPC/移动    :a1, 0, 2
    section 逻辑 tick
    GameMode/AI/物理/GAS 结算     :a2, 2, 10
    section 复制发包
    relevancy 计算 + 序列化 + 发送 :a3, 10, 14
    section 余量
    突发缓冲                      :a4, 14, 15

三段账里,逻辑 tick 是大头,也是唯一一段随玩法复杂度无限增长的部分;复制发包随玩家数近线性增长;收包相对平稳。预算超了不会"掉帧"那么简单——服务器 tick 被拖长,客户端的输入会被积压,表现为全房间一起卡顿。服务器端没有"降画质保帧率"这条路,超支就是事故。

承载估算:一行公式定生死

在出架构方案之前,先做一道小学算术。设 tick 预算为 BtickB_{\text{tick}}(毫秒),单个玩家给服务器带来的综合成本为 cplayerc_{\text{player}},那么单进程理论承载为:

Nmax=Btickcplayer=1000/ftickcrecv+clogic+creplicateN_{\max} = \frac{B_{\text{tick}}}{c_{\text{player}}} = \frac{1000 \,/\, f_{\text{tick}}}{c_{\text{recv}} + c_{\text{logic}} + c_{\text{replicate}}}

分母拆成三段,正好对应上面的 gantt:收包成本 crecvc_{\text{recv}}、逻辑成本 clogicc_{\text{logic}}、复制成本 creplicatec_{\text{replicate}},全部按"每玩家每 tick"计。这个公式不提供精确答案,它提供量级判断——在设计评审会上,它比任何 PPT 都好使。

cplayerc_{\text{player}} 从哪来?测,不猜。服务器进程虽然没有画面,stat 家族照常工作:启动参数带上允许控制台的环境,或在配置里开启远程控制台后,stat unit 给出每帧的 Game 线程耗时,stat net 给出入站出站带宽与复制 Actor 计数;更细的分段耗时用 Unreal Insights 连服务器会话抓 trace(见第 18 章的工具链),把 NetDriver 的 TickDispatch、GameMode 的 Tick、NetServerTick 三段在 trace 里量出来。压测人数怎么凑?一百个真人太贵,用引擎的模拟客户端方案——起一批只连网不渲染的 bot 进程占满连接,再让 5-10 个真人混进去验证手感。测出的 cplayerc_{\text{player}} 必须连同硬件型号、地图、玩法密度一起记录,换一个场景这套数字就作废,这也是为什么公式只敢承诺量级。

代入「远港 Online」设想的 64 tick、100 人战场算一遍。Btick=1000/6415.6B_{\text{tick}} = 1000/64 \approx 15.6 ms。在一台 4 vCPU 的中档云主机上实测(数量级,视玩法密度浮动):单玩家收包约 0.01 ms,逻辑(含 GAS 结算与移动物理)约 0.08 ms,复制约 0.02 ms,合计 cplayer0.11c_{\text{player}} \approx 0.11 ms。100 人的稳态成本约 11 ms,账面余量 4.6 ms,似乎能过。

团战条件下,稳态是个幻觉。技能齐放的峰值时刻,GAS 结算与 relevancy 变更量翻倍是常态,按峰值系数 1.6 折算,瞬时成本约 17.6 ms——超过 15.6 ms 的预算,tick 被拖长,全房卡顿。结论:64 tick × 100 人在单进程上是走钢丝。 出路只有三条,而且都不免费:

方案换到什么付出什么
tick 率降到 30预算翻倍到 33ms射击手感明显变"肉"
人数压到 60峰值 10.6ms,余量充足战场规模缩水
拆 Zone/分线承载 ×N架构复杂度跳变(见第 25 章)

这里顺带拆掉一个高频误会:NetServerMaxTickRate 与角色的 NetUpdateFrequency 是两码事。前者是服务器主循环的转速,决定整台服务器的预算口径;后者是单个 Actor 每秒最多向客户端复制几次(默认 100 次,但受服务器 tick 率封顶——服务器只转 30 圈,角色设置 100 也发不出 30 次以上的更新)。调承载时动前者,调单个角色的带宽与跟手度时动后者,拧错旋钮的调优等于没调。

quadrantChart
    title "tick 率 × 玩家数的取舍地图(64/100 为假设目标点)"
    x-axis "低 tick 率(手感肉)" --> "高 tick 率(手感脆)"
    y-axis "少人(承载稳)" --> "多人(承载险)"
    quadrant-1 "钢丝区: 慎入"
    quadrant-2 "电竞区: 小房高 tick"
    quadrant-3 "养老区: 安全但平淡"
    quadrant-4 "MMO 区: 大房低 tick"
    "假设目标 64t×100人": [0.85, 0.9]
    "战场 30t×100人": [0.35, 0.85]
    "竞技场 64t×10人": [0.85, 0.15]
    "大世界 20t×300人": [0.15, 0.95]

「远港 Online」最终的选择是先按"30 tick × 100 人"立项,把 64 tick 留给 10 人竞技场这类小房间。这个表是一份需求文档——说它是妥协清单就看低了它。它告诉策划,手感与规模在同一台机器上是零和的,想要两头都要,请为第 25 章的分布式架构付费。

补课·运维:进程守护与日志轮转的最小实践

补课:本节补运维基础。写过 systemd unit、配过 logrotate 的后端出身读者可跳过;客户端出身、第一次把进程丢上 Linux 的读者,这一节能帮你避开三个经典事故。

游戏服务器是长进程,长进程的三件事必须有人管:死了有人拉、日志别撑爆盘、崩了留得下锅底的证据。

进程守护用 systemd,别再用 nohup ... & 加 crontab 巡检的老黄历。最小可用单元就几行关键配置:unit 文件里写 Restart=alwaysRestartSec=5(崩溃 5 秒后拉起,避免开篇那个每秒三次复活的气锤循环),LimitNOFILE=65535(UE 对文件句柄的需求不小),TimeoutStopSec=30(给优雅停机 30 秒,超时强杀)。ExecStart 指向上文那条启动命令即可。

日志轮转解决的是另一个死法:服务器跑三周,server-7777.log 长到 40 GB,磁盘写满,进程以一种极其难看的方式死掉,顺便把同盘的其他实例带走。logrotate 的最小配置四个指令就够:daily(每天切)、rotate 14(留 14 份)、compress(历史压缩)、copytruncate(UE 不重新打开日志句柄,必须原地截断,而不是 rename 后等它写新文件——漏了这条,轮转完磁盘照样涨)。容器化部署则换思路:-stdout 输出交给 docker logs 或 journald,轮转由容器运行时的 max-size 参数管,应用别自己写文件。

健康检查与自动重启的完整逻辑,写成 watchdog 伪代码存档:

# watchdog:三档探测,逐级升级,动手前先留证据
every 5s:
    if not process_alive("YuanGangServer"):
        restart("进程消失"); continue              # 第一档:进程在不在

    if not log_heartbeat_within(seconds=90):
        restart("心跳停止,疑似死锁"); continue      # 第二档:30s 心跳日志断更

    if not udp_echo_ok(port=7777, magic=b"YG_PING"):
        strikes += 1
        if strikes >= 3:                           # 连续 3 次才判死
            restart("UDP 无响应,网络栈疑似卡死")
    else:
        strikes = 0

def restart(reason):
    collect_evidence()          # 先留证:日志归档、core dump、指标快照
    alert_oncall(reason)        # 通知值班,机器自愈不等于问题消失
    graceful_shutdown(30s)      # 先 SIGTERM 给 30 秒;超时才 SIGKILL
    start_server()

两个细节值得敲黑板。一是 strikes >= 3 的连续失败门槛:网络抖动是常态,单次探测失败就重启,等于拿玩家的房间给网络毛刺陪葬。二是 collect_evidence 必须在重启之前——core dump 和现场日志是复盘唯一的原料,进程一杀,现场就没了。自动重启负责"玩家无感",值班告警负责"问题被看见",两件事都做到,才叫运维;只做前者,叫把尸体藏地毯下面。

core dump 这件事在裸机部署时要提前备好:systemd 管理的发行版默认走 systemd-coredump,确认 ulimit -c 不是 0、存储配额没被压到写不下一个 GB 级的 dump。崩溃发生后再开这些开关,等于事故现场被清扫过才报警。还有一个不起眼但天天打交道的配置:镜像里把时区固定成运维所在时区(前文 Dockerfile 里装 tzdata 就是为这个),并保证所有实例的日志时间戳同源——跨三个实例对一条时间线查问题,发现时钟各差两分钟,那种混乱经历过一次就会把它写进部署清单。

小结

Dedicated Server 构建把渲染、音频、输入整排剜掉,换来内存与启动时间的数量级收益;门槛是必须用源码版引擎编译 Server Target。命令行与 ini 覆盖是服务器的全部配置界面,-abslog 给每个进程独立日志,是本地联调与线上排查的第一生存技能。Session 只是通讯录,游戏连接永远在客户端与服务器之间建立;走 IOnlineSession 抽象,别让代码里出现平台名。Docker 镜像按变更频率分三层,探针用"进程 + 心跳 + UDP 回声"三档;readiness 管进人、liveness 管重启,搞混等于谋杀满员房间。承载公式 Nmax=Btick/cplayerN_{\max} = B_{\text{tick}} / c_{\text{player}} 给出量级判断:64 tick × 100 人单进程过不了峰值,手感与规模零和,要么取舍要么拆 Zone。

上手任务

  1. 打通 Server 构建:用源码版引擎为你的工程添加 Server Target 并打出 Linux 服务器包。验收:裸机启动后日志出现地图加载完成与 NetDriver 监听行,进程常驻内存明显低于客户端包。
  2. 三进程联调:按本章命令在本机起 1 服 2 客户端。验收:服务器日志出现两次 Join succeeded,两个客户端互相可见对方角色移动,三个进程的日志各自独立成文件。
  3. 镜像化与探针:把服务器包做进三层 Docker 镜像,在 K8s(或本地 k3d/minikube)里配齐 startup/liveness/readiness 三探针。验收:kill -9 服务器进程后 30 秒内被自动拉起;手动把房间塞满后 readiness 转 not ready 但 Pod 不重启。
  4. 算自己的承载账:用 stat unit 与服务器性能计数实测你的玩法的 cplayerc_{\text{player}},代入公式算出当前 tick 率下的 NmaxN_{\max},并按峰值系数 1.6 复算。验收:给出一份"tick 率 × 人数"的取舍表,明确标注测试硬件条件。

下一章

单进程的天花板算清楚了,下一个问题自然浮上来:一台服务器装不下一个世界时怎么办——第 25 章把「远港 Online」拆成网关、Zone 与分线,看 WOW 级架构的分层逻辑。

延伸阅读

  • 官方文档:Unreal Engine → Networking and Multiplayer → Setting Up Dedicated Servers(Server Target 构建的权威流程)
  • 官方文档:Unreal Engine → Online Subsystem / IOnlineSession 接口词条(Session 四调用的参数细节,以文档为准)
  • 官方文档:Epic Online Services → Sessions 与 Matchmaking 概念页(跨平台房间制的平台侧视角)
  • 源码:Engine/Source/Runtime/Engine/Classes/GameFramework/GameSession.h(AGameSession 的审批与比赛状态接口)
  • GDC 主题方向:Fortnite 与大型射击游戏的 dedicated server 弹性伸缩分享(检索 Epic 官方 GDC Vault 演讲列表,看大厂如何处理房间服的生灭节奏)