「远港 Online」第一次 200 人战场压测,开场四十秒,全员开始漂移:战士冲锋冲到一半瞬移回原地,治疗读条读完人已经死了。stat net 打上去,单连接下行 5.8 Mbps,是我们预算线的五倍。查了一个星期,罪魁祸首不是技能系统,不是移动同步,是一根血条——一根每 0.1 秒变一次、广播给全图 200 人的血条。这个事故的完整复盘放在本章末尾,但要听懂它,得先把 UE 复制系统这台机器拆开看一遍。
本章地图
mindmap
root((复制系统))
世界观
服务器有真相
客户端有影子
怪bug都是不同步的变种
属性复制
完整链路
RepNotify
条件复制
休眠Dormancy
RPC三态
Server
Client
Multicast
选型决策树
所有权与连接
NetDriver
Connection
Channel
相关性
NetCullDistance
Priority
降频策略
带宽实战
估算公式
200人战场排查实录服务器有真相,客户端有影子
第 20 章讲了网络三害和服务器权威模型,本章把它落到 UE 的具体机制上。UE 的网络模型一句话就能说完:游戏世界只在服务器上真实存在,每个客户端手里是一份定时刷新的副本。
一个 Actor 想参与网络同步,第一步是调 SetReplicates(true)。这行代码的真正含义:授权复制系统在客户端造一个影子,并保持影子跟随真相。"把这个对象发给大家"只是粗浅的表面理解。之后服务器上这个 Actor 标记为 Replicated 的属性一旦发生改变,复制系统会按节奏把增量推到客户端,覆盖影子的对应字段。
这个模型里藏着三条铁律,记不住就会在深夜里被 bug 追杀:
- 客户端写影子,等于没写。你在客户端改
CurrentHealth = 0,服务器不知道,下一次复制包到达时这个 0 会被服务器的旧值覆盖。 - 服务器不写,影子永远不动。服务器改了值但没走 Replicated 声明,客户端看到的是化石。
- 影子是旧闻。影子反映的是几十到几百毫秒前的真相,中间隔着网络延迟和打包周期。
第 20 章结尾留了个问题:客户端的插值缓冲里那些快照是哪里来的?答案就是本章——复制系统按节拍交付增量状态,客户端把每次影子更新(位置、旋转、速度)喂进插值缓冲,缓冲再按"快照间隔×2"的延迟渲染出平滑画面。Character 的移动这条路由 UCharacterMovementComponent 内部包办了,自己写的属性快照则要手动接进缓冲,21.2 末尾讲接法。
把线上见过的怪 bug 摆在一起看,会发现它们全是"影子和真相不同步"的变种:
| 症状 | 影子与真相的错位点 | 常见根因 |
|---|---|---|
| 血条不掉,人却倒了 | 血量影子旧,死亡状态影子新 | 血量走了低频通道,死亡走了 RPC |
| 门开了又瞬间关上 | 影子被旧真相覆盖 | 客户端擅自改了门状态 |
| 特效放了两遍 | 事件被交付两次 | Reliable RPC 叠加 RepNotify 双触发 |
| 远处敌人原地踏步 | 影子停止刷新 | 相关性剔除后没有兜底姿态 |
| 自己捡的东西在别人包里 | 请求没校验 | Server RPC 里没做服务器侧验证 |
这张表可以贴在工位上。排查网络 bug 的第一步永远是问:此刻真相是什么,影子是什么,两者之间隔了什么。
影子模型还有一个反直觉的推论:你写的代码读到的值,可能比你以为的旧一拍。 客户端上 GetHealth() 返回的是影子,是几十毫秒前服务器认可的血量。客户端逻辑拿它做判断("血低于 30% 自动喝药"),判断依据天然滞后。这类"读了旧数据"的逻辑错误在单机里不存在,联机后批量出现。工程对策是分层:表现层随便读影子,决策层要么交给服务器,要么明确容忍滞后,并在文档里写下容忍的理由。
另一件趁早建立的习惯:任何属性加进 Replicated 名单前,先回答三个问题——真相在哪台机器上改、观众是谁、变化多频繁。三个答案直接决定 21.3 的条件、21.6 的分区和最终的带宽账单。复制系统里最贵的 bug,几乎全来自一个错误:把不该上网的数据送上了网。代码写错反倒是第二顺位的嫌疑人。
属性复制的完整链路:一个变量怎么飞到客户端
CurrentHealth 从服务器变量变成客户端血条上缩短的一格,中间经过的每一站都值得知道名字。链路如下:
sequenceDiagram
participant S as 服务器游戏逻辑
participant R as ReplicationDriver
participant N as UNetConnection
participant K as 客户端ActorChannel
participant A as 客户端影子Actor
S->>S: CurrentHealth = 72 写真相
S->>R: 标记脏数据 传统模式靠diff轮询
Note over R: 等待下一次复制周期
节奏上限 = NetUpdateFrequency
R->>R: 相关性过滤 该连接需要这个Actor吗
R->>R: 与上次已发送状态做增量对比
R->>N: 组装Bunch 属性ID + 增量字节
Note over N: 随服务器tick末尾打包发出
实际频率被NetServerMaxTickRate钳制
N->>K: 网络传输 约半个RTT
K->>A: 反序列化 覆盖影子字段
A->>A: 触发OnRep_CurrentHealth
A-->>A: 刷新血条 或推入插值缓冲三个容易被误解的站:
脏数据的发现方式有两种。 传统模式下,ReplicationDriver 在每个复制周期把当前值和上次发送的值做一次逐字节 diff,变了才发;UE5 引入的 Push Model 模式下,写入方主动调 MARK_PROPERTY_DIRTY 把属性推进待发送队列,省掉全量轮询。MMO 这种对象上千的项目,Push Model 能砍掉可观的 CPU 开销,配合 5.8 里仍在演进的 Iris 复制框架效果更佳——Iris 在 5.8 中的完成度以官方文档 "Iris Replication" 词条为准,新项目值得评估,老项目迁移前先把回归测试补齐。
NetUpdateFrequency 控制的是检查频率上限。 它是"这个 Actor 最多多久被检查一次"的上限,默认 100Hz。真正的发包发生在服务器 tick 末尾,而服务器网络发送频率受 NetServerMaxTickRate 钳制,默认 30。所以默认值下,任何 Actor 的实际更新频率天花板是 30Hz,写 100 只是给了它"每 tick 都有机会"的资格。压测里见过有人把 NetUpdateFrequency 调成 200 然后质问为什么延迟没降——发包那头根本没变。
增量对比是省带宽的主力。 服务器为每个连接记着"上次给这个连接发过什么",只发变化的部分。一个 40 属性的角色站着不动,复制流量接近零。这条机制的后果是:带宽消耗正比于变化率——对象复杂度反而在第二行。记住这句话,21.7 的战报全靠它解释。
增量机制在丢包下的行为也值得知道。每个 bunch 发出后进入待确认队列,客户端 ack 了才从"上次已发送状态"里转正;哪个 bunch 丢了,它携带的全部属性变更被打回脏队列,随下一批重发。所以丢包率的代价是"这批变更整体晚一个 RTT"。这就是为什么属性复制敢用 Unreliable 通道跑——状态可以迟到,但永远不会错序抵达一个中间态。事件型 RPC 没有这个兜底,丢了就是丢了,这又回到了 21.4 那个判据:能不能丢,决定了它该住哪条通道。
链路最后一站有个分工问题:影子字段被覆盖之后,谁来消费?UI 属性(血量、蓝量)在 OnRep 里直接刷新界面;空间属性(位置、朝向)不要直接 SetActorLocation,那是瞬移,要塞进第 20 章的插值缓冲:OnRep 里把新快照连同到达时间 push 进环形缓冲,渲染线程按 服务器当前时间 - 快照间隔×2 取样插值。复制系统负责"准时送到",缓冲负责"平滑播放",两层各司其职。
RepNotify 与条件复制:影子变化时的门铃
属性复制到客户端后,默认动作是默默覆盖字段。想在覆盖的瞬间做点事——刷新 UI、播数字、触发特效——给属性挂 ReplicatedUsing,这就是 RepNotify,门铃。
// FHCharacter.h 与 .cpp 合并节选,解决"血量变了客户端要知道"
UPROPERTY(ReplicatedUsing = OnRep_Health)
float CurrentHealth = 100.f;
UFUNCTION()
void OnRep_Health(float OldHealth);
// .cpp 中注册复制规则
void AFHCharacter::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& Out) const
{
Super::GetLifetimeReplicatedProps(Out);
DOREPLIFETIME_CONDITION_NOTIFY(AFHCharacter, CurrentHealth,
COND_None, REPNOTIFY_Always);
}
void AFHCharacter::OnRep_Health(float OldHealth)
{
// 影子刚被真相覆盖:OldHealth 是覆盖前的影子值
const float Delta = CurrentHealth - OldHealth;
if (Delta < 0.f) { ShowFloatingDamage(-Delta); }
RefreshHealthBar();
}两个细节能救命。REPNOTIFY_OnChanged 只在值真的变化时响铃,REPNOTIFY_Always 则每次复制到都响——服务器重复发送同一个值时也会响,挂特效时选错会播两遍。OldHealth 参数需要主动声明才能拿到,在头文件里把 OnRep 声明成带参形式后引擎才会传给你,靠它能算出伤害差值,省掉客户端自己记账。
条件复制(Replication Condition)控制"发给谁"。DOREPLIFETIME_CONDITION 一族宏里填的条件,本质是每个连接上的过滤器:
| 条件 | 语义 | 远港 Online 的用法 |
|---|---|---|
| COND_None | 发给所有人 | 位置、外观、死亡状态 |
| COND_OwnerOnly | 只发属主 | 背包、任务列表、私密数值 |
| COND_SkipOwner | 跳过属主 | 别人眼中的动作表现 |
| COND_SimulatedOnly | 只发模拟代理 | 非本人角色的移动参数 |
| COND_AutonomousOnly | 只发自主代理 | 本人角色的预测校准数据 |
| COND_InitialOnly | 只在首次复制发 | 名字、职业、皮肤这类不变量 |
| COND_ReplayOnly | 只发录像回放 | 观战系统专用数据 |
用对条件的收益立竿见影:背包走 COND_OwnerOnly 之后,100 人主城每个连接的下行直接少了一整类数据。代价是规则变多,审查属性表时要多问一句"这玩意儿真的需要广播吗"。
条件表里没有"只发同队"这一项——引擎不认识你的游戏规则。「远港 Online」需要"队友可见血量、敌人不可见"时,正路是重写 IsNetRelevantFor 或按 21.6 的办法在属性粒度之外做连接级过滤;想做到属性粒度,就得自定义 condition(引擎允许注册自定义复制条件,具体接口以 5.8 官方文档 Replication 词条为准)。走歪路的方案:给每个队建一个 Actor 转发数据,在 20 人小队规模下能用,规模上去了维护成本会反噬。
最后一件武器是休眠(Dormancy)。门、宝箱这种几百年不变一次的 Actor,设 NetDormancy = DORM_DormantAll 之后彻底退出复制循环,直到服务器调 FlushNetDormancy 唤醒。200 个静态互动物休眠掉,服务器的复制轮询列表能短一半。注意休眠期间属性变了客户端也不知道,唤醒时机必须由服务器逻辑保证,漏唤醒的 bug 表现是"全服只有一个人看不见门开了"。
RepNotify 还有一个新手必踩的时序坑:影子出生时的初始复制也会触发 OnRep。Actor 刚在客户端 Spawn,初始属性到达,OnRep 立刻执行,此时 OldHealth 里躺的来自 C++ 默认值——Actor 刚从服务器出生,没有"上一次的值"。在 OnRep 里无条件播受击特效,每个新出生的影子都会原地放一次烟花。对策是判断 OldHealth 是否为初始哨兵值,或者在 OnRep 里先查 Actor 是否已完成初始化。「远港 Online」的规矩是:OnRep 第一行永远先处理出生分支,再处理增量分支,两个分支写清楚注释。
RPC 三态:一次性事件的三种快递
属性复制管"状态",RPC(Remote Procedure Call)管"事件"。判据只有一个问题:这件事中途丢了,要不要补上?
要补——血量、门的开合状态、背包内容——用属性复制,复制系统的增量重发机制天然保证最终一致。丢了也就少看一帧:伤害飘字、技能特效、命中音效,用 RPC,而且要 Unreliable——让网络拥塞时引擎帮你丢,别让它排队堵死后面的包。
先用一个反例把判据砸实。新手最爱写的代码是拿 bool 属性当事件触发器:服务器置 bHit = true,客户端 OnRep 里播特效,然后服务器再置回 false。这个写法在高频下必死——两次复制可能合并成一次交付(间隔内变了两次,增量 diff 只看首尾),客户端只看到一次甚至零次跳变,特效丢帧丢得毫无规律。事件的正确载体是 RPC,因为 RPC 每次调用独立成 bunch,调用次数与交付次数一一对应。反过来,把状态塞进 RPC 也是灾难:迟到加入的客户端收不到历史 RPC,门永远停在默认状态。选型的错误从来关乎行为。"性能差点"只是表象。
三态的语义和路由规则:
| 声明 | 在哪执行 | 路由规则 | 可靠性选择 |
|---|---|---|---|
| Server | 服务器 | 客户端调用,跨网到服务器执行 | 关键请求用 Reliable |
| Client | 某个客户端 | 服务器调用,只发该 Actor 属主的连接 | 私聊/提示用 Reliable |
| NetMulticast | 服务器+全体客户端 | 服务器调用,广播给所有连接 | 特效类一律 Unreliable |
// RPC 三种声明与一次带校验的调用链
UFUNCTION(Server, Reliable)
void ServerRequestOpenDoor(AActor* Door);
UFUNCTION(Client, Reliable)
void ClientShowLootToast(const FLootEntry& Item);
UFUNCTION(NetMulticast, Unreliable)
void MulticastPlaySkillFX(FVector_NetQuantize Location);
void AFHCharacter::ServerRequestOpenDoor_Implementation(AActor* Door)
{
// 服务器权威校验:存在性、钥匙、距离、冷却,一个不能少
if (!Door || !HasKeyFor(Door)) { return; }
if (FVector::Dist(GetActorLocation(),
Door->GetActorLocation()) > 300.f) { return; }
Door->Open(); // 门状态走属性复制广播
MulticastPlaySkillFX(Door->GetActorLocation());
}UE5 里 WithValidation 已经废弃,Server RPC 不再需要 _Validate 函数,所有校验直接写进 _Implementation——但"写"和"不写"之间隔着整个外挂产业,第 28 章反作弊还会回到这个话题。FVector_NetQuantize 这类量化类型把坐标压到整数厘米级,传输体积比裸 FVector 小三分之二,事件参数里能上就上。
Reliable 的代价要讲清楚再选。同一 Channel 上的 reliable bunch 严格有序:一个包丢了,后面所有 reliable 流量排队等重传。团战高峰期一个 Reliable 特效 RPC 丢包,会把同 Actor 的属性更新一起堵在半秒之外——玩家看到的是血量条卡住不动。Unreliable 则是"到了就执行,丢了就算了",永不出队。经验法则:一条连接上 Reliable 流量占比超过三成就该警惕,特效、飘字这类纯表现事件没有任何理由占 reliable 的位置。
六个典型场景的选型,按决策树走一遍:
flowchart TD
A[游戏里发生了一件事] --> B{中途丢了要不要补上}
B -- 必须补上 --> C{是持续状态
还是一次性关键事件}
C -- 持续状态 --> D[属性复制
血量 门开合 背包 死亡标记]
C -- 一次性关键事件 --> E[Reliable RPC
交易确认 拾取到账]
B -- 丢了就少看一帧 --> F{观众是谁}
F -- 只有本人 --> G[Client RPC
私聊 拾取提示]
F -- 周围所有人 --> H[NetMulticast Unreliable
技能特效 伤害数字]
F -- 客户端请求服务器 --> I[Server RPC
开门 拾取 释放技能]
I -.执行结果回到状态层.-> D逐个过:
伤害数字:高频、丢失无所谓、周围人都要看——NetMulticast, Unreliable,参数里塞伤害值和命中部位。千万别走 Reliable,团战时 reliable 队列一堵,所有事件集体迟到。血量本身另走属性复制,两条通道互不拖累。
开门:客户端发 Server RPC 请求,服务器校验钥匙和距离后改门的 bIsOpen 属性,属性复制把状态广播出去。开启门是"状态"——这直接决定了选型:晚进门的玩家也得看到它开着,只有复制系统会给后来者补发当前状态。事件做不到这一步。
技能特效:NetMulticast, Unreliable。施法动作本身是状态(在 GAS 里由 GameplayAbility 管,见第 22 章),飘出来的火花是事件。
聊天:私聊走 Client RPC 点对点;频道聊天在 MMO 架构里通常旁路给独立的聊天服务,不占用游戏连接(见第 25 章)。
拾取:客户端 Server RPC 请求,服务器校验后改背包属性(COND_OwnerOnly 复制),再用 Client RPC 弹"获得物品"提示。请求、状态、反馈三步分离,各走各的通道。
死亡:死亡是状态——bIsDead 属性复制 + RepNotify 里播倒地演出。用 RPC 播死亡是经典事故:迟到加入的客户端看到尸体站着,因为事件它没赶上。
把这六个场景投进"状态-事件 × 频率"的坐标系,选型规律会自己浮出来:
quadrantChart
title "选型坐标系:状态 vs 事件 × 频率"
x-axis "低频" --> "高频"
y-axis "一次性事件" --> "持续状态"
quadrant-1 "属性复制 配合降频"
quadrant-2 "属性复制 默认形态"
quadrant-3 "Reliable RPC"
quadrant-4 "Unreliable Multicast"
"背包内容": [0.15, 0.9]
"门开合": [0.1, 0.75]
"死亡标记": [0.12, 0.6]
"血量": [0.6, 0.85]
"位置朝向": [0.95, 0.92]
"拾取到账": [0.25, 0.2]
"聊天": [0.3, 0.12]
"伤害数字": [0.7, 0.15]
"技能特效": [0.65, 0.08]上半区(状态)几乎被属性复制垄断,区别只在频率高时要做 21.6 的降频;下半区(事件)按可靠性需求分流,频率越高越倾向 Unreliable。右上角的"高频状态"是带宽重灾区,位置、血量都住在这里——21.7 的事故主角正是这个象限的居民。新项目评审网络方案时,把每个同步需求先在这个坐标系里落点,落错象限的设计当场打回。
所有权与连接:RPC 的投递地址从哪来
Client RPC 凭什么知道发往哪台机器?答案藏在所有权(Ownership)链里。UE 的每个玩家对应服务器上一个 UNetConnection,连接的归属物是 PlayerController;Pawn 的 Owner 设为 PlayerController 后,整条链就通了:复制系统沿 Actor 的 Owner 链向上爬,爬到第一个有连接的节点,那个连接就是投递地址。
classDiagram
UNetDriver "1" --> "N" UNetConnection : 管理
UNetConnection "1" --> "N" UChannel : 复用
UActorChannel ..> AActor : 一对一绑影子
UNetConnection --> APlayerController : 归属
APlayerController "1" --> "1" APawn : 拥有链
class UNetDriver {
+TickDispatch()
+ServerReplicateActors()
}
class UNetConnection {
+OutBytesPerSecond
+SentBunches
}
class UActorChannel {
+ReplicateActor()
+ProcessBunch()
}这条链解释了三类高频报错:
Client RPC 静默丢失。 在 GameState 上调 Client RPC,路由不到任何人——GameState 不属于任何连接。只有 Owner 链能爬到 PlayerController 的 Actor 才能接 Client RPC,典型的有自己 Pawn、PlayerState、挂在 Pawn 上的组件。
Server RPC 打不出去。 客户端调用 Server RPC 的前提同样是"我拥有这个 Actor"。玩家对别人的 Pawn 调 Server RPC,引擎直接丢弃并打日志。想让别人替你传话,先把请求发给自己的 Pawn,由服务器侧转发。
影子迟迟不出生。 服务器 Spawn 一个 Actor 后,客户端要等三件事:Actor 对该连接判定为相关、ActorChannel 建立、Actor 影子 Spawn 并收完初始属性。PlayerController 上的 OnPossess 时序、组件依赖父 Actor 的时序,全由 Channel 层的可靠序保证——这也是引擎替你做的脏活之一,自己撸过网络层的都懂这值多少钱。
UNetDriver 是总管:每 tick 收包分发给 Channel,每 tick 末调用 ServerReplicateActors 跑一轮"相关性过滤 → 优先级排序 → 增量打包"。Channel 是 per-Actor 的传输管道,属性 bunch、RPC bunch 都从它过。理解这三层,看 stat net 和 obj list 的输出时就知道每个数字挂在哪层。
还有一个幕后角色值得知道名字:NetGUID。服务器和客户端的对象是各自内存里的两块独立数据,bunch 里不可能塞指针,跨网引用全靠 NetGUID——服务器给每个上网对象分配的整型标识,客户端拿它在本地解析出对应影子。包里传"命中了谁",传的就是目标 Actor 的 NetGUID。它的工程后果有两个:引用解析是异步的,目标 Actor 的影子可能还没出生,引用会先挂起等它到达;全量 NetGUID 首次出现时是按字符串路径发送的,这也是新对象首次复制偏贵的原因。「远港 Online」压测里新玩家入场瞬间的带宽尖峰,一半来自这批首次同步的引用解析,缓解办法是入场区域预置对象尽量复用关卡静态 Actor,它们的 NetGUID 是稳定的,不用临时分配。
相关性:谁在视野里,谁就配占用带宽
服务器每 tick 的复制轮询面对一个残酷现实:带宽有限,而 Actor 很多。相关性(Relevancy)回答"这个连接需不需要这个 Actor",优先级(NetPriority)回答"带宽不够时先发谁"。一个管开关,一个管排队。
默认判定逻辑在 AActor::IsNetRelevantFor:距离超过 NetCullDistanceSquared(默认 15000cm 的平方,即 150m)就不相关,bOnlyRelevantToOwner 为真的只发属主。重写这个虚函数可以塞进任何规则——「远港 Online」里加了两条:同小队成员无视距离永远相关(队友血条要跨图可见),敌对阵营在 150m 内也只复制移动和生死标记,不复制 Buff 列表。
一个 Actor 在某条连接上的相关性生活,用状态机看更清楚:
stateDiagram-v2
[*] --> 未相关 : 服务器Spawn完成
未相关 --> 相关 : 进入剔除半径或被强制相关
相关 --> 休眠 : NetDormancy置为DormantAll
休眠 --> 相关 : 服务器FlushNetDormancy唤醒
相关 --> 未相关 : 离开剔除距离
未相关 --> 影子销毁 : 服务器Destroy通知TearOff
影子销毁 --> [*]优先级饥饿是另一个暗坑。复制系统按 NetPriority 从高到低打包,带宽配额用完时低优先级 Actor 本轮直接跳过。把背景 NPC 的 NetPriority 留在默认 1.0、玩家角色抬到 2.0–3.0,团战时玩家操作不受影响,代价是远处 NPC 更新变卡——这笔账划算,玩家只看眼前的对手。
相关性还有 CPU 一侧的账,而且往往先爆。默认实现里,ServerReplicateActors 每 tick 对每条连接遍历一次全场景 Actor 列表做相关性判定,复杂度是 O(连接数 × Actor 数)。200 连接 × 2000 个上网 Actor,每 tick 就是 40 万次判定,30Hz 下 1200 万次/秒——单个 IsNetRelevantFor 里多写一行开方,服务器帧时间就能肉眼可见地涨。所以 21.6 的分区策略用定时器低频刷新、判定函数里只许出现廉价运算,是两条硬性军规。规模再往上走(千人同图),引擎给出的答案是 Replication Graph:把 Actor 按空间格子预先分桶,连接只订阅附近桶,把逐连接全量遍历换成查表。它是官方文档 Networking 下的独立词条,「远港 Online」在 500 人战场的规划里已经预留了它的位置,落地细节以 5.8 文档为准。
带宽的账:一个公式和一条红线
单连接下行带宽的估算模型:
其中 是该连接的相关 Actor 数, 是第 个 Actor 的实际发送频率(Hz), 是每 bunch 的固定开销(Actor 头、NetGUID、时间戳,约 15–25 字节), 是本次增量数据的序列化大小, 是 RPC 流量。乘 8 换成比特。
拿「远港 Online」主城 100 人聚集的场景套一遍。移动 bunch 实测约 40 字节(量化后的位置+旋转+速度),取 字节兜底:
- 不设防的默认配置:Hz 全员全量, Mbps,再加属性和 RPC,直奔 2 Mbps。
- 分区降频后:内圈 20 人 30Hz、中圈 40 人 10Hz、外圈 40 人 2Hz, Mbps。
运营给的客户端下行红线是 1.5 Mbps——超出后在弱网玩家那里就是丢包和排队。结论很直白:100 人同图不做降频必然破线,做了降到红线的三分之一。相关性是一项准入条件——没有它,连门都摸不到。优化项那是入场之后再谈的事。
公式里的 单独记一本账。事件流量正比于"每秒发生次数 × 观众数 × 参数大小":主城 100 人每秒 30 次技能特效、参数 20 字节、全员可见, Mbps——事件也可以吃掉三分之一红线,特效 RPC 的参数表(位置、技能 ID、随机种子)每一个字节都要辩护。我们的红线内部分配是:移动 0.5、属性 0.3、RPC 0.4、余量 0.3 Mbps,任何系统超支找对应负责人,不共享大锅饭。
降频策略落地,伪代码如下:
# 伪代码:按观察者距离与阵营的动态降频(类 Python)
def update_replication_policy(actor, viewer):
d = distance(actor, viewer)
if d > NET_CULL_DISTANCE: # 150m 默认边界
actor.set_relevant(viewer, False) # 直接不相关
return
if same_squad(actor, viewer): # 队友:全量
actor.net_priority = 3.0
actor.net_update_freq = 30 # Hz
elif in_combat_ring(actor, viewer): # 交战圈 50m 内
actor.net_priority = 2.0
actor.net_update_freq = 20
else: # 远处围观群众
actor.net_priority = 0.5
actor.net_update_freq = 2 # 能省则省UE 侧的落点是三个钩子:IsNetRelevantFor 管开关,SetNetUpdateFrequency 管节奏,NetPriority 管排队。这个策略函数不要每帧跑,挂在 1 秒定时器或玩家跨区事件上足够——相关性判断本身也吃 CPU,服务器要为每条连接×每个 Actor 付钱。
带宽杀手排查实录:200 人战场的那根血条
回到开头的事故。测试条件:200 个 bot 客户端接入源码版 5.8 Dedicated Server,战场地图,全员混战。
T+0 分钟,症状。 全员漂移,技能集体迟到。stat net 显示单连接 Out 从平时的 300 Kbps 飙到 5.8 Mbps,丢包率 12%。第一反应是移动同步出了问题——但移动 bunch 的量按公式算只有 1 Mbps 出头,对不上。
T+2 小时,缩小范围。 按 21.2 的机制:带宽正比于变化率。obj list class=BP_SoldierCharacter 确认 Actor 数量正常(200 个,没有意外增殖)。问题必然出在某个"高频变化的属性"上。逐个审查 Replicated 属性表,锁定 CurrentHealth:DoT 技能每 0.1 秒 tick 一次,每次扣血都触发全量广播;NetUpdateFrequency 被策划调到 100("血条要丝滑");条件是 COND_None——发给全图 200 人,包括 150m 外根本看不见血条的人。
定性。 一根血条,200 个源 × 100Hz × 全图广播,占满了整条下行管道。移动 bunch 被 reliable 队列挤在后面,表现就是全员漂移。
修复四连:
| 手段 | 改动 | 单连接下行变化 |
|---|---|---|
| 条件收窄 | 血量只发同队和交战圈(IsNetRelevantFor 白名单) | 5.8 → 1.9 Mbps |
| 量化 | float 改 uint8 百分比档位,4B → 1B | 1.9 → 1.4 Mbps |
| 降频 | 该属性独立通道 5Hz,丝滑感由客户端插值补 | 1.4 → 0.95 Mbps |
| 休眠兜底 | 脱战 10 秒的 bot 置 Dormant | 0.95 → 0.82 Mbps |
修复后 200 人混战下行稳定在 0.8–0.9 Mbps,丢包归零,漂移消失。"丝滑"的目标没有妥协——客户端血条照样 60fps 渲染,只是数据源从 100Hz 网络流换成了 5Hz 网络流 + 本地插值。这条经验可以推广:UI 的刷新率不该绑架网络的交付率。
为什么这个问题上线前没被发现?复盘会挖出来两层原因。流程上,此前的压测最高只到 40 人,带宽随人数近似平方增长(人数 × 每人相关对象数),40 人时 0.6 Mbps 完全看不出异常,200 人直接撞墙——小规模压测测不出规模病,压测目标人数必须对齐运营目标。设计上,血条需求是策划用一句"头顶血条要实时"提的,程序照搬成了网络需求,没人追问"实时"到底是 100Hz 还是 5Hz 加插值。需求翻译环节缺了"这个效果打算花多少带宽"这一问,事故从那一刻起就注定了。现在「远港 Online」的属性评审表里有一栏固定填写:预期变化频率、观众范围、单列带宽预算,填不出来不许合入。
三条军规带走:先测量再怀疑(stat net 在前,猜想在后);带宽问题找"变化率最高的属性",不找"最大的对象";任何属性的默认假设应该是"有人不需要它",让属性表里的每一条都为自己的观众名单辩护。
到这里,第 20 章的插值缓冲和本章的复制系统正式合流成一条流水线:服务器写真相 → 复制系统按节拍交付增量 → Channel 反序列化覆盖影子 → OnRep 把快照推入插值缓冲 → 渲染帧按两倍快照间隔的延迟取样播放。前一半管"送到",后一半管"播好"。「远港 Online」里所有自建同步实体(坐骑、投射物、载具)都走这条流水线,Character 的移动由移动组件内部做了同样的事。排查任何"抖、飘、瞬移",先定位它卡在哪一段:stat net 看交付段,p.NetShowCorrections 看消费段,两段的症状完全不同,别混着修。
小结
服务器持有真相,客户端持有影子;所有网络怪 bug 都是两者不同步的变种,排查先问"错位在哪一环"。属性复制管状态,RPC 管事件;判据是"丢了要不要补"。六大场景的选型背下来:伤害数字和特效走 Multicast Unreliable,开门拾取走 Server RPC + 属性广播,死亡走属性 + RepNotify。Client RPC 的投递地址由 Owner 链爬到 PlayerController 决定;GameState 上调 Client RPC 等于扔进黑洞。NetUpdateFrequency 是检查频率上限,不是发包频率;发包被 NetServerMaxTickRate 钳制。带宽正比于变化率,不是对象复杂度。相关性管"发不发",优先级管"先发谁";100 人同图不做分区降频必破 1.5 Mbps 红线,做完降到三分之一。
上手任务
- 给「远港 Online」的门加上完整复制链路:
bIsOpen属性(COND_None + RepNotify 播放动画)+ServerRPC 请求开门(服务器校验距离 ≤300cm)。验收:两个客户端互见门状态一致;中途加入的第三个客户端看到正确的门开合状态;客户端断线重连后状态不丢。 - 亲手制造并修复一次带宽事故:在 PIE 起 1 服 4 客户端,给一个 Actor 挂
COND_None的 float 属性每 tick 随机变化,用stat net记录 Out 带宽;然后依次套用条件收窄、量化、降频三招。验收:三步各留截图,带宽降幅合计超过 70%,且客户端显示效果无明显劣化。 - 实现 21.6 的分区降频策略:重写
IsNetRelevantFor支持"同队无视距离",用 1 秒定时器刷新 NetUpdateFrequency 分档。验收:两名玩家相距 200m 同队时互见血条,非同队时影子被剔除;stat net下行随距离拉开而下降。
下一章
属性复制让血量到了客户端,但"放技能扣多少血、Buff 怎么叠、冷却怎么算"这套规则还没着落——第 22 章请出 MMO 技能系统的事实标准:游戏能力系统(Gameplay Ability System, GAS)。
延伸阅读
- 官方文档:Unreal Engine → Networking and Multiplayer → Actor Replication 与 RPCs 词条,5.8 的 Iris 实验状态也在这里跟踪。
- 官方文档:Networking → Replication Graph 词条——千人同图时的下一代相关性框架,思路与本章分区降频一脉相承。
- GDC 2018:It IS Rocket Science! The Physics of Rocket League(网络同步部分讲了"状态 vs 事件"取舍的实战样本)。
- 源码精读:Engine/Source/Runtime/Engine/Private/NetDriver.cpp 中
ServerReplicateActors,复制轮询的真身。 - 源码精读:Engine/Source/Runtime/Engine/Classes/Engine/NetConnection.h,OutBytesPerSecond 等 stat net 数字的出处。