☰
抖动缓冲区(Jitter Buffer):用一点点延迟,换来稳定流畅
2026/10/17 1:42:55 网站建设 项目流程

一句话:网络包到达的时间忽快忽慢(这叫"抖动"),抖动缓冲区先把包攒一小会儿,再按固定节奏取出来播放或显示,用一点点额外延迟换来画面和声音的平稳。


一、生活里的例子:蓄水池

自来水管的水压忽大忽小,但你家洗澡的花洒需要稳定的水流。

办法:在中间放一个水桶。

进水(忽大忽小) → [ 水桶 ] → 出水(稳定)
水桶的大小效果
太小进水一断,桶马上空了,花洒断流
太大要等很久才装满,打开开关半天才出水
刚好偶尔断水也能撑住,开水也不用等太久
蓄水池网络
忽大忽小的进水时快时慢到达的网络包
水桶抖动缓冲区
稳定的出水按固定节奏播放的声音 / 显示的画面
桶空了断流缓冲区欠载:卡顿、声音断续
等桶装满额外的延迟

二、什么是"抖动"?

先分清三个经常混在一起的概念:

概念意思例子
延迟(Latency)一个包从发出到收到要多久平均 40ms
抖动(Jitter)延迟变化有多大这个包 30ms,下个包 80ms
丢包(Packet Loss)包根本没到100 个包丢了 2 个

延迟稳定在 100ms,比延迟在 20~150ms 之间乱跳更容易处理。前者只是"慢",后者是"乱"。

看一个具体的例子

服务器每20ms发一个包:

包编号发送时间路上花了到达时间
0030ms30
12050ms70
24025ms65(比 1 号先到!)
36045ms105
48030ms110

发的时候是整齐的 20ms 一个,收的时候间隔变成了:35、-5、40、5……还乱了序。

如果收到一个就马上用一个:

  • 声音:一会儿挤在一起、一会儿断掉,咔咔响
  • 游戏画面:敌人一顿一顿地瞬移

三、抖动缓冲区怎么解决

核心思路:不按"到达时间"播放,而按"发送时间 + 固定延迟"播放。

上面的例子,设缓冲延迟 =60ms,第 n 个包在发送时间 + 60播放:

包编号到达时间计划播放时间来得及吗?
03060✅ 等 30ms
17080✅ 等 10ms
265100✅ 等 35ms(乱序也不怕,按编号排好)
3105120✅ 等 15ms
4110140✅ 等 30ms

播放时间变成了 60、80、100、120、140 ——又恢复了整齐的 20ms 间隔。

到达: ──●────────●●─────────●──●──→ 乱 ↓ 进入缓冲区,按编号排序 播放: ──────●───●───●───●───●──→ 齐 └ 60ms ┘ 代价:所有东西晚 60ms 出现

抖动缓冲区做的三件事:

事情说明
排序按编号/时间戳重新排好,解决乱序
等待攒够一定的量再开始取
匀速取出按固定节奏交给播放器或渲染

四、最关键的取舍:缓冲多大?

缓冲太小缓冲太大
包晚到一点就来不及 →欠载(Underrun)所有东西都晚很久才出现
声音断续、角色卡顿、只能靠猜语音对话时两人抢话,射击游戏手感"肉"

一个简单的经验公式:

缓冲延迟 ≈ 发送间隔 + k × 抖动 (k 通常取 2~4)

k 越大,越能扛住偶尔的大波动,但延迟越高。

应用能接受的缓冲原因
在线看视频几秒到几十秒不需要互动,晚点播没关系
语音通话20~200ms太大就会互相抢话
竞技射击游戏尽量小,通常 2~3 个快照间隔每一毫秒都影响手感

看视频时的"缓冲中……",就是抖动缓冲区欠载了,正在重新蓄水。


五、固定缓冲 vs 自适应缓冲

固定缓冲

永远等同样长的时间。简单,但网络好时浪费延迟,网络差时又不够用。

自适应缓冲(实际产品几乎都用这种)

一边收包,一边估算当前抖动多大,动态调整缓冲长度。

抖动的估算方法来自 RTP 协议标准(RFC 3550),非常简单:

D = 这次包的路上时间 − 上次包的路上时间 J = J + (|D| − J) / 16

每来一个包更新一次,/16让结果平滑,不会被单个异常值带偏。

这里有个巧妙之处:发送时间是对方的时钟,接收时间是自己的时钟,两台机器的时钟不一样。但算的是两次的差值,时钟偏差会被互相抵消,所以不需要两边对时。

调整时不能"跳"

缓冲区要从 60ms 缩到 40ms,不能一下子丢掉 20ms 的内容(声音会"咔"一下,角色会瞬移)。常用做法是悄悄改变播放速度:

缓冲区里的量播放速度效果
比目标多很多1.05 倍慢慢追上,减少延迟
接近目标1.0 倍正常
比目标少很多0.95 倍慢慢放,攒一点缓冲

语音里对应的技术叫变速不变调(如 WebRTC 的 NetEQ 会对语音做轻微拉伸或压缩),玩家几乎听不出来。


六、包丢了、或者晚到太久怎么办?

到了播放时间,包还没来:

场景处理方式
语音丢包隐藏(PLC):根据前一段声音"编"一小段相似的声音补上
视频重复上一帧,或请求关键帧
游戏位置同步外推(Extrapolation):按最后的速度继续往前猜一小段

之后那个包才姗姗来迟 →直接丢掉。已经过了它的时间,放了反而更乱。


七、在射击游戏里的两种抖动缓冲区

1. 客户端:快照插值缓冲(最常见)

服务器每秒发 20 次快照(每 50ms 一次),告诉你"敌人在哪"。客户端故意显示过去一点的时间,在两个快照之间平滑插值:

服务器快照: S1 S2 S3 S4 时间轴: ───────────┼─────────┼─────────┼─────────┼───→ 0ms 50ms 100ms 150ms ▲ ▲ 渲染的时刻 现在收到的最新时刻 (落后 100ms) 在 S2 和 S3 之间插值显示敌人位置

为什么要落后2 个快照间隔(100ms)?这样丢一个快照也还有下一个可用,依然能插值,不必去猜。

经典的 Source 引擎(CS 系列)默认插值延迟就是 0.1 秒左右,原理正是这个。

2. 服务器端:输入缓冲

客户端每帧把"按了什么键"发给服务器。如果输入到晚了,服务器这一 tick 就没有输入可用,只能猜(比如沿用上一帧的输入),结果就是"我明明按了,角色却没反应"。

所以服务器会为每个玩家攒 1~2 个 tick 的输入再执行。网络好的玩家缓冲小,网络抖的玩家缓冲大,有的游戏还会让客户端稍微加快或放慢发送节奏来调整这个缓冲,原理和第五节的"变速"一样。


八、动手:Unity 里的简易快照缓冲

第 1 步:估算抖动

usingSystem;publicclassJitterEstimator{doublelastTransit;boolhasLast;publicdoubleJitter{get;privateset;}// 单位:秒// sendTime:包里带的服务器时间;recvTime:本地收到的时间publicvoidOnPacket(doublesendTime,doublerecvTime){doubletransit=recvTime-sendTime;// 两台机器时钟不同也没关系if(hasLast){doubled=Math.Abs(transit-lastTransit);Jitter+=(d-Jitter)/16.0;// RFC 3550 的平滑公式}lastTransit=transit;hasLast=true;}}

第 2 步:缓冲 + 插值

usingSystem.Collections.Generic;usingUnityEngine;publicstructSnapshot{publicdoubletime;// 服务器时间publicVector3position;}publicclassSnapshotBuffer{readonlyList<Snapshot>buffer=newList<Snapshot>();constintMaxSize=32;publicvoidAdd(Snapshots){// 太旧的(早于已有最早的)直接丢;按时间插入,自动处理乱序if(buffer.Count>0&&s.time<=buffer[0].time)return;inti=buffer.Count;while(i>0&&buffer[i-1].time>s.time)i--;if(i>0&&buffer[i-1].time==s.time)return;// 重复包buffer.Insert(i,s);if(buffer.Count>MaxSize)buffer.RemoveAt(0);}// renderTime = 估算的服务器时间 − 缓冲延迟publicboolSample(doublerenderTime,outVector3pos){pos=default;if(buffer.Count==0)returnfalse;// 找到 renderTime 两侧的快照for(inti=0;i<buffer.Count-1;i++){Snapshota=buffer[i],b=buffer[i+1];if(renderTime>=a.time&&renderTime<=b.time){floatt=(float)((renderTime-a.time)/(b.time-a.time));pos=Vector3.Lerp(a.position,b.position,t);// 已经用不到的旧快照可以清掉(保留 a)if(i>0)buffer.RemoveRange(0,i);returntrue;}}// 走到这里:要么太早(缓冲还没攒够),要么欠载(新包没来)pos=renderTime<buffer[0].time?buffer[0].position:buffer[buffer.Count-1].position;// 简化:停在最后位置,实际项目可做有限外推returnfalse;}}

第 3 步:自适应地决定缓冲延迟

publicclassRemotePlayer:MonoBehaviour{publicfloatsendInterval=0.05f;// 服务器 20HzreadonlySnapshotBufferbuffer=newSnapshotBuffer();readonlyJitterEstimatorjitter=newJitterEstimator();doubleServerTimeNow;// 由时钟同步模块提供(另一个话题)publicvoidOnSnapshotReceived(Snapshots){jitter.OnPacket(s.time,Time.timeAsDouble);buffer.Add(s);}voidUpdate(){// 缓冲延迟 = 发送间隔 × 2 + 3 倍抖动,并限制在合理范围doubledelay=sendInterval*2+3*jitter.Jitter;delay=System.Math.Clamp(delay,sendInterval,0.3);if(buffer.Sample(ServerTimeNow-delay,outVector3pos))transform.position=pos;}}

这只是讲原理的最小版本。实际项目中Mirror、Netcode for GameObjects、Fish-Net等网络库都内置了类似的插值缓冲,通常只需要调几个参数;但懂原理之后,才知道"敌人一顿一顿"时该调哪个。


九、和前面几篇的联系

前面讲过的关系
网关 / Wi-Fi抖动主要产生在这里:Wi-Fi 干扰、路由器排队、运营商拥塞
缓冲膨胀(Bufferbloat)⚠️ 名字像但不一样:那是路由器里缓冲太大造成延迟,是"病";抖动缓冲区是应用里主动设的,是"药"
系统调用每收一个包都要recvfrom进一次内核;包来得乱,并不影响你按固定节奏从缓冲区里取
对象池快照结构体频繁创建,可用固定大小的环形数组代替List,避免 GC
架构原则缓冲、时钟同步、插值拆成独立模块,好测试、好调参

十、常见的坑

坑现象解决
收到就直接用,不缓冲远程玩家一顿一顿地瞬移加插值缓冲,落后 2 个快照间隔左右
缓冲设得太大被打中时自己已经躲到墙后很久了用自适应缓冲,网络好就缩小
用"到达时间"排序乱序时角色往回跳用包里的序号或服务器时间戳排序
晚到的旧包也拿去用角色闪回过去的位置早于当前渲染时间的包直接丢
调整缓冲时直接跳声音"咔"一下、角色瞬移用 0.95~1.05 倍速慢慢调
抖动估算没有平滑缓冲大小来回乱跳用/16这类平滑公式,并加上下限
外推太久网络断一下,敌人穿墙跑出去很远外推设上限(比如 200ms),超过就停住
把抖动当成延迟问题优化了服务器位置,体验还是差先测抖动;建议用网线、开启路由器 QoS
只在本机测试本地零抖动,一上线全是问题用网络模拟工具加延迟、抖动、丢包(如 Unity Transport 的模拟器、clumsy)

一句话总结

抖动 = 延迟的变化;抖动缓冲区 = 先攒一点,再按发送时的节奏匀速取出。
它做三件事:排序、等待、匀速取出;核心取舍是缓冲越大越平稳,但延迟越高。实际系统用自适应缓冲:持续估算抖动、动态调整缓冲长度,并通过轻微变速平滑过渡。
在射击游戏里,它的样子就是客户端快照插值和服务器输入缓冲。


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询