NACK、PLI、FEC 到底怎么配合:丢包之后,实时音视频系统到底怎么补救

前言:为什么实时音视频里最难受的不是“丢包会不会发生”,而是“丢了以后到底怎么救”

只要开始做实时音视频,几乎很快就会接受一个现实:

丢包不是例外,而是常态。

尤其一旦进入这些环境,丢包几乎迟早要来:

  • Wi-Fi 抖动
  • 4G / 5G 波动
  • 公网跨地区链路
  • 弱网移动场景
  • 多人会议并发
  • 家庭网络被别的设备抢占

所以真正有价值的问题,从来不是:

系统能不能做到完全不丢包?

而更像是:

一旦丢了,系统到底怎么尽量把体验救回来?

这时候你就会碰到几组非常关键的机制:

  • NACK
  • PLI
  • FEC

很多文章会把它们分别解释一下:

  • NACK 是重传请求
  • PLI 是关键帧请求
  • FEC 是前向纠错

这些定义都对,但如果只停在这里,其实还是抓不到工程里的那条主线。

因为真实系统里,这三样东西不是孤零零站着的,而是:

围绕“丢包之后怎么补救”这个核心问题,分别站在不同时间尺度、不同代价模型上的三种手段。

说白了就是:

  • 有的适合“赶紧补回来”
  • 有的适合“补不回来了,那就尽快重建参考链路”
  • 有的适合“别等丢了再说,提前带一点冗余过去”

所以这篇文章的目标,不只是背定义,而是想把它们讲成一个真正的工程决策问题。

重点讲清:

  • 为什么光有 jitter buffer 还不够
  • NACK、PLI、FEC 各自到底在补什么洞
  • 它们为什么不能互相完全替代
  • 接收端怎么检测丢包、怎么发 NACK、什么时候该发 PLI
  • 发送端为什么要有重传缓存
  • FEC 到底是在拿什么换什么
  • 如果你自己实现一套简化版丢包恢复链路,代码骨架应该怎么写

而且这篇会继续按你强调的方式来:

核心代码示例会继续给。

我会放:

  • 结构图
  • 时序图
  • 恢复流程图
  • 接收端丢包检测代码
  • NACK 构造与发送代码
  • 发送端重传缓存代码
  • PLI 触发逻辑代码
  • 一个简化版 FEC 恢复骨架

我希望这篇读完之后,你对“丢包恢复”不再只是记住三个名词,而是真能看到系统遇到坏包之后,是怎么一点点自救的。


一、先给一句最重要的话:丢包恢复不是单一技术,而是一组围绕时延、恢复概率和画面连续性做权衡的策略组合

如果让我先压一句最重要的话,我会这样说:

NACK、PLI、FEC 不是三种孤立功能,而是实时音视频系统围绕“时延预算、恢复成功率、画面连续性”做出来的一组补救策略组合。

这句话特别重要。

因为只要你开始真正做系统,很快就会发现,丢包恢复从来不是一句“补回来”那么简单。

你真正要面对的是这些互相打架的约束:

  • 我能不能来得及补
  • 补回来还有没有意义
  • 现在是只坏了几包,还是整条参考链已经断了
  • 我是该请求重传,还是该直接要关键帧
  • 我值不值得一开始就多发一点冗余

这也是为什么真实系统不会只靠一种方法吃天下。

它通常是多种手段一起上,只是侧重点不同。


二、先看总图:丢包恢复在整条实时链路里处于什么位置

先上总图。

flowchart LR
    A[发送端编码] --> B[RTP发送]
    B --> C[网络传输]
    C --> D[接收端发现丢包]
    D --> E1[NACK请求重传]
    D --> E2[PLI请求关键帧]
    D --> E3[FEC尝试恢复]

    E1 --> F[发送端重传缓存]
    F --> G[重发缺失包]
    E2 --> H[发送端尽快产生关键帧]
    E3 --> I[接收端局部恢复]

    G --> J[解码链路恢复]
    H --> J
    I --> J

这张图里最核心的一点是:

丢包恢复并不是只发生在接收端,也不是只发生在发送端,而是一整条链路的协同行为。

你可以先粗略理解成:

  • 接收端负责发现问题
  • 反馈机制负责发出求救
  • 发送端负责提供补救材料
  • 解码链路负责看现在还能不能继续活下去

三、为什么光有 jitter buffer 还不够

很多人看完 jitter buffer,会下意识觉得:

那我等一等、排一排,不就行了吗?

但问题是,jitter buffer 解决的主要是:

  • 到得早晚不均
  • 顺序乱一点
  • 某些包只是稍微迟到

它真正不擅长解决的是:

包彻底没了。

如果缺的包一直不来,jitter buffer 最终还是得面对:

  • 等太久,延迟爆炸
  • 不等,解码链路可能断掉

也就是说,它更像“秩序修复器”,不是“凭空造包器”。

所以一旦包真的缺失,就必须进入更主动的补救层:

  • NACK
  • PLI
  • FEC

四、NACK 到底在解决什么问题

4.1 NACK 的核心直觉

NACK(Negative ACKnowledgement)你可以先把它理解成:

接收端发现某些包没收到,于是告诉发送端:这些包我缺了,你如果还来得及,就补给我。

它特别适合什么场景?

  • 丢的是少量包
  • 发送端手里还缓存着这些包
  • 当前往返时延没有大到“补回来也没意义”

所以 NACK 的本质,是一种:

来得及的、针对性的事后补救。

4.2 它为什么值钱

因为它很节省。

相比一开始就多发很多冗余,NACK 的思路是:

  • 先正常发
  • 真丢了再补

这在丢包不重、RTT 不夸张的场景里,通常性价比很高。


五、PLI 到底在解决什么问题

5.1 PLI 不是补单个包,而是在说“参考链路可能已经坏了”

PLI(Picture Loss Indication)不是在说:

  • 某一个 RTP 包丢了

而更像是在说:

我这边的视频参考状态可能已经坏掉了,请你尽快发一个关键帧,让我重新建立解码基线。

这就和 NACK 很不一样。

NACK 关心的是:

  • 某几个包能不能补回来

PLI 关心的是:

  • 就算补几个包,也未必救得回来了,赶紧重开一局吧

5.2 它适合什么情况

比如:

  • 丢失的是关键参考数据
  • 解码器已经明显花屏 / 无法继续正确参考
  • NACK 来不及或者连续失败
  • 新加入的接收端需要尽快拿到完整可解码画面

所以 PLI 更像是一种:

请求发送端主动刷新参考链路的恢复手段。


六、FEC 到底在解决什么问题

6.1 FEC 的核心直觉

FEC(Forward Error Correction)可以理解成:

发送端在发送原始媒体数据时,顺手带上一些可用于恢复丢失数据的冗余信息。

它的思路和 NACK 完全不同。

NACK 是:

  • 先丢了再说
  • 事后请求补发

FEC 是:

  • 我先预判可能会丢
  • 所以提前带一些修复材料过去

6.2 它为什么值钱

因为它有一个特别大的优点:

不依赖往返重传时延。

也就是说,如果你在特别低延迟、RTT 又比较敏感的场景里,等 NACK 往返可能已经晚了。

这时候 FEC 的提前冗余就会特别有用。

6.3 它的代价是什么

当然不是白来的。

FEC 在拿这些东西换恢复能力:

  • 更多带宽开销
  • 更多打包和恢复复杂度
  • 冗余不是无限强,丢太多照样救不回来

所以它不是银弹,而是一种:

用额外码率换更强实时恢复能力 的手段。


七、一张图看懂 NACK、PLI、FEC 三者的时间尺度差别

flowchart TD
    A[包丢失] --> B{能否快速发现并且来得及补?}
    B -- 能 --> C[NACK请求重传]
    B -- 不能 --> D{参考链是否已损坏?}
    D -- 是 --> E[PLI请求关键帧]
    D -- 否 --> F{是否已有FEC冗余可恢复?}
    F -- 是 --> G[FEC本地恢复]
    F -- 否 --> E

这张图不是绝对流程,但很适合建立直觉:

  • NACK 偏“快速补洞”
  • PLI 偏“重建参考基线”
  • FEC 偏“提前带保险”

它们不是一个维度上的替代关系。


八、第一段核心代码:接收端怎么检测 RTP 丢包

代码先上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class LossDetector {
public:
struct Result {
std::vector<uint16_t> missing_seq;
bool has_gap = false;
};

Result OnPacket(uint16_t seq) {
Result res;

if (!initialized_) {
last_seq_ = seq;
initialized_ = true;
return res;
}

uint16_t expected = static_cast<uint16_t>(last_seq_ + 1);
if (seq == expected) {
last_seq_ = seq;
return res;
}

if (seq > expected) {
res.has_gap = true;
while (expected < seq) {
res.missing_seq.push_back(expected);
++expected;
}
last_seq_ = seq;
} else {
// 乱序或重传到达,交给上层进一步判断
}

return res;
}

private:
bool initialized_ = false;
uint16_t last_seq_ = 0;
};

这段代码的意义

它干的事很朴素,但特别关键:

通过 RTP sequence number 发现“中间少了谁”。

这就是很多恢复动作的起点。

如果连“少了哪个包”都不知道,后面根本谈不上 NACK。


九、第二段核心代码:接收端怎么组织 NACK 请求

检测到丢包之后,下一步通常不是马上狂发反馈,而是先组织成更像样的请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
struct NackRequest {
std::vector<uint16_t> lost_seq;
};

class NackGenerator {
public:
void OnMissingPackets(const std::vector<uint16_t>& missing) {
for (uint16_t seq : missing) {
if (nack_history_.count(seq) == 0) {
nack_history_[seq] = 0;
}
}
}

std::optional<NackRequest> BuildRequest() {
NackRequest req;

for (auto& [seq, retry_count] : nack_history_) {
if (retry_count >= max_retry_) continue;
req.lost_seq.push_back(seq);
++retry_count;
}

if (req.lost_seq.empty()) {
return std::nullopt;
}
return req;
}

void OnRecovered(uint16_t seq) {
nack_history_.erase(seq);
}

private:
std::map<uint16_t, int> nack_history_;
int max_retry_ = 3;
};

这里最该看什么

最该看的是这几个工程动作:

  • 丢包不是只记一次就完
  • NACK 通常会有重试次数限制
  • 一旦补到了,要把它从等待恢复集合里移掉

这就已经不是“发个请求”那么简单,而是一个小型恢复状态机了。


十、第三段核心代码:NACK 通过 RTCP 发送时,系统逻辑长什么样

下面给一个简化版“接收端主循环”骨架。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class VideoReceiver {
public:
void OnRtpPacket(const RtpPacket& pkt) {
auto loss_result = loss_detector_.OnPacket(pkt.sequence_number);
if (loss_result.has_gap) {
nack_generator_.OnMissingPackets(loss_result.missing_seq);
}

jitter_buffer_.Push(pkt);

if (pkt.is_retransmission) {
nack_generator_.OnRecovered(pkt.sequence_number);
}
}

void PeriodicFeedback() {
auto nack = nack_generator_.BuildRequest();
if (nack.has_value()) {
rtcp_sender_.SendNack(nack->lost_seq);
}
}

private:
LossDetector loss_detector_;
NackGenerator nack_generator_;
JitterBuffer jitter_buffer_;
RtcpSender rtcp_sender_;
};

这段代码说明什么

说明 NACK 不是独立在系统外面的。

它通常会和这些模块一起工作:

  • RTP 接收
  • 丢包检测
  • jitter buffer
  • RTCP 反馈发送

也就是说,恢复动作是实时链路里的一等公民,不是补丁。


十一、第四段核心代码:发送端为什么必须有重传缓存

NACK 要想有意义,发送端不能“听到你缺包了,但我早删了”。

所以发送端通常要保留一段时间的可重传历史。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class RetransmissionBuffer {
public:
void Store(const RtpPacket& pkt, uint64_t now_ms) {
history_[pkt.sequence_number] = {pkt, now_ms};
Cleanup(now_ms);
}

std::optional<RtpPacket> Find(uint16_t seq) const {
auto it = history_.find(seq);
if (it == history_.end()) {
return std::nullopt;
}
return it->second.packet;
}

private:
struct Entry {
RtpPacket packet;
uint64_t store_time_ms;
};

void Cleanup(uint64_t now_ms) {
const uint64_t kMaxAgeMs = 1000;
for (auto it = history_.begin(); it != history_.end(); ) {
if (now_ms - it->second.store_time_ms > kMaxAgeMs) {
it = history_.erase(it);
} else {
++it;
}
}
}

private:
std::map<uint16_t, Entry> history_;
};

这里的关键工程含义

为什么不能无限缓存

因为:

  • 内存不是无限的
  • 太老的包即使补回去,也未必还有意义
  • 实时系统不值得为了极晚的恢复一直背历史包袱

所以通常要有一个:

按时延预算决定的重传缓存窗口。


十二、第五段核心代码:发送端收到 NACK 之后怎么重传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class VideoSender {
public:
void OnEncodedPacket(const RtpPacket& pkt, uint64_t now_ms) {
retrans_buffer_.Store(pkt, now_ms);
SendRtp(pkt);
}

void OnNackReceived(const std::vector<uint16_t>& lost_seq) {
for (uint16_t seq : lost_seq) {
auto pkt = retrans_buffer_.Find(seq);
if (!pkt.has_value()) continue;

RtpPacket rtx = BuildRetransmissionPacket(pkt.value());
SendRtp(rtx);
}
}

private:
RtpPacket BuildRetransmissionPacket(const RtpPacket& original) {
RtpPacket rtx = original;
rtx.is_retransmission = true;
return rtx;
}

private:
RetransmissionBuffer retrans_buffer_;
};

这段代码最关键的点

你要看到的是:

NACK 的价值完全建立在发送端“还留着旧包”且“还能快速再发一次”之上。

这也是为什么高 RTT 场景下,NACK 有时就会开始变得不划算。

因为等你补回来,解码时机可能已经过了。


十三、第六段核心代码:什么时候该从 NACK 升级到 PLI

这是特别有工程味的一段。

因为系统不可能永远执着于“再补一次试试”。

如果参考链路已经坏了,继续 NACK 可能只是浪费时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class FrameRecoveryController {
public:
void OnNackTimeout(uint16_t seq) {
++consecutive_failed_repairs_;
if (consecutive_failed_repairs_ >= 3) {
need_pli_ = true;
}
}

void OnDecoderReferenceBroken() {
need_pli_ = true;
}

void OnKeyFrameReceived() {
need_pli_ = false;
consecutive_failed_repairs_ = 0;
}

bool ShouldSendPli() const {
return need_pli_;
}

private:
int consecutive_failed_repairs_ = 0;
bool need_pli_ = false;
};

这里体现的工程判断

什么情况适合发 PLI

  • 某些关键包一直补不到
  • 解码器已经明确报告参考损坏
  • 花屏 / 马赛克已经持续
  • 新用户刚加入但缺少可解码关键基线

也就是说,PLI 更像一句:

别补零件了,直接给我一块新的底板。


十四、第七段核心代码:PLI 发送与关键帧触发流程怎么组织

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ReceiverControlLogic {
public:
void PeriodicControl() {
if (recovery_ctrl_.ShouldSendPli()) {
rtcp_sender_.SendPli();
}
}

private:
FrameRecoveryController recovery_ctrl_;
RtcpSender rtcp_sender_;
};

class SenderControlLogic {
public:
void OnPliReceived() {
encoder_.RequestKeyFrame();
}

private:
VideoEncoder encoder_;
};

这段代码很短,但意义很大

它说明:

  • 接收端不是自己生成关键帧
  • 它只能通过反馈告诉发送端:赶紧刷新参考链
  • 真正产生关键帧的是发送端编码器

这就是控制面和媒体面的分工。


十五、第八段核心代码:FEC 的最小直觉实现骨架

FEC 真要完整展开会很深,但这篇里我先给你一个最小直觉版,重点是让你理解它怎么工作。

假设我们做一种非常简化的异或保护:

  • 每 4 个媒体包
  • 生成 1 个 parity 包
  • 如果恰好丢 1 个,可以用其他包 + parity 包恢复

15.1 发送端生成简化 FEC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class SimpleFecEncoder {
public:
std::optional<RtpPacket> AddMediaPacket(const RtpPacket& pkt) {
group_.push_back(pkt);
if (group_.size() < 4) {
return std::nullopt;
}

RtpPacket fec = BuildParityPacket(group_);
group_.clear();
return fec;
}

private:
RtpPacket BuildParityPacket(const std::vector<RtpPacket>& group) {
RtpPacket parity;
parity.is_fec = true;

size_t max_size = 0;
for (const auto& pkt : group) {
max_size = std::max(max_size, pkt.payload.size());
}
parity.payload.resize(max_size, 0);

for (const auto& pkt : group) {
for (size_t i = 0; i < pkt.payload.size(); ++i) {
parity.payload[i] ^= pkt.payload[i];
}
}
return parity;
}

private:
std::vector<RtpPacket> group_;
};

15.2 接收端尝试恢复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class SimpleFecDecoder {
public:
std::optional<RtpPacket> RecoverOneLost(
const std::vector<std::optional<RtpPacket>>& media_group,
const RtpPacket& fec_pkt) {

int lost_index = -1;
int lost_count = 0;
size_t max_size = fec_pkt.payload.size();

for (int i = 0; i < static_cast<int>(media_group.size()); ++i) {
if (!media_group[i].has_value()) {
lost_index = i;
++lost_count;
}
}

if (lost_count != 1) {
return std::nullopt;
}

RtpPacket recovered;
recovered.payload = fec_pkt.payload;

for (const auto& pkt_opt : media_group) {
if (!pkt_opt.has_value()) continue;
const auto& pkt = pkt_opt.value();
for (size_t i = 0; i < pkt.payload.size(); ++i) {
recovered.payload[i] ^= pkt.payload[i];
}
}
return recovered;
}
};

这段 FEC 代码最该看懂什么

不是异或细节本身,而是它背后的思路:

  • 冗余不是等丢了才要
  • 是在发送时提前埋进去
  • 这样接收端有机会不等重传就本地恢复

当然真实 FEC 会复杂很多,但这个骨架已经足够把核心直觉立起来。


十六、一张时序图看懂 NACK、PLI、FEC 三种恢复路径的区别

sequenceDiagram
    participant S as Sender
    participant N as Network
    participant R as Receiver

    S->>N: media packets
    S->>N: optional FEC packets
    N->>R: packets with some loss

    alt FEC can recover
        R->>R: local recovery from redundancy
    else Small loss and still in time
        R->>S: RTCP NACK
        S->>R: retransmission packet
    else Reference chain broken
        R->>S: RTCP PLI
        S->>R: next key frame
    end

这张图可以帮你直接抓住三条恢复路径:

  • FEC:本地先救
  • NACK:请求补洞
  • PLI:请求重建基线

十七、为什么三者不能互相完全替代

这个问题非常重要。

17.1 为什么不能只靠 NACK

因为:

  • RTT 大时补回来太慢
  • 连续丢包时补洞效率会变差
  • 某些关键参考一旦错过,后面即使补回来也未必赶得上解码时序

17.2 为什么不能只靠 PLI

因为:

  • 关键帧通常更大
  • 频繁请求关键帧会显著拉高码率和瞬时压力
  • 小范围丢包不值得动不动就“重开一局”

17.3 为什么不能只靠 FEC

因为:

  • 冗余要额外吃带宽
  • 冗余不是无限强
  • 丢包模式复杂时未必恢复得了
  • 对带宽本就紧张的场景,FEC 比例不能乱加

所以真实系统常见的思路往往是:

FEC 抵挡一部分即时小损失,NACK 补可来得及的缺口,PLI 负责在参考链断了之后快速重建。


十八、一个更像真实系统的恢复策略图

flowchart TD
    A[接收端发现丢包] --> B{丢包规模小且时延允许?}
    B -- 是 --> C[发NACK请求重传]
    B -- 否 --> D{FEC能本地恢复?}
    D -- 是 --> E[FEC恢复]
    D -- 否 --> F{参考链是否已损坏?}
    F -- 是 --> G[发PLI请求关键帧]
    F -- 否 --> H[继续观察/局部跳过]

这张图不是某个标准规定,而是一个很实用的工程脑图。

它提醒你:

恢复策略的核心不是“谁最高级”,而是“现在最划算的补救动作是什么”。


十九、最后给一句更像工程师的话:丢包恢复不是追求“全部救回”,而是追求“在时延预算内把体验尽量救回来”

写到这里,最想落下的一句话其实是:

实时音视频里的丢包恢复,不是追求每个包都完美拿回来,而是在有限时延预算内,把可交流体验尽量拉回来。

这是特别工程化的理解。

因为如果你把目标错设成:

  • 每个丢包都必须补到

那系统很容易走向:

  • 过度等待
  • 重传过多
  • 关键帧过频
  • 额外带宽被挤爆
  • 延迟越来越高

而如果你把目标设成:

  • 小损失尽量本地修或快速补
  • 参考坏了尽快重建
  • 带宽紧张时别过度加冗余
  • 整体以体验连续性优先

那这三种机制就会各自回到更合理的位置。

所以真正成熟的实时系统,考虑的不是:

  • “包有没有百分百救回”

而是:

  • “用户现在还能不能顺畅交流”

这才是更像工程师的最终指标。


二十、总结

最后把这篇收成几句话。

1. NACK、PLI、FEC 是三种不同时间尺度和代价模型下的恢复手段

  • NACK:适合快速补小洞
  • PLI:适合重建已损坏的参考链
  • FEC:适合提前带保险,减少对重传时延的依赖

2. 丢包恢复不是某个点的工作,而是发送端、接收端、反馈控制和解码链路一起协同

这也是为什么它天然是系统问题。

3. 核心代码最该看的是系统骨架

包括:

  • 丢包检测
  • NACK 生成
  • 重传缓存
  • PLI 触发
  • FEC 编码与恢复

4. 三者不能互相完全替代

真正成熟的系统通常是组合使用,而不是押宝单一机制。

5. 最终目标不是把每个包都救回来,而是在时延预算内把体验尽量救回来

这才是实时音视频里真正合理的恢复目标。

如果你愿意,下一篇我建议直接接:

《关键帧为什么又贵又重要:I 帧、IDR、GOP 与实时视频恢复链路的真实关系》

这篇可以把你前面写过的 I/P/B、GOP、PLI、关键帧请求这些东西,再真正并成一条更强的工程主线,而且我也会继续放代码。


NACK、PLI、FEC 到底怎么配合:丢包之后,实时音视频系统到底怎么补救
https://breaker505.github.io/2026/05/01/nack-pli-fec-how-realtime-media-recovers-from-loss/
作者
爱发呆的鱼
发布于
2026年5月1日
许可协议