一句话:网络负载就是网络上"正在跑的数据量"占了多少能力。负载低,数据畅通无阻;负载接近上限,数据开始排队,于是延迟升高、抖动变大,最后直接丢包。
前几篇讲了网关(数据从哪出去)、子网(谁和谁是邻居)、抖动缓冲(怎么扛波动)、CDN(怎么把大文件分散出去)。这一篇讲它们背后共同的问题:网络的能力是有限的,用多了会怎样,以及怎么少用。
一、生活里的例子:高速公路
一条三车道高速公路:
| 路况 | 车流 | 体验 |
|---|---|---|
| 凌晨 | 车很少 | 一路 120 码 |
| 平时 | 车不少,但能跑 | 偶尔要减速 |
| 节假日 | 接近路的极限 | 走走停停,时快时慢 |
| 出了事故 | 超过极限 | 堵死,后面的车根本上不了高速 |
| 高速公路 | 网络 |
|---|---|
| 车道数 | 带宽:最多能跑多少数据 |
| 实际车流 | 吞吐量:实际跑了多少 |
| 车流 ÷ 能力 | 负载率 / 利用率 |
| 收费站排队 | 路由器、网卡里的队列 |
| 走走停停 | 延迟升高、抖动变大 |
| 匝道封闭,车上不去 | 丢包 |
关键:路堵不堵,不只看车多不多,要看"车流 ÷ 路的能力"。同样 1000 辆车,在单车道是灾难,在八车道很轻松。
二、几个必须分清的概念
| 概念 | 意思 | 单位 |
|---|---|---|
| 带宽(Bandwidth) | 链路的最大能力 | Mbps、Gbps |
| 吞吐量(Throughput) | 实际传了多少 | Mbps |
| 利用率(Utilization) | 吞吐量 ÷ 带宽 | % |
| 包速率(PPS) | 每秒处理多少个包 | 包/秒 |
| 延迟 / 抖动 / 丢包 | 负载高的后果 | ms、ms、% |
单位换算:最常见的误会
运营商说的 100M 宽带 = 100 Mbps(兆比特每秒) 下载软件显示的速度 = MB/s(兆字节每秒) 1 字节 = 8 比特 → 100 Mbps ÷ 8 ≈ 12.5 MB/s所以"办了 100M 宽带,下载只有 12MB/s"是正常的,不是被骗。
带宽和 PPS 是两种不同的上限
| 情况 | 先撞到哪个上限 |
|---|---|
| 下载大文件(每个包都接近 1500 字节) | 带宽 |
| 游戏同步(每个包只有几十到几百字节,但非常多) | PPS |
游戏服务器经常是带宽还很空,PPS 已经满了。每个包无论大小,都要经过一次中断、一次系统调用(前面讲过的recvfrom/sendto)、一次路由查表 ——处理开销是按"个"算的。很多云服务器也会对每台机器的 PPS 设上限。
三、负载高了为什么会变慢:排队
网络设备里,每个出口都有一个队列。包到得比发得快,就要排队。
一个反直觉的事实:利用率不是"越接近 100% 越好"
用最简单的排队模型估算,平均等待时间大约是:
平均耗时 ≈ 处理一个包的时间 ÷ (1 − 利用率)| 利用率 | 相对于空闲时的耗时 |
|---|---|
| 10% | 1.1 倍 |
| 50% | 2 倍 |
| 80% | 5 倍 |
| 90% | 10 倍 |
| 95% | 20 倍 |
| 99% | 100 倍 |
延迟 │ ╱ │ ╱ │ ╱ │ ╱ │ __╱ │____________________───── └──────────────────────────────────── 利用率 0% 50% 80% 100% ↑ 过了这里,延迟开始飙升这就是为什么运维常说"链路利用率长期超过 70%~80% 就该扩容了"。不是因为满了,而是因为还没满,体验就已经崩了。
负载升高的完整过程
负载低 → 包几乎不用排队 延迟稳定 负载升高 → 队列时长时短 **抖动变大**(抖动缓冲区要变大) 接近上限 → 队列一直很长 **延迟持续升高** 超过上限 → 队列满了,新包被直接扔掉 **丢包**上一篇提到的**缓冲膨胀(Bufferbloat)**就发生在第三步:路由器的队列设得太大,宁可让包排几百毫秒也不丢,结果"能用但巨卡"。
四、负载从哪来:家里、路上、服务器
一个玩家打游戏,数据要经过好几段路,任何一段满了都会卡:
玩家电脑 ─ Wi-Fi ─ 家用网关 ─ 运营商接入 ─ 骨干网 ─ 机房出口 ─ 服务器网卡 ─ 游戏进程 ① ② ③ ④ ⑤ ⑥ ⑦ ⑧| 位置 | 典型的负载问题 |
|---|---|
| ① 玩家电脑 | 后台在下载、同步网盘、更新系统 |
| ② Wi-Fi | 信号差、邻居信道干扰、几十台设备抢一个路由器 |
| ③ 家用网关 | 上行带宽小(很多宽带下行 300M,上行只有 30M);有人在直播、上传视频 |
| ④⑤ 运营商 / 骨干 | 晚高峰拥塞、跨运营商互联口拥塞 |
| ⑥ 机房出口 | 开服、版本更新、遭遇 DDoS |
| ⑦ 服务器网卡 | 带宽或 PPS 上限 |
| ⑧ 游戏进程 | 处理不过来,接收缓冲区堆满,内核开始丢包 |
第 ⑧ 点容易被忽略:网络没满,是程序处理不过来。表现和网络拥塞一样(延迟高、丢包),但要优化的是代码,不是带宽。
五、动手查看网络负载
客户端 / 个人电脑
| 系统 | 工具 | 看什么 |
|---|---|---|
| Windows | 任务管理器 → 性能 → Wi-Fi/以太网 | 实时收发速度 |
| Windows | 资源监视器 → 网络 | 哪个进程在占带宽 |
| macOS | 活动监视器 → 网络 | 每个进程的流量 |
| 通用 | ping -t 服务器(边下载边 ping) | 负载升高时延迟有没有飙升 |
一个简单的"缓冲膨胀"自测:
1. 不开任何下载,ping 游戏服务器 → 比如 30ms 2. 开一个大文件下载,同时继续 ping → 如果涨到 200ms+,就是排队严重Linux 服务器
nload# 实时看网卡收发带宽iftop# 看哪些连接在占流量sar-nDEV1# 每秒的收发字节数、包数(PPS)ss-s# 连接数统计netstat-su# UDP 统计:留意 "receive buffer errors"(程序来不及收)iperf3-c服务器IP# 测两台机器之间的实际带宽
netstat -su里的receive buffer errors一直在涨,说明内核替你丢包了:网络把包送到了,你的进程没来得及读。这时调大接收缓冲区(SO_RCVBUF)或优化处理逻辑,比加带宽有用。
Unity 里
| 工具 | 用途 |
|---|---|
| Profiler → Network Messages / Network Operations | 看每帧收发多少消息、多少字节 |
| Netcode for GameObjects 的Network Profiler / Runtime Network Stats Monitor | 按对象、按消息类型统计流量 |
| Wireshark | 抓包,看每个包到底有多大、多少个 |
六、算一笔账:射击游戏到底要多少带宽
假设一局64 人的射击游戏,服务器30Hz(每秒 30 次快照)。
每个玩家的状态(朴素写法)
| 字段 | 类型 | 字节 |
|---|---|---|
| 玩家 ID | int | 4 |
| 位置 xyz | 3 × float | 12 |
| 朝向 | 4 × float(四元数) | 16 |
| 速度 xyz | 3 × float | 12 |
| 血量、状态 | int + int | 8 |
| 合计 | 52 字节 |
计算
每个快照: 63 个其他玩家 × 52 字节 ≈ 3276 字节 + IP/UDP 头部 28 字节 ≈ 3.3 KB ← 已经超过一个包大约 1200~1400 字节的安全上限,要拆成 3 个包 每个玩家下行:3.3 KB × 30 次/秒 ≈ 99 KB/s ≈ 790 Kbps 一局服务器出口:× 64 人 ≈ 6.3 MB/s ≈ 50 Mbps 一台机器跑 50 局:≈ 2.5 Gbps ← 普通千兆网卡直接打满 PPS:64 人 × 30 次 × 3 个包 × 50 局 ≈ 29 万包/秒这还没算子弹、技能、特效事件、语音。所以带宽优化不是"锦上添花",而是决定一台服务器能开多少局、服务器账单是多少。
优化之后(第七节的技巧组合)
| 手段 | 每个玩家的数据 |
|---|---|
| 朴素写法 | 52 字节 |
| 量化 + 位压缩 | ≈ 15 字节 |
| 只发变化的字段(增量) | 平均 ≈ 6 字节 |
| 只发附近 / 视野内的 20 人 | 63 人 → 20 人 |
优化后每个快照:20 × 6 + 28 ≈ 150 字节 → 一个包就够 每个玩家下行:150 × 30 ≈ 4.5 KB/s ≈ 36 Kbps从 790 Kbps 降到 36 Kbps,约二十分之一。同一台服务器可以多开很多局,玩家在差网络下也更稳。
七、降低负载的六个核心技巧
1. 量化:别用 float 存不需要那么精确的东西
地图范围 ±1024 米,精度 1 厘米就够:
可能的取值:2048 ÷ 0.01 = 204,800 种 2^18 = 262,144 ≥ 204,800 → 每个轴 18 位就够 位置:3 × 32 位 = 96 位 → 3 × 18 位 = 54 位| 数据 | 朴素 | 量化后 | 说明 |
|---|---|---|---|
| 位置 | 96 位 | 54 位 | 1cm 精度 |
| 只有水平朝向(yaw) | 32 位 | 10 位 | 约 0.35° 精度 |
| 完整旋转 | 128 位 | 约 30 位 | "最小三分量"压缩四元数 |
| 血量 0~100 | 32 位 | 7 位 | |
| 布尔状态(蹲下、开火…) | 各 8~32 位 | 各 1 位 |
publicstaticclassQuantize{// 把 [min, max] 范围的 float 压到 bits 位整数publicstaticuintEncode(floatv,floatmin,floatmax,intbits){uintmaxValue=(1u<<bits)-1;floatt=UnityEngine.Mathf.InverseLerp(min,max,v);// 自动夹到 0~1return(uint)UnityEngine.Mathf.RoundToInt(t*maxValue);}publicstaticfloatDecode(uintq,floatmin,floatmax,intbits){uintmaxValue=(1u<<bits)-1;returnUnityEngine.Mathf.Lerp(min,max,q/(float)maxValue);}}2. 位压缩:按"位"写,不按"字节"写
量化之后是 18 位、7 位、1 位这种零碎的长度,用字节存会浪费。写一个简单的位写入器:
publicclassBitWriter{readonlybyte[]buffer;intbitPos;publicBitWriter(intcapacity)=>buffer=newbyte[capacity];publicvoidWrite(uintvalue,intbits){for(inti=0;i<bits;i++){if(((value>>i)&1)!=0)buffer[bitPos>>3]|=(byte)(1<<(bitPos&7));bitPos++;}}publicvoidWriteBool(boolb)=>Write(b?1u:0u,1);publicintByteCount=>(bitPos+7)>>3;// 向上取整到字节publicbyte[]Buffer=>buffer;}使用:
varw=newBitWriter(1200);w.Write(Quantize.Encode(pos.x,-1024,1024,18),18);w.Write(Quantize.Encode(pos.y,-64,192,15),15);// 高度范围小,位数更少w.Write(Quantize.Encode(pos.z,-1024,1024,18),18);w.Write(Quantize.Encode(yaw,0,360,10),10);w.Write((uint)health,7);w.WriteBool(isCrouching);// 合计 69 位 ≈ 9 字节,原来是 52 字节这里的移位和按位与,和上一篇子网掩码的计算是同一套东西:
>> 3是除以 8,& 7是对 8 取余。
3. 增量压缩:只发变了的
站着不动的玩家,位置每帧都一样,没必要重发。
每个字段前加 1 位"变了吗": 0 → 没变,后面不带数据 1 + 新值 → 变了 更进一步:以"客户端已确认收到的那一帧"为基准做差| 情况 | 位置字段 |
|---|---|
| 站着不动 | 1 位 |
| 在走路 | 1 位 + 54 位 |
基准必须是对方确认收到过的帧,否则丢一个包,后面的增量全都对不上。
4. 兴趣管理(AOI):只发你需要知道的
地图另一头的敌人,你看不见、听不见、打不着,就不用每帧告诉你。
| 距离 / 状态 | 发送频率 |
|---|---|
| 视野内、50 米内 | 每个快照都发(30Hz) |
| 50~200 米 | 每 3 个快照发一次(10Hz) |
| 200 米外 | 每秒 2 次,只发位置 |
| 墙后面、完全看不见 | 不发(顺便防透视外挂) |
第一篇讲遮挡剔除时说"看不见的不画";这里是"看不见的不发"。思路完全一样,甚至可以复用同样的空间划分和可见性数据。
5. 带宽预算 + 优先级:总量固定,重要的先发
给每个客户端每个快照一个固定预算(比如 1200 字节),按优先级装包,装不下的留到下一次 —— 但它的优先级会一直累积,不会被永远饿死。
usingSystem.Collections.Generic;publicclassPrioritySender{classEntry{publicintentityId;publicfloataccumulated;}readonlyList<Entry>entries=newList<Entry>();publicvoidAdd(intid)=>entries.Add(newEntry{entityId=id});// basePriority:根据距离、是否在视野内、是否在开火等计算publicList<int>Pick(System.Func<int,float>basePriority,System.Func<int,int>sizeOf,intbudgetBytes){foreach(vareinentries)e.accumulated+=basePriority(e.entityId);entries.Sort((a,b)=>b.accumulated.CompareTo(a.accumulated));varpicked=newList<int>();intused=0;foreach(vareinentries){intsize=sizeOf(e.entityId);if(used+size>budgetBytes)continue;// 这次放不下,下次优先级更高used+=size;picked.Add(e.entityId);e.accumulated=0;// 发了就清零}returnpicked;}}| 实体 | 基础优先级 |
|---|---|
| 正在朝你开火的敌人 | 很高 |
| 附近的队友 | 高 |
| 远处走动的敌人 | 中 |
| 地上掉落的弹药箱 | 低 |
6. 合并小包、控制发送频率
| 做法 | 原因 |
|---|---|
| 一个快照的所有内容合进一个包 | 每个包都有 28 字节头部,还占一次 PPS |
| 包大小控制在约 1200 字节以内 | 超过路径 MTU(常见 1500,各种隧道下更小)会被分片,丢一片整个包作废 |
| 客户端输入可以每帧带上最近几帧的输入 | 多几个字节,换来丢一个包也不丢操作(冗余,用少量带宽换可靠性) |
| tick 率按需选择 | 竞技射击 30~64Hz,休闲游戏 10~20Hz 往往足够 |
八、网络已经拥塞时怎么办:拥塞控制
TCP 自带"刹车"
TCP 发现丢包或延迟升高,会自动降低发送速度,等路况好了再慢慢提速。所以下载大文件时,网络不会被一个连接彻底堵死。
UDP 没有刹车,游戏要自己做
射击游戏大多用 UDP。如果路已经堵了还照常狂发,只会让队列更长、丢包更多,自己和别人都更卡。
自适应发送的简单思路:
| 观察到的信号 | 反应 |
|---|---|
| RTT 持续升高、抖动变大 | 降低非关键数据的频率(远处实体、装饰性事件) |
| 丢包率超过阈值(如 5%) | 降低快照频率,比如 30Hz → 20Hz |
| 一段时间都很稳定 | 慢慢恢复 |
| 极差 | 提示"网络不佳",只保留最核心的数据 |
这和抖动缓冲区里"缓冲区根据抖动自动调整"是一个思路:持续测量,动态适应,变化要平滑。
客户端能做的
| 措施 | 效果 |
|---|---|
| 游戏中自动暂停后台的资源下载 | 不和自己的战斗流量抢上行 |
| 热更新下载限速 | 边玩边下时不影响对局 |
| 语音降低码率 | 网络差时优先保证操作同步 |
九、服务器扛不住时:把负载分出去
单台机器的带宽、PPS、CPU 都有上限。超过之后,就要把负载分摊到多台机器。
| 手段 | 分摊的是什么 | 前面讲过的关联 |
|---|---|---|
| CDN | 安装包、补丁、热更资源 | 上一篇:静态大流量完全不经过游戏服务器 |
| 负载均衡器 | 登录、商城、匹配等 HTTP / TCP 请求 | 把请求轮流或按负载分给多台后端 |
| 按对局分配服务器 | 实时战斗 | 一局只在一台机器上,新开的局放到最空闲的机器 |
| 多地区部署 | 跨地区的长距离流量 | 就近接入,减少骨干网负载和延迟 |
| 抗 DDoS / 清洗 | 恶意流量 | 攻击的本质就是人为制造网络负载 |
┌─ 静态资源 ─→ CDN 玩家 ─────────┼─ 登录/商城 ─→ 负载均衡器 ─→ 多台 Web 服务器 └─ 实时战斗 ─→ 匹配服务分配 ─→ 某台战斗服务器(某一局)这又回到"架构原则":不同类型的负载走不同的路,各自独立扩容,一边出问题不拖垮另一边。
十、和前面几篇的联系
| 前面讲过的 | 关系 |
|---|---|
| 网关 | 家用网关的上行带宽往往是瓶颈;开启 QoS / SQM 可以让游戏包优先、减少排队 |
| 子网掩码 | 网段越大,广播越多,局域网本身的负载就越高 —— 划分子网能减少广播负载 |
| 抖动缓冲区 | 负载升高 → 排队变化 → 抖动变大 → 缓冲区被迫变大 → 延迟升高 |
| CDN | 把最大的那部分流量(下载)从源站卸掉 |
| 遮挡剔除 | "看不见的不画"与兴趣管理"看不见的不发"同一个思路 |
| 系统调用 | 每个包一次sendto/recvfrom;PPS 高时,可以用批量收发(如 Linux 的sendmmsg/recvmmsg)减少系统调用次数 |
| 对象池 | 每个包都new byte[]会产生大量 GC,收发缓冲区应复用 |
十一、常见的坑
| 坑 | 现象 | 解决 |
|---|---|---|
| 把 Mbps 当 MB/s | 带宽规划差了 8 倍 | 记住:除以 8 |
| 只看带宽不看 PPS | 带宽很空,服务器却在丢包 | 同时监控 PPS;合并小包 |
| 利用率跑到 90% 才扩容 | 还没满,延迟已经飙升 | 70%~80% 就开始规划扩容 |
| 位置、旋转全用 float 原样发 | 带宽是需要的好几倍 | 量化 + 位压缩 |
| 全图所有实体发给所有人 | 人一多带宽平方级增长(n 人 × n 人) | 兴趣管理 + 优先级 |
| 增量压缩的基准没被确认 | 丢包后客户端状态全乱 | 以客户端 ACK 过的帧为基准 |
| 包太大被分片 | 丢包率莫名其妙偏高 | 控制在约 1200 字节以内 |
| UDP 拥塞时照常狂发 | 越卡越发,越发越卡 | 根据丢包和 RTT 自适应降频 |
| 进程处理慢误以为网络问题 | 加了带宽依然丢包 | 看netstat -su的 buffer errors;优化接收线程 |
| 对局中后台下载热更新 | 玩着玩着突然延迟飙升 | 对局中暂停或限速下载 |
| 只在局域网测试 | 本地流畅,上线就卡 | 用网络模拟工具限制带宽、加丢包测试 |
| 没算服务器出口账单 | 上线后流量费超预算 | 开发阶段就按"每人每秒多少字节"做预算 |
一句话总结
网络负载 = 实际流量 ÷ 网络能力。负载升高时,数据先排队(抖动变大、延迟升高),再丢包;而且延迟不是线性增长,利用率过了 70%~80% 就会急剧变差。
衡量负载要同时看带宽和