NetDriver / Channel / Bunch
这是 UE 复制栈的「地板」。属性复制、RPC、Relevancy 全都建在这层之上。搞懂
NetDriver → NetConnection → Channel → Bunch → Packet这条链,就能解释所有上层现象:为什么复制会延迟、为什么可靠 RPC 洪泛会断线、为什么低优先级 Actor 会「饿死」发不出去。
一句话结论
UE 在 UDP 之上自建了一套可靠传输:Bunch 是逻辑消息、Packet 是物理包,靠序列号+ACK 做可靠,靠带宽配额做流控。
场景问题
打个比方:这层像一家快递公司自建的物流网。
UNetDriver是总调度室,管全国的线路;每个客户端是一条专线(UNetConnection);专线上按业务分不同传送带(Channel)——控制指令走一条、每个 Actor 各占一条、语音单独一条,互不打扰;你要发的每件东西是一个包裹(Bunch);而真正上路的是货车(Packet)——一辆车会把同方向的多个小包裹拼车装走(bunch merging),包裹太大装不进一辆车就拆箱分几车运、到站再拼回来(partial bunch)。车都编了号,收货方每收到一车就回执(ACK),没回执的重发——这就是可靠性。但每条专线每天限运多少吨是有配额的(带宽限额),货多了就得排优先级,优先级低的包裹可能今天一直排不上车(低优先级 Actor 饿死)。而声明要「必达」的包裹(Reliable)发不出去时不能丢,只能堆在仓库里——仓库堆满(可靠队列溢出)快递公司直接终止合作(断开连接)。
面试官追问:「UE 底层用 TCP 还是 UDP?可靠性怎么保证的?」——UE 默认用 UDP,然后在应用层自己实现了一套可靠 ARQ(选择性重传 + 序列号 + ACK)。这和 KCP 解决的是同一个命题:「UDP 上怎么做可靠有序」。区别是 UE 把可靠性做进了引擎、按 Bunch 粒度控制哪些要可靠哪些不要。要讲清这套,得从对象链拆起。
实现方案
1. 对象链:从驱动到字节
| 层 | 类 | 一句话 |
|---|---|---|
| 驱动 | UNetDriver | 管所有连接、驱动每帧收发(服务器一个,GetNetDriver()) |
| 连接 | UNetConnection | 一个客户端一个,持有带宽配额、序列号状态 |
| 通道 | UChannel | 逻辑分流:UControlChannel / UActorChannel / UVoiceChannel |
| 消息 | FOutBunch/FInBunch | 一条逻辑消息(一次属性更新或一个 RPC) |
| 物理包 | Packet | 一个 UDP 数据报,可打包多个 Bunch |
每个复制 Actor 在每个相关连接上有一个 Actor Channel——这也是为什么 Actor 数 × 连接数会成为开销瓶颈,见 Relevancy 与带宽预算。
2. Bunch 与 Packet 的关系
- Bunch 是逻辑单位:一次属性同步、一个 RPC 调用序列化成一个 Bunch。
- Packet 是物理单位:一个 UDP 报文。为省包头开销,UE 把同一帧内多个小 Bunch 合并(bunch merging) 进一个 Packet。
- 超 MTU 分片:单个 Bunch 太大(超过一个 Packet 容量)时会被拆成多个 partial bunch,用标志位标记:
bPartialInitial(第一片)、bPartial(中间片)、bPartialFinal(末片)。接收端按序拼回完整 Bunch。 MaxPacket:单包上限,默认约 1024 字节量级(UNetConnection::MaxPacket),贴近网络 MTU 避免 IP 层分片。
一句话:Bunch 讲「我要发什么」,Packet 讲「怎么塞进网线」。
3. 可靠性:序列号 + ACK
UE 有两层序列号:
- Packet 级:每个 Packet 一个递增序列号。接收端回 ACK/NAK,发送端据此判断丢包。
- Bunch 级:可靠 Bunch 带
bReliable+ChSequence(通道内序号)。
可靠与不可靠:
| 类型 | 丢了怎么办 | 用途 |
|---|---|---|
可靠 Bunch(bReliable) | 进重发队列,收到 ACK 前反复重发,保证到达且有序 | 初始复制、可靠 RPC、通道打开关闭 |
| 不可靠 Bunch | 丢了就丢,不重发 | 高频属性(位置)、不可靠 RPC |
Reliable buffer overflow 断线
可靠 Bunch 有重发队列上限(RELIABLE_BUFFER,默认 256 量级)。如果可靠消息产生速度长期快于对端 ACK 速度(比如疯狂发可靠 RPC),队列塞满 → 连接直接断开。这是「可靠 RPC 洪泛会踢人」的底层原因,详见 RPC。
4. 握手与安全
新连接不是直接就能发游戏数据的,要先过 Control Channel 的握手:
客户端 服务器
│── (UDP包) ──────────────────▶│ PacketHandler 层:StatelessConnectHandler
│ │ challenge-response(防伪造源 IP / SYN 洪泛)
│◀───────── challenge ─────────│
│── response ─────────────────▶│ 验证通过才分配 UNetConnection
│── NMT_Hello (版本号) ────────▶│ 校验引擎/网络版本
│── NMT_Login (URL/OnlineID) ──▶│ → GameMode::PreLogin / Login
│◀───────── NMT_Welcome ───────│ 下发首个地图
│── NMT_Join ─────────────────▶│ → PostLogin,开始复制 Actor
PacketHandler/StatelessConnectHandler:连接建立前的一层,用无状态 challenge-response 确认对端 IP 真实可回包,防伪造源 IP 的放大攻击(服务器不为未验证的包分配状态)。- Control Channel 消息:
NMT_Hello(版本协商)→NMT_Login(带登录 URL 和在线 ID)→NMT_Welcome→NMT_Join。每一步失败都有对应断线原因。完整连接时序见 专用服务器 · Session · Travel。 DDoSDetection:UNetDriver内置,按包速率阈值检测异常连接并限流/丢弃,防单连接刷包打爆服务器。
5. 饱和(Saturation)与饥饿
每个 UNetConnection 有每秒带宽配额(MaxClientRate,默认约 15000 B/s 量级,可协商)。每帧 TickFlush 时:
- 引擎按
NetPriority给相关 Actor 排序,优先级高的先发。 - 逐个 Actor 打包 Bunch,累加已用字节。
- 配额用尽 →
IsNetReady()返回 false → 本帧停止发送,剩下的 Actor 等下一帧。
饥饿现象:如果高优先级 Actor 持续占满配额,低优先级 Actor 可能长期轮不到,表现为「远处的东西半天不更新」。缓解靠调 NetPriority、NetUpdateFrequency、Dormancy 减少参与竞争的 Actor 数,详见 Relevancy 与带宽预算。
这本质上是流控——和 TCP 的拥塞/流控、KCP 的带宽换延迟 是同层问题,只是 UE 把它做成了「按 Actor 优先级分配固定配额」。
6. 一句话记住这几组容易混的概念
| 一组 | 差别一句话 | 类比 |
|---|---|---|
| Bunch vs Packet | Bunch 是「发什么」的逻辑消息,Packet 是「怎么上路」的 UDP 报文;一辆车可拼多个包裹,大包裹也可拆几辆车 | 包裹 vs 货车 |
| Reliable vs Unreliable | 可靠的丢了必重发、堵住会堆仓库直到断线;不可靠的丢了就算,绝不拖累别人 | 挂号信 vs 平信 |
| 饱和 vs 饥饿 | 饱和是「这条连接本帧配额用完了」;饥饿是「某个 Actor 因为优先级低长期抢不到配额」 | 车厢装满 vs 你的货永远排在队尾 |
| UE 可靠层 vs KCP | 同一个命题(UDP 上做可靠),UE 按 Bunch 粒度逐条选要不要可靠;KCP 是整条流统一可靠 | 一个包裹一个包裹挑寄送方式 vs 整批统一挂号 |
一条主线串起来:包裹(Bunch)按可靠性分类装车(Packet),车有载重上限(带宽配额),载重不够时按优先级排队,排不上的下一趟——必达包裹排不上就只能堆着,堆爆了断线。
为什么这么做
选 UDP + 自建可靠层:游戏要的是「部分数据要可靠有序(登录、扣血 RPC)、部分数据丢了无所谓且不能等(每帧位置)」。TCP 是「全部可靠有序」,一个位置包丢了会队头阻塞后面所有数据——这对实时游戏是灾难。UE 在 UDP 上按 Bunch 粒度选择可靠性,正是为了让不可靠的高频数据不被可靠数据拖累。这与 KCP/QUIC 的动机完全一致。
Bunch/Packet 分离:逻辑消息(Bunch)和物理传输(Packet)解耦,才能做 bunch merging(省包头)和分片(发大消息),同时让上层只管「发什么」不管「怎么塞进 MTU」。
带宽配额 + 优先级:带宽是硬约束(尤其服务器上行 = 每客户端下行 × 客户端数),必须有流控。按优先级分配让「重要的先到」,是有限带宽下的最优策略。
为什么别的选择不行
- 直接用 TCP:队头阻塞——一个丢包阻塞所有后续数据,实时性崩溃;且无法对不同数据选择不同可靠性。详见 KCP 与 QUIC。
- 全部 Bunch 都可靠:高频位置包也重发,带宽爆炸且延迟不可控(旧位置重发到了也没用)。
- 不做带宽配额:一个热闹场景瞬间产生海量更新,直接打爆客户端下行,还不如按优先级丢弃低价值更新。
- 不做握手 challenge:伪造源 IP 就能让服务器为大量假连接分配状态,被放大攻击打垮。
沉淀结论
UE = UDP + 自建 ARQ。Bunch 逻辑消息 / Packet 物理包,序列号+ACK 做可靠,bReliable 按需选可靠性。可靠队列满会断线(RPC 洪泛坑)。带宽配额 + 优先级做流控,配额耗尽即饱和、低优先级会饥饿。
记忆口诀
对象链:NetDriver → NetConnection(每客户端)→ Channel(Control/Actor/Voice)→ Bunch → Packet
Bunch vs Packet:Bunch 逻辑消息 / Packet 物理 UDP 包 / 合并省包头 / 超 MTU 分片 partial
可靠:Packet 序列号 + ACK / Bunch 级 bReliable / 可靠队列满 → 断线
流控:MaxClientRate 配额 / NetPriority 排序 / 配额耗尽饱和 / 低优先级饥饿
内容来源
综合整理。主要参考:Epic 官方文档 Networking Architecture、Data Transfer 、UE 源码 NetConnection.cpp/DataChannel.cpp/NetDriver.cpp/PacketHandler,社区分析 Alex Forsythe《How Unreal's Networking Works》。数字为引擎默认值或业界数量级估算。
相关专题:UDP 可靠传输原理与 KCP/QUIC 对比见 KCP 与 QUIC;TCP 队头阻塞与流控见 TCP 网络;底层 I/O 模型(epoll)见 LVS 与 epoll;建在这层之上的属性同步见 属性复制。
自测:合上资料能说清楚吗?
UE 底层用 TCP 还是 UDP?可靠性怎么保证?
参考答案
默认用 UDP,在应用层自建一套可靠 ARQ:Packet 级序列号 + ACK/NAK 判断丢包,Bunch 级 bReliable 标记哪些消息需要可靠(进重发队列直到收到 ACK),不可靠的丢了就丢。好处是能按数据粒度选择可靠性——登录/扣血用可靠、每帧位置用不可靠,避免 TCP 那种一个丢包阻塞全部的队头阻塞。这和 KCP 是同一命题。
Bunch 和 Packet 是什么关系?
参考答案
Bunch 是逻辑消息(一次属性更新或一个 RPC),Packet 是物理 UDP 报文。为省包头,多个小 Bunch 会合并(merging)进一个 Packet;单个 Bunch 超过 Packet 容量则拆成 partial bunch(bPartialInitial/bPartial/bPartialFinal)分片发送,接收端拼回。MaxPacket 默认约 1024 字节贴近 MTU。
为什么疯狂发可靠 RPC 会导致客户端断线?
参考答案
可靠 Bunch 进重发队列,队列有上限(RELIABLE_BUFFER,256 量级)。若可靠消息产生速度长期快于对端 ACK 速度,队列塞满,连接直接断开(Reliable buffer overflow)。所以高频事件不该用可靠 RPC,更不该用 RPC 传状态。
什么是复制饱和(saturation)和饥饿?
参考答案
每个连接有每秒带宽配额(MaxClientRate)。每帧发包时按 NetPriority 排序逐个 Actor 打包,配额用尽(IsNetReady 为 false)就停止发送、剩余 Actor 等下一帧——这是饱和。若高优先级 Actor 持续占满配额,低优先级 Actor 长期轮不到就是饥饿,表现为远处物体半天不更新。靠调优先级、更新频率、Dormancy 缓解。