做实时音视频的人,迟早会碰到一对经常放在一起讲的概念:NACK 和 RTX。NACK 是接收方向发送方反馈“哪个包丢了”,RTX 则是发送方针对丢包进行的重传动作。音视频学习(九十四)这篇,我不想只停留在“丢包就重传”这种层面,而是把从丢包检测、RTCP 反馈、重传包格式到实际协商参数这条链路完整拆开。适合正在做 WebRTC 通话、自研 RTP 传输层,或者刚接触实时音视频但被各种 RFC 名词绕晕的开发者。
先说一个很多人容易忽略的判断:NACK 和 RTX 并不是同一层的东西,它们的配合远没有“接收端报一下、发送端发一下”那么简单。真正要理解它们,得先回答三个问题:什么时候该判断丢包?用什么报文告诉对端?收到 NACK 之后,重传的到底是“原始 RTP 包”,还是另一个经过包装的包?
1. 先搞清场景:为什么实时传输不能像 TCP 一样做重传
1.1 TCP 式重传的问题
在实时音视频里,RTP 通常跑在 UDP 上,这是为了绕开 TCP 的队头阻塞和拥塞控制带来的不确定延迟。但 UDP 没有丢包重传机制,所以在应用层,我们要自己补一套“准可靠”的传输策略。
最朴素的想法是:丢了包,就把原始包再发一次。听起来可行,实际做起来却有一个很现实的问题——实时性。一个视频通话如果端到端延迟要求控制在 200ms 以内,那么发送方收到 NACK 后,只能在剩余时间窗口内完成重传。如果RTT 已经占了大半,重传到接收端也没有意义,因为播放器等不到那个时候。
还有一个更“致命”的问题:TCP 重传不会影响应用层对字节流的顺序理解,但在 RTP 里,同一个 SSRC 的序列号是接收方判断包是否连续、是否乱序、是否重复的唯一依据。如果发送方收到 NACK 后把同一个序列号的原始包直接重发一次,表面上没问题,但接收方已经把这个序列号标记为“期待到达”,如果数据包还在网络里漂着,后面又来了一个重发副本,接收方就得做额外的去重。如果处理不好,解码器拿到重复帧,视频画面会出现跳变。
1.2 为什么需要“否定确认”而不是“肯定确认”
TCP 用 ACK 确认每一个或连续一批字节,实时音视频场景为什么不照搬?
因为实时音视频的接收端状态是“连续播放”,它并不需要像文件传输一样保证每比特都可靠到达。接收方只要知道自己播放进度附近缺了哪些包就够了。用一个 ACK 列表把所有收到的包都汇报回去,带宽消耗太大,尤其是高码率视频场景,一秒几百个 RTP 包,不可能全部确认。
NACK 的思路是:只告诉发送方“我没收到什么”。在绝大多数网络正常的通话里,丢包率很低,NACK 的报文数量远比 ACK 少。而且接收端如果发现缺口可以通过后续到达的包补齐,它甚至可以不发 NACK。这种“带有宽容度的反馈”才是实时传输需要的。
所以,NACK 是接收方对“预期收到的包缺失”的反馈,是一种否定式确认。而 RTX 是发送方收到 NACK 之后,从重传缓冲区取出对应包再发一次的执行动作。两者共同把 UDP 传输从“尽力而为”提升到“准可靠”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 丢包检测怎么做的?序列号缺口与乱序的博弈
2.1 接收侧如何判定某个 RTP 包丢了
RTP 序列号是连续的,正常情况下一个媒体 SSRC 的接收序列应该一个接一个递增。接收端每收到一个 RTP 包,会和上一个收到的序列号比较,如果发现差值大于 1,就说明中间可能有包丢了。
但这里有一个经典陷阱:序列号有跳变不代表一定丢包。网络路径上出现乱序时,后面那个序号更大的包可能先到,之前那个序号较小的包还在路上。如果接收端一看到缺口就立刻上报 NACK,发送方马上重传,结果过了一会儿原始包也到了,接收端就收到了重复副本,白白浪费一次重传带宽。
所以,几乎所有的实现都会做一个“等待乱序包”的窗口。常见做法是:发现序列号缺口后,不立刻发 NACK,而是先启动一个很短的定时器,或者等待若干毫秒,看看缺失的包能不能在乱序窗口内到达。如果等待超时仍未到达,再判定为丢失。
在 WebRTC 的开源实现和一些自研实时传输引擎里,这个等待时间不会太长,一般控制在 10ms 到 30ms 这个量级。等待太短,会把乱序误判为丢包;等待太长,即使重传回来,播放器也可能已经等不及了。
2.2 一个 NACK 能报多少丢失包
RTCP 的 NACK 报文属于 RTPFB(RTP Feedback)类型。它的格式不复杂,但非常巧妙:每个 NACK 条目由 PID 和 BLP 两个字段组成。PID 表示第一个丢失包的 RTP 序列号,BLP 是 16 bit 的位掩码,每一位代表 PID 之后的第 N 个包是否也丢了。
具体对应关系是:BLP 的第 0 位对应序列号 PID+1,第 1 位对应 PID+2,依此类推。所以一个 NACK 条目最多可以描述连续 17 个包的丢失情况。
举个例子。接收端发现序列号 100 丢失,同时 104 到 110 也丢失,101 到 103 都正常收到了。那么 PID 填写 100,BLP 从低位到高位依次表示 PID+1、PID+2……:
text复制PID = 100
序列号: 101 102 103 104 105 106 107 108 109 110
是否丢: 0 0 0 1 1 1 1 1 1 1
BLP bit:
bit0=seq101=>0
bit1=seq102=>0
bit2=seq103=>0
bit3=seq104=>1
bit4=seq105=>1
bit5=seq106=>1
bit6=seq107=>1
bit7=seq108=>1
bit8=seq109=>1
bit9=seq110=>1
其余为0
BLP = 0x03F8
如果你的音视频调试工具只显示了 PID 和 BLP,不要慌,自己按这个位掩码换算一下,就能知道对端到底报了哪些包丢失。这也是排查时最常用的手工计算之一。
2.3 发送端收到 NACK 后怎么处理
发送端也要维护一个发送缓冲区,把最近发出的 RTP 包按序列号缓存起来。注意,这个缓存不能无限大,一般只保留“最近一段时间内可能还需要重传”的包。如果 NACK 请求的包太老,已经不在发送缓冲区里,那就直接放弃,或者通过请求关键帧来恢复画面。
常见发送缓冲区保留时长会和 RTT 挂钩。比如保留 2 到 3 倍 RTT 窗口内的包,再给一个经验上限,比如 200ms 到 1000ms。WebRTC 中常用的是 1 秒左右的一个滑窗,但这并不意味着所有包都能被重传。对视频来说,如果丢失的恰好是关键帧的某个 RTP 包,那它值得重传;如果丢失的是已经过时的非参考帧,重传意义就不大。
在收到 NACK 后,发送端还需要避免“NACK 风暴”。如果网络质量突然恶化,丢包率飙升,接收方可能会在几个 RTCP 周期内连续上报大量丢失包。发送端如果对这些 NACK 全部响应,重传流量会叠加到已经拥塞的链路上,造成更严重的丢包。所以大多数实现会对 NACK 响应做节制,比如每个 RTT 最多处理一轮 NACK 反馈,或者对同一个包的 NACK 最多重传一到两次,避免无限重传。
3. RTX 重传包不是“把原包再发一遍”:RFC 4588 的包装思路
3.1 直接用原包重发的坑
接着开头那个问题:发送端从缓冲区里找到了丢失的原始 RTP 包,是不是直接把它塞回网络就行?
如果原包是走普通 RTP,同一 SSRC 下同一个序列号,接收端确实能识别出这就是缺失的那个包。但问题在于,接收端的 RTP 模块、SRTP 解密模块、JitterBuffer、解码器各自维护的状态,全都绑定在“SSRC + 序刑号”上。
直接重发原始包会带来两个主要问题。第一,发送端的 RTCP 发送报告里会统计这个 SSRC 发出的包数和字节数。重发包如果和原包有相同序列号,统计会重复;如果给重发包分配新序列号,又会破坏原始媒体流的连续性,可能导致接收端以为出现新的媒体帧。第二,解码器层面的 RTP 包序号判断依赖连续性,重复序号很容易让接收端认为这是乱序到达的旧包,直接丢进重排缓冲区,反而不会很快送到解码器。
所以 RFC 4588 干脆提出一种新的重传格式:RTX。重传时不再使用原 SSRC、原序列号,而是用一个新的 RTX SSRC 和新的序列号来承载,同时在 payload 最前面记录原始包序列号。
3.2 RTX 包的具体结构
一个 RTX 包仍然是一个 RTP 包,但它的头部字段和原始包不同:
text复制RTP Header:
- SSRC = RTX SSRC,和原媒体流的 SSRC 不同
- Sequence = RTX 流自己的新序列号,和原流无关
- PT = RTX 对应的动态 Payload Type,比如 98
- Timestamp = 可以沿用原包的时间戳
RTX Payload:
- OSN = Original Sequence Number,16 bit,原始 RTP 包的序列号
- Payload = 原始 RTP payload
关键是 OSN 这一小段。接收端收到 RTX 包后,并不是直接把它当作新的一路媒体流去解码,而是先提取 OSN,还原出原始序列号,再把这个 RTX 包“翻译”回原始 RTP 包的形式,送进 JitterBuffer。
从这里也能理解为什么很多 SDP 里看到媒体流的 PT 是 96,RTX 的 PT 是 98,但它们其实是同一路视频。PT 96 是真正的编码数据格式,PT 98 不代表编码格式,它表示“这是一个重传包,负载前 16 bit 是 OSN”。所以在 SDP 协商中,RTX 的 Payload Type 必须通过 apt 参数和某个具体媒体 PT 绑定,不能独立存在。
如果你抓包看到一路视频有两个 SSRC 在发数据,不要以为这是一路冗余流或双路流。把它当成一路媒体加一路 RTX 去理解,整个时序就清楚了。
3.3 为什么 OSN 比简单重发更利于接收端去重
接收端能同时收到原始 RTP 包和对应的 RTX 重传包。正常情况下,网络乱序或重传竞争可能导致两个副本先后到达。
如果接收端只是简单地把 RTX 包还原成原始 RTP 包就送去解封装,它就必须自己判断:这个原始序列号我之前有没有已经收到过?如果已经收到,RTX 副本必须丢弃,否则解码器会收到重复数据。
有了 RTX SSRC 作为独立通道,接收端可以先把所有 RTX 包按照 OSN 映射回原始序列号,再和主媒体流已经收到的序列号做对比。重复的就丢弃,空缺的才补进 JitterBuffer。这种去重逻辑被独立出来,而不是和编解码器去重逻辑耦合在一起,对上层非常友好。
实际实现里,接收端会为媒体流维护一个“已经到达序列号集合”或者去重表。RTX 包还原出来的 OSN 只要查一下表,就知道是不是重复。这样做还有一个附加好处:RTX 流的 RTP 序列号是独立的,即使重传包在网络里被复制、乱序,也不会反过来污染主媒体流的序列号连续性判断。
4. 从 SDP 协商到完整链路:NACK+RTX 的一通电话是怎么工作的
4.1 SDP 里必须协商哪些能力
NACK 和 RTX 并不是默认开启的。双方必须在 SDP 协商阶段明确告诉对方“我支持用 NACK 反馈丢包”,以及“我支持用 RTX 格式重传”。否则另一端收到 RTX 包就不知道该怎么解。
一个典型的视频 SDP 片段类似这样:
sdp复制m=video 40000 RTP/AVPF 96 98
a=rtpmap:96 H264/90000
a=fmtp:96 profile-level-id=42e01f
a=rtpmap:98 rtx/90000
a=fmtp:98 apt=96
a=rtcp-fb:96 nack
a=rtcp-fb:96 nack pli
这里面的信息很关键。m 行用了 RTP/AVPF,而不是 RTP/AVP。AVPF 全称是 “RTP Control Protocol Extended Reports (RTCP) for RTP Audio Visual Profile with Feedback”,它允许接收端在非固定 RTCP 周期内提前发送反馈报文。NACK 是对时效性要求很高的反馈,如果只能等一个 5 秒的 RTCP 周期,重传就没有意义。所以实际 WebRTC 通话基本都协商 RTP/AVPF。
a=rtcp-fb:96 nack 表示针对 PT 96 的媒体流启用 NACK 反馈。a=rtcp-fb:96 nack pli 则是把 NACK 和关键帧请求 PLI 一起协商,如果接收端发现无法靠重传恢复,就请求发送端发一个关键帧。a=fmtp:98 apt=96 告诉对方:PT 98 是 PT 96 的 RTX 流。
音频也可以用 NACK,但实际部署中音频更常见的是用 FEC(前向纠错)或者冗余编码。原因是音频包比较小,一个 NACK + 重传的 RTT 延迟对听感影响明显,而 FEC 可以做到零等待恢复。视频因为数据量大,FEC 开销高,NACK + RTX 是更常用的兜底手段。
4.2 完整链路:接收端、发送端各自的角色
为了方便理解,我按时间线把一次 NACK + RTX 交互拆开:
- 发送端发送 RTP 包,序列号从 100 到 120,同时把最近发出的包缓存在发送缓冲区。
- 接收端收到 100 到 103,然后直接收到 108。它发现 104、105、106、107 缺失。
- 接收端启动一个乱序容忍等待窗口,比如 20ms。
- 20ms 后,104 到 107 仍没有到达,接收端判定这 4 个包丢失。
- 接收端构造 RTCP NACK 报文,PID 填 104,BLP 中把对应 105、106、107 的位都置为 1。
- 发送端收到 NACK,从发送缓冲区找到序列号 104 到 107 的原始 RTP 包。
- 发送端把这些包封装成 RTX 包,带上新的 RTX SSRC、新的序列号,payload 最前面写入 OSN。
- RTX 包到达接收端,接收端根据 RTX SSRC 和 PT 识别出来这是重传包。
- 接收端提取 OSN,还原出原始系列号 104 到 107,检查去重表,发现这些序列号还没到,就补入 JitterBuffer。
- 解码器从 JitterBuffer 中取到连续完整的数据,正常解码。
这条链路看起来不复杂,但每一步都有隐藏的时效要求。比如发送缓冲区太大,内存会涨;太小,NACK 到达时包已经被删掉了。再比如接收端等待乱序的窗口设置太长,会直接影响 JitterBuffer 的播放延迟,造成音画不同步。
4.3 SRTP 加密下 RTX 包怎么解
如果通话环境开了 SRTP,RTX 流同样需要被加密。否则,只要有人能抓到网络包,重传的数据就会成为突破口,相当于绕过了一层加密保护。
所以在协商 RTX 的时候,SRTP crypto context 也要把 RTX SSRC 加进去。发送端加密 RTX 包时,会以 RTX 流自己的 SSRC、序列号作为 SRTP 加密参数的一部分。接收端收到 RTX 包后,先按 RTX SSRC 对应的 crypto context 解密,再提取 OSN 和原 payload。
这就导致一个有意思的现象:抓包时你可能看到一个 RTX 包的 RTP header 是明文,SSRC、PT 都看得清清楚楚,但 payload 是密文,根本看不到 OSN 和原始媒体内容。这是正常现象,不是排查有问题,不要误以为 RTX 没生效。
5. FEC、延迟与拥塞控制:NACK+RTX 不是万能药
5.1 重传的延迟边界怎么估算
NACK + RTX 的恢复时间基本取决于 RTT 的一个来回。假设 RTT 是 40ms,接收端等乱序窗口 20ms,那么从丢包发生到接收端收到 RTX 数据,大约要 60ms 到 80ms。如果播放器的 JitterBuffer 深度只有 60ms,重传回来的视频帧很可能已经赶不上播放。
因此,NACK + RTX 适合 RTT 不太大的网络。在跨国或弱网高延迟链路上,单纯靠重传很难在播放 deadline 前恢复。这时候 FEC 的价值就出来了。
FEC 的思路是“预防性冗余”。发送端在发原始包的同时,额外发送一些冗余数据,接收端只要收到足够多的冗余包,即使丢了几个原始包,也能通过数学运算把数据恢复出来。它不需要等待 RTT,恢复几乎是即时的,缺陷是会额外占用带宽。如果网络状况很好,几乎没有丢包,FEC 的冗余就是纯粹浪费。
所以很多实时引擎会采用 NACK + RTX 与 FEC 结合的策略:当丢包率较低或 RTT 较小时,用 NACK + RTX;当丢包率超过阈值或网络延迟很高时,开启 FEC。也可以对不同的媒体类型区别对待,比如音频用 FEC,视频用 NACK + RTX,屏幕共享则会对关键帧完整性要求更高,可能强制开启 RTX 并缩短重传超时。
5.2 重传流量不能无视拥塞控制
有朋友问过我,既然 RTX 可以提高可靠性,为什么不把所有包都放进发送缓冲区,遇到 NACK 就无限重传?这里最大的风险在于拥塞控制。
实时音视频的发送端通常会根据网络带宽估计调整码率。如果网络出现丢包,发送端检测到拥塞,会主动降低媒体码率。但重传包的产生往往不受正常码率控制约束。一旦丢包率升高,NACK 数量增加,重传流量跟着上涨,相当于在原本已经拥塞的链路上又加了一股流量,可能导致丢包更严重。
业界常用的做法是把 RTX 重传也纳入发送带宽预算。发送端评估出当前可用带宽后,会把媒体码率和重传预留分开计算。发生丢包时,优先传输重传包,同时降低新媒体的编码码率,保证总带宽不超标。我们在一些 WebRTC 实现里看到的 rtx 码率统计,就是希望开发者意识到:重传并不免费,它占用的带宽最终会反馈到视频清晰度上。
5.3 NACK 风暴怎么避免
“NACK 风暴”是弱网场景下最让人头疼的问题之一。当链路质量急剧恶化,接收端会持续发现大量缺口,每个 RTCP 反馈周期都可能塞满 NACK。发送端如果对每个 NACK 都立即重传,可能把重传窗口塞爆,进一步加剧拥塞。
开源实现里常见的抑制策略有几种:
- 限制单个反馈周期内 NACK 条目的数量。
- 对同一个包的 NACK 设置最大重传次数,比如最多重传 2 次。
- 降低 NACK 反馈频率,一个 RTT 内最多只发送一轮 NACK。
- 如果丢包率持续过高,从“包级恢复”切换到“帧级恢复”,直接请求关键帧,丢弃中间所有不可恢复的帧。
这几种策略实际部署时通常是组合使用。尤其要注意,对于视频关键帧,一个关键帧可能被切成几十个 RTP 包,其中一个包丢了,可能导致接收端无法完整解码这个关键帧。这种情况下,重传关键帧的一个包往往比单独请求一个新的关键帧效率更高,因为一个关键帧可能有好几百 KB,而重传一个包只有几百字节。这个权衡,很多刚做音视频传输的人容易忽略。
6. 实战排查:NACK 和 RTX 相关的几个高频问题
6.1 RTX 包到了,但画面还是卡
这是排查中非常典型的一种情况:抓包能看到发送端确实发了很多 RTX 包,接收端也收到了,但用户体验还是卡顿。
问题往往出在接收端没有及时把 RTX 包还原成原始 RTP 包,或者还原后没有插入正确的位置。RTX 包到达接收端时,播放进度可能已经往前走了一大截。如果 JitterBuffer 没有足够空间接收“迟到但还能用的包”,它会直接丢弃这些重传数据。
我在实际调试中遇到过一次最隐蔽的问题:某个 RTX 包能够还原出原始序列号,但接收端去重表没有正确记录“该序列号已经由 RTX 补全”。结果后面原始 RTP 包也到了,接收端又把它当作有效包,导致同一帧被送了解码器两次。表面上看丢包率不高,实际画面却一直抖动。
排查这类问题,不能只看有没有 RTX 流量,还要看接收端的 JitterBuffer 丢弃计数和去重计数。
6.2 用 WebRTC 工具看 NACK 指标
如果做 WebRTC 开发,Chrome 的 chrome://webrtc-internals 是很好的观察窗口。在里面找到视频接收rtp流统计,重点关注:
nackCount:接收端发送的 NACK 次数。packetsLost:接收端统计的累计丢包数。packetsReceived:收到的 RTP 包总数。- 发送端统计里的
retransmittedPacketsSent:发送端发出的 RTX 包数量。
只看这几个数值的绝对值意义有限,要把它们和时间曲线、RTT 曲线放在一起看。有一次我排查一个延迟越来越高的通话,发现 nackCount 很低,但 packetsLost 持续增长。进一步看才发现接收端根本没有启用 RTX,所有的 NACK 都没有触发重传,导致播放器只能在 JitterBuffer 里等,最终表现为延迟越来越大。
如果你用 Wireshark 抓包,可以按 RTCP 类型过滤 NACK 报文,检查报文里的 PID 和 BLP 是否和你预期的丢包一致。再用 RTP 流的过滤条件把 RTX SSRC 的包单独列出来,观察 RTX 包是否在 NACK 发出后的一个 RTT 内到达。这一步能快速定位问题出在反馈链路还是重传链路。
6.3 发送缓冲区与 RTX 时间窗的匹配
我在做通话音质优化时踩过另一个坑:为了节省内存,把发送缓冲区设置得太小,结果 RTT 稍微变大,NACK 到达发送端时,对应的 RTP 包已经被移出缓冲区,导致重传失败。
这个问题的本质是发送缓冲区的保留时间必须大于等于 NACK 的反馈周期。假设发送方每 50ms 收到一轮 RTCP,缓冲区至少要保留 50ms 以上。如果 RTT 较大,建议把保留时间设置为 2 到 3 倍 RTT。但这也会带来副作用:缓冲区保留越久,内存占用越高,而且如果接收方迟迟不反馈,发送端不知道哪些包已经被确认,只能把所有包都缓存。
实际项目里,我会根据业务场景设置两个档位:普通音视频通话,保留 200ms 到 500ms;屏幕共享或关键帧较大场景,保留 500ms 到 1000ms。再配合 RTT 实时调整,就不会出现“NACK 来了,包没了”这种无意义的反馈。
收尾时说一句很个人的体会:NACK + RTX 这套机制,看 RFC 和看代码是两种难度。RFC 4585 和 RFC 4588 字数不多,但真正到抓包排查时,你会发现关键不是“有 NACK”或“有 RTX”,而是它们是否在正确的时间窗口内配合起来。以后再看到通话卡顿,别只盯着丢包率,先默默算一遍 RTT,再看看 JitterBuffer 深度和发送缓冲区窗口,多半能找到答案。
