1. 丢包为什么治不好FEC要解决的真正问题1.1 实时流媒体里时间比内容更贵我在折腾音视频通话的时候被一个问题反复折腾本地网络测出来丢包率只有 2%-3%但通话对端画面就是卡顿、马赛克、音频断断续续。带宽明明够路由器也没掉线为什么画面就是恢复不了后来想明白了实时音视频里内容可以丢但时间不能等。视频帧是按照时间轴生产的接收端要在一帧的播放点之前把它解码出来晚了就没有意义了。典型场景里端到端时延预算只有 100ms 到 300ms丢一个包你只有这个窗口去补救。网络上丢了的 RTP 包听起来只是“少了一帧的数据”但对解码器来说这一帧可能直接解不出来后面依赖它的帧也会跟着花屏。音频少一个包就是一段吞音人在说话时中间被砍了一截。这时候有两种修复思路一个是丢了我再要一次也就是 ARQ自动重传请求另一个是我事先多发一点冗余丢了还能自己算回来这就是 FECForward Error Correction前向纠错。两种思路的差别远不止“多花点带宽”这么简单。1.2 “事后补救”和“提前预防”两条路的本质差别NACK 重传的原理很简单接收端检测到 RTP 序号断裂就发一条 RTCP NACK 告诉发送端“第 738 个包我没收到”。发送端再把那个包重发一次。成本很低链路没有丢包时完全不消耗额外带宽这也是为什么很多初学音视频的朋友觉得 NACK 就够了。但 NACK 有个硬伤它至少要等一个 RTT。而且这个 RTT 是动态的拥塞时 RTT 会从 20ms 涨到 200ms 甚至更多。等接收端发现丢包、发 NACK、发送端处理、重发的包到达播放点早就过了。更麻烦的是真正丢包通常发生在链路拥塞的时候此时重发的包本身也可能再次丢失第二次重传还要再等一个 RTT基本等于放弃。我用过一次实际通话的跟踪数据RTT 平均 40ms 的情况下一次 NACK 往返 接收端解码排队修复一个包大约需要 60ms 到 80ms。如果播放缓冲只有 120ms意味着这个修复窗口最多扛一两次重传继续丢就只能花屏。FEC 的思路完全不同。发送端不等待任何反馈直接把原始数据通过某种算法生成额外的校验数据跟着原始包一起发出去。接收端收到后发现缺了某个包只要同一组里丢失的包数没有超过冗余的设计上限就能在本地解算出来。整个过程零 RTT 开销受网络延迟的影响非常小。这两条路线的取舍我整理了张表方便大家根据场景选维度NACK 重传FEC 前向纠错延迟代价至少 1 个 RTT恢复时仅需本地解码不受 RTT 影响带宽开销丢包时才重传正常情况下很低无论丢不丢包都会持续消耗冗余带宽实现复杂度相对简单状态机好控制需要编解码算法、分组管理、交织设计抗连续丢包连续丢包时可反复重传但延迟不可控一旦超过冗余上限就无能为力适用场景低丢包、低延迟、带宽宝贵丢包率中等偏高、对时延敏感、带宽有富余1.3 丢包其实不止一种别把所有问题都推给“网线不好”学习 FEC 之前先去搞清楚网络丢包的类型否则参数设置了也是白设。第一种是随机零星丢包。比如无线信道波动、数据包在校验时出错被丢弃分布比较散偶尔丢一个。FEC 对这种丢包最得心应手。第二种是突发丢包。路由器缓存积压到极限后操作系统会一次丢一排包或者 Wi-Fi 信号中断几十毫秒后缓冲的包成串到达又成串被丢弃。突发丢包是 FEC 的大敌后面讲交织和冗余度时会专门聊。第三种其实是带宽不足导致的隐性丢包。发送码率超过了链路瓶颈路由器排队缓存塞满只能丢包。这种场景下加 FEC 只会雪上加霜——你多发的冗余包进一步加剧拥塞拥塞引发更多丢包于是你再加冗余形成一个灾难性的正反馈。这也是为什么 FEC 参数必须和拥塞控制、码率估计联动而不是一个固定的静态值。2. FEC 的核心原理冗余不是拍脑袋算出来的2.1 最简单的 XOR 奇偶校验一张图说明白FEC 最基础的实现是异或XOR冗余思路非常直接。假设我现在要发送 7 个 RTP 包我可以把这 7 个包的 payload 做一次按位异或运算得到一个校验包 D。计算方式是P1 ⊕ P2 ⊕ P3 ⊕ P4 ⊕ P5 ⊕ P6 ⊕ P7 D然后我把 P1 到 P7 和 D 一起发出去。接收端如果 7 个包全收到直接忽略 D如果丢了一个比如丢了 P3就用 P1 ⊕ P2 ⊕ P4 ⊕ P5 ⊕ P6 ⊕ P7 ⊕ D 把 P3 还原出来。这个方案最大的好处是计算开销几乎为零硬件层面都能直接做。缺点也很明显一组里只允许丢一个包丢两个就救不回来。于是实际应用中必须精心设计“组”的大小——组越大校验包摊薄后的冗余比例越低但一组内容量越大丢两个包的概率也越高。这里有一个经常被新手忽略的点组的大小直接影响延迟。接收端为了恢复丢失的包必须在收到整组所有包之后才开始解码至少需要等待一个组的发送时间。比如发包间隔是 10ms一组 7 个原始包加 1 个校验包发送时间是 80ms那么接收端要等这 80ms 的跨度结束才能开始恢复。所以组跨度本质上就是从“发送做好准备”到“接收端能开始复原”之间的时间成本。2.2 从 XOR 到 RS 码能扛住多个丢包的纠删码XOR 抗不住多个丢包于是我们需要更通用的方案里德-所罗门纠删码Reed-Solomon Erasure Code。RS 码的数学背景听起来复杂但理解它只需要换一个角度把若干原始包看作一组未知数然后用线性方程组构造校验方程。比如要发送 n 个原始包再生成 m 个校验包。在接收端只要收到的原始包和校验包总数不少于 n就相当于凑齐了 n 个独立方程可以解出全部 n 个原始数据。这一点最关键接收端不需要知道“具体丢了哪几个包”只需要知道“收到了哪些包”。收到的包数量够了就可以通过解方程组还原丢掉的任何包。m 个校验包意味着最多能抵抗 m 个包的丢失。RS 码在音视频领域通常以系统码Systematic Code的形式使用。系统码的设计是原始包完全不变地以明文形式发送只是额外附加校验包。接收端如果没丢包直接拼原始包连解码步骤都不需要只有发现丢失时才动用校验包恢复。对 RTP 这类“优先保证数据少处理一次”的协议来说系统码是必选方案。具体实现时编码会用到范德蒙德矩阵解码要做高斯消元或者矩阵求逆。好在每个包的实际长度没那么大在软件层面做几百个包的矩阵运算计算时间也就是几毫秒级别。真正吃 CPU 的场景是丢包率特别高的弱网环境下一帧几百个 RTP 包都要抢救那时可以考虑用 SIMD 优化但多数场景默认实现就够了。2.3 交织把“连续丢一堆”变成“每组只丢一两个”网络丢包总是有“群聚效应”。Wi-Fi 信号闪断一下、路由器缓存瞬时积压都可能导致几十个包连续丢失。如果这些包落在同一个 FEC 组里组内丢失数会远超冗余上限再强的 RS 码也无能为力。解决方式叫交织Interleaving。思路是把本应属于同一 FEC 组的包分散到多个不同的组中。比如有 A、B、C、D 四个组正常顺序是 A1-A7、B1-B7、C1-C7、D1-D7。交织之后发送顺序变成 A1-B1-C1-D1、A2-B2-C2-D2、A3-B3-C3-D3… 这样一旦发生连续 4 个包丢失实际影响的是每个组各丢一个包而不是 A 组一下丢 4 个。就好比洗牌把坏运气均匀分摊到每个人头上。交织后每个 FEC 组都能用自己的冗余把丢失的那个包恢复出来。交织深度不是越大越好。深度越深接收端为了重组乱序包所需的缓冲时间越长时延也越大。我之前在实验环境里测试过交织深度 4 时FEC 恢复延迟大约增加 30ms深度 8 时延迟就到了 60ms 左右。对于实时通话这类场景交织深度通常选 4 到 8 之间并且要和目标端到端时延匹配。2.4 关键数学冗余度怎么算才能既不浪费带宽也不裸奔FEC 冗余度设计的核心是概率模型。假设单个包随机丢失概率为 p组内共有 n 个原始包、m 个校验包那么“这组能恢复成功”的概率就是“丢失包数不超过 m”的概率。按二项分布计算P(组恢复成功) Σ_{i0}^{m} C(nm, i) × p^i × (1-p)^(nm-i)我算过几组典型数据直接给结论。假设 n10p5%不加 FECm0能无伤收到的概率约为 0.59840% 概率会遇到至少一个包丢失导致的卡顿。加 1 个校验包m1恢复成功率约 0.898。加 2 个校验包m2恢复成功率约 0.997。也就是说在随机 5% 丢包下冗余 20%2 个校验包摊 10 个原始包能把不卡顿的概率从 60% 拉到 99.7%。这 20% 的带宽投入换来了体验的质变。但这套计算有个大前提包丢失是独立、均匀分布的。真实网络常常出现突发丢失同样的概率模型会高估恢复能力。所以在实践里我会按照这个数学模型做一个基础值然后根据突发性再额外增加 5 到 15 个百分点的冗余。这份“经验补贴”没有公式得靠不同网络环境的实测锚定。3. WebRTC 里的 FEC 到底长什么样从 ULP 到 FlexFEC3.1 ULPFEC老一代的 RTP 层前向纠错方案最早在 WebRTC 标准体系里大规模使用的 FEC 方案叫 ULPFECUneven Level Protection FEC对应 RFC 5109。它的核心机制是分层保护不同重要性的媒体包分配不同数量的冗余包能做到对关键数据多保护、对次要数据少保护。在 RTP 封装层面ULPFEC 的校验包自带一个掩码字段用来描述“这个校验包到底保护了哪些 RTP 包”。接收端通过掩码可以知道当某个原始包丢失时哪些校验包可以用来恢复它。ULPFEC 在实现上通常基于 XOR 运算计算简单、不需要大型矩阵求解。它是逐个保护块protection block组织的一组成员最多能恢复的丢包数取决于该组校验包数量。但正因为它是按 XOR 设计的每个校验包对组内丢包的贡献是单一维度保护能力以校验包数量线性增长效率并不算高。3.2 FlexFEC为什么还要换个新方案ULPFEC 用起来最别扭的一点是掩码格式设计得比较死。它倾向于为每个 FEC 组固定分配一组校验包当媒体包尺寸差异很大、或者你希望更精细地调整保护级别时这种固定的分组方式就显得僵硬。后来 WebRTC 实现在更新中选择支持 FlexFEC这是一版更灵活的前向纠错草案方案。FlexFEC 允许动态配置 FEC 组、掩码范围和校验包策略甚至可以针对同一路媒体流中不同重要性的包采用不同的保护策略。比如对视频关键帧的包做高冗余保护对普通帧的包做低冗余保护这些都可以在运行时调整。从实现角度看FlexFEC 的协议描述比 ULPFEC 松散扩展位和配置项更多但也意味着实现者在做兼容时需要更仔细。很多音视频库在 FlexFEC 支持上到现在还是半成品的状态如果自己做协议栈要有心理准备——标准草案只是给了框架细节全在代码里。对于学习路线我的建议是先把 ULPFEC 看明白再过渡到 FlexFEC。理解 ULPFEC 的分组和掩码逻辑之后FlexFEC 的“灵活”到底灵活在哪才算真正理解了。3.3 Opus 的低码率带内 FEC不给网络增加额外开销的加分项RTP 层 FEC 不是唯一的前向纠错手段。Opus 编码器自身也内置了一种带内 FECIn-band FEC它的做法很有意思在编码当前音频帧时把前一帧的部分关键特征也塞进码流里。接收端如果发现当前帧丢失可以依靠前一个帧里内嵌的冗余特征重建出一个低质量但可辨识的音频信号。用户听到的效果就是“声音略糊一点”而不是直接断掉一句话。代价是 Opus 码率会增加一些通常从 32kbps 可能涨到 40-48kbps但对实时音频应用来说这个开销非常划算。很多音视频项目在启用 RTP 层 FEC 的同时也会把 Opus 的带内 FEC 打开形成两极防护音频帧在 RTP 层可以用 RS 码恢复如果整个 RTP 包彻底找不到还能用带内冗余抢救一下语音的连续性。实测下来前者负责“包级别的恢复”后者负责“最后一公里的兜底”配合效果明显好于只用一个。不过这招只对音频有效视频编码器目前没有类似的低成本带内冗余方案。视频帧还是要老老实实靠 RTP 层 FEC 处理。3.4 一个简化的工作流从发送端到接收端的完整链路把前面这些原理拼成一个实际操作链路大致是这样编码器输出 RTP 包发送端按保护组比如每隔 n 个原始包为它们分配 FEC 组。根据当前的带宽估计、目标时延和反馈的丢包率决定 FEC 类型ULPFEC 还是 FlexFEC以及冗余度。对组内原始包执行 FEC 编码生成校验包。原始包和校验包按交织顺序发送。接收端根据 RTP 序号和 FEC 掩码把包归类到对应 FEC 组等组内包大部分到达后开始尝试解码恢复。恢复出来的包重新插入 JitterBuffer再交给解码器。接收端判断逻辑用伪代码表示就是# 组恢复伪代码简化示意 group_queue defaultdict(list) def on_recv(rtp_pkt): fec_group_id get_group_id(rtp_pkt) group group_queue[fec_group_id] if rtp_pkt.is_original: group.original.append(rtp_pkt) else: group.redundant.append(rtp_pkt) if group.is_generation_done(): lost get_lost_packets(group) if len(lost) 0: deliver_all(group.original) elif len(lost) len(group.redundant): recovered fec_decode(group.original group.redundant, lost) deliver_all(recovered) else: # 超出恢复能力把能拿到的都交付缺失标记为空帧 pull_partial(group)真正生产环境需要处理的问题比这多得多FEC 组状态过期、交织重排、时间戳对齐、冗余包本身损坏等等。但它能帮助你理解一件事——FEC 不是玄学它就是把丢包问题转化成了一个“在限定时间窗口内凑齐足够方程”的问题。4. 参数动手调丢包率与实际测试的冗余映射4.1 静态冗余与动态冗余的取舍FEC 冗余度可以设计成固定值也可以随网络状况动态调整。现实中我强烈建议做动态调整但不要调得太灵敏。固定冗余的缺点是网络好时白白占用带宽网络差时又不够用两头不讨好。动态冗余则依赖 RTCP 反馈里的丢包率和 RTT 数据每隔几百毫秒到一两秒重新计算一次目标冗余度。这里的坑在于反馈有滞后而网络状况是波动的。如果某次检测到丢包率稍微一高就立刻加大冗余下一秒网络恢复了又立刻降回去就会造成冗余度的频繁抖动连带编码码率和发送节奏也不稳定。第一次做动态 FEC 时我就是这样日志里冗余率在 10% 和 30% 之间来回跳用户端看到的就是画面质量忽好忽坏。解决思路是引入阈值迟滞。比如设定丢包率达到 8% 时才开启 FEC丢包率降到 3% 以下才关闭在 3% 到 8% 之间的区间里维持上一次的状态不变。这就像一个带死区的控制器可以有效防止冗余度在临界点附近反复振荡。4.2 实测数据不同场景下的冗余配置参考我做过一组基于模拟网络环境的实验。环境具备受控的随机丢包模型和独立的突发丢包模型能分别模拟两种截然不同的网络行为。针对测试数据整理了一张可用参考表网络场景丢包特征建议冗余率建议 FEC 策略实测效果Wi-Fi 轻度干扰随机 2% 以下0-5%不开或仅 NACK几乎无感有线网络高负载随机 3%-8%10%-15%ULPFECNACK 保留偶发可恢复4G/移动网络弱信号随机混合突发 8%-15%20%-30%FlexFEC 交织深度 4-6卡顿明显减少卫星或极弱网突发且长时间 15%-25%30%-40%RS 系统码 SVC 降级只能保基本连通重度拥塞 25% 以上突发为主不宜作为首选优先降码率 关键帧保护FEC 不再经济我拿视频 1Mbps、RTT 40ms 的场景单独测了一组。5% 随机丢包下冗余率 15% 时画面几乎看不出损耗但当我把丢包模型切换成“连续丢 3 包、重复间隔 50ms”的突发模型后同样的冗余率明显不够用画面开始出现花屏。把冗余率提到 25%同时将交织深度从 2 调整到 4才能达到和随机丢包下接近的效果。这说明一个问题不要照着淘宝卖家秀式的丢包率去做冗余配置。同样的 5% 丢包率随机和突发两种形态需要的冗余差了一倍。4.3 和拥塞控制带宽评估一起工作的正确姿势FEC 参数不能独立于带宽控制单独设置否则就是前文提到的“雪崩”现场。WebRTC 内部的带宽估计器会通过拥塞控制算法测算当前可用带宽 B这个值要同时分给编码码率 R 和 FEC 冗余开销 FB R × (1 redundancy)这里的 redundancy 是相对编码码率的比例。比如带宽估计 1Mbps冗余率 20%那么编码目标码率应该设置为大约 833kbpsFEC 消耗约 167kbps总输出仍是 1Mbps。如果只把冗余率调成 20%却不改编码码率还是按 1Mbps 做视频编码那么实际总发送码率就变成 1.2Mbps超过可用带宽拥塞立刻出现。正确的联动流程是带宽估计模块输出可用带宽 B。根据丢包率、突发程度算出目标冗余率 r。编码码率设定为 B / (1 r)。FEC 模块按 r × 编码码率 生成校验数据。持续监控如果丢包情况恶化优先降低编码码率再考虑提升冗余率。我之前在自己的测试里把顺序搞反过一次——丢包率升高时优先加冗余编码码率不变结果总码率超过瓶颈RTT 直线上升随后丢包更多FEC 组里的校验包也一起丢失。重新按“先降编码码率再适度加冗余”的顺序调整后链路才稳定下来。记住FEC 只负责修复“有限且随机”的损伤链路持续过载时它解救不了你反而会拖垮你。5. 我在实测过程中踩过的 FEC 坑5.1 突发丢包面前静态冗余度瞬间失效第一次把 FEC 接进通话链路时我拍着胸脯说“10% 冗余包治 5% 丢包”。结果模拟器一切到突发丢包模式画面直接变成马赛克现场。查日志才发现连续 80ms 的丢弃让每个 FEC 组的丢失数量都超过了校验包的上限而交织深度只配了 2根本不足以把连续丢包分摊开。那次之后我总结出一条规律突发丢包环境下只靠提高冗余率不划算一定要同时提高交织深度。因为突发丢破坏的是同一组内的连续性只有让每个组少分到一点损伤冗余率才能发挥真正的作用。把交织深度从 2 提到 6 后同样的 15% 冗余率突发丢包的恢复率上涨了 40% 以上。5.2 FEC 和 NACK 同时在跑带宽凭空多了一倍另一个让我挠头的坑出现在 FEC 和重传机制同时生效的时候。接收端因为还没等到校验包就先发出了 NACK发送端重传原始包但 FEC 校验包随后也到了而且真的完成了恢复。结果同一份数据被重复送了三份占用的是拥塞链路上最宝贵的带宽。排查之后我把重传策略改成“先等 FEC 恢复窗口结束再决定是否 NACK”。具体做法是接收端收到同一 FEC 组的大部分包后启动一个短定时器比如 20ms等到 FEC 恢复还没成功、缺口仍在时才发起重传。这样把 FEC 作为第一优先级修复手段NACK 只是兜底。如果你的项目是自己写 RTP 栈这步一定要留意。FEC 解码是个“等包”的过程NACK 是个“催包”的过程两者节奏没协调好带宽会白白翻倍。5.3 为了恢复率把组调太大时延反而超标了FEC 组越大、交织越深恢复能力越强但这些都会增加接收端的等待时间。有的朋友把保护组跨度设到 150ms表面上丢包全恢复了端到端时延却从 120ms 涨到了 300ms音视频不同步、对话感全无用户反馈“画面倒是没花但感觉大家都在慢半拍说话”。我的经验是FEC 组的时间跨度尽量不要超过目标端到端时延的三分之一。如果目标时延是 150ms那 FEC 保护组的最大跨度控制在 50ms 以内。超过这个阈值即使画面稳定交互体验也已经失败了。真要保关键帧不如把冗余集中给 I 帧或 P 帧中的重要分片而不是把所有包都一视同仁地保护起来。5.4 区分链路丢包和带宽不足调反了方向就是灾难“链路丢包”和“带宽不足”这两者的症状几乎一样都是丢包率飙升但处方完全相反。链路丢包适合加 FEC带宽不足则需要先降码率再加 FEC 是火上浇油。区分方法有两条线索。第一条看 RTT 趋势如果 RTT 也持续上涨通常说明路由器排队积压属于带宽不足RTT 平稳但丢包率高则更可能是无线链路问题。第二条看接收端包到达时间统计如果包到达间隔有明显排队延迟的特征基本可以判定带宽受限。我自己测试时会故意做一组对比先手动把发送码率砍半观察丢包是否明显下降。砍半后丢包好转就说明是带宽不足而非物理链路问题。这时候正确的操作顺序是降低编码码率、关闭部分视频增强功能确保总发送码率低于瓶颈值再考虑剩下的随机损耗分配 FEC。方向一旦搞错就会在一个本来已经很拥挤的通道上再塞进一批冗余包把最后的通信通道彻底堵死。5.5 关键帧永远值得拥有更高的保护优先级最后分享一个我认为最重要、也最容易被低估的实践经验不要对所有包用同一套 FEC 保护策略。实时视频流里关键帧IDR 帧一旦丢失解码器会直接卡在上一帧直到下一个关键帧到达而普通帧丢失往往只影响局部画面。让关键帧和普通帧享受同样的保护待遇纯属浪费冗余额度。所以我现在做项目时会单独维护一条“关键包队列”视频编码器产出的关键帧分片进入高优先级保护级别使用更高的冗余率和更大的交织深度非关键帧则采用基础保护策略。同样 20% 的总冗余预算这样分配之后画面卡顿的频次比平均分配方案低了将近一半。对了Opus 带内 FEC 也是类似逻辑它专门保护“前一个包”而不是平均撒网。有效的 FEC 从来都是一种有选择的策略而不是无差别的数据扩频。
阅读完成 · 觉得有帮助?