4、重传缓冲区(NACK Module)实现:PacketBuffer设计、RTP序列号管理、重传请求去重、超时丢弃策略
好,咱们今天来啃一块硬骨头——NACK Module 的实现。说白了,就是接收端发现丢包了,怎么高效地管理缓冲区,怎么发请求,怎么避免重复请求,以及什么时候该放弃。
我在做嵌入式 WebRTC 移植的时候,发现很多开发者把精力都放在编码和传输上,结果一跑起来,画面卡成幻灯片。一抓包,好家伙,NACK 风暴直接把网络打死了。嗯,缓冲区设计没做好,就是这个下场。
4.1 PacketBuffer 的核心设计
PacketBuffer 是 NACK 模块的心脏。它不只是一个简单的数组,而是一个带状态管理的环形缓冲区。我习惯把它设计成三个核心区域:
- 空闲区:还没被使用的槽位
- 待处理区:已经收到但还没被上层消费的包
- 已消费区:已经被上层取走的包,但保留元数据用于去重
你想想看,如果只用一个线性数组,序列号绕回的时候怎么办?所以必须用环形结构,配合模运算来定位。
关键设计指标:缓冲区大小必须是 2 的幂次方。为什么?因为取模运算可以用位运算替代,在嵌入式平台上能省下不少 CPU 周期。
// 我常用的 PacketBuffer 结构体
#define PACKET_BUFFER_SIZE 1024 // 必须是2的幂
#define PACKET_BUFFER_MASK (PACKET_BUFFER_SIZE - 1)
typedef struct {
uint16_t seq_num; // RTP序列号
uint8_t *data; // 包数据指针
uint32_t length; // 数据长度
uint32_t timestamp; // 接收时间戳
uint8_t state; // 0:空闲 1:待处理 2:已消费
uint8_t nack_count; // 被NACK请求的次数
} packet_slot_t;
typedef struct {
packet_slot_t slots[PACKET_BUFFER_SIZE];
uint16_t newest_seq; // 最新收到的序列号
uint16_t oldest_seq; // 最老的未消费序列号
uint32_t watermark_ms; // 超时水线(毫秒)
} packet_buffer_t;
这里有个坑,我踩过。最开始我把 newest_seq 和 oldest_seq 设计成 uint32_t,想着反正不会溢出。结果在 ARM Cortex-M4 上,32 位比较操作比 16 位慢了一倍。后来全部改成 uint16_t,配合序列号回绕检测,性能直接拉满。
4.2 RTP 序列号管理
RTP 序列号是 16 位的,范围 0~65535。超过就回绕。很多新手在这里翻车——直接用 seq1 > seq2 判断新旧,回绕后全乱套。
正确的做法是用有符号比较。WebRTC 源码里用的就是这个套路:
// 判断 a 是否比 b 更新(考虑回绕)
static inline int seq_newer_than(uint16_t a, uint16_t b) {
return (int16_t)(a - b) > 0;
}
// 判断 a 是否比 b 更旧
static inline int seq_older_than(uint16_t a, uint16_t b) {
return (int16_t)(a - b) < 0;
}
// 计算两个序列号之间的差值(考虑回绕)
static inline uint16_t seq_distance(uint16_t a, uint16_t b) {
return (uint16_t)(a - b);
}
我个人习惯在插入包时做三步检查:
- 重复包检测:如果槽位状态不是空闲,说明已经收过了,直接丢弃
- 乱序包处理:如果序列号比
newest_seq旧,但还在缓冲区范围内,正常插入 - 超老包丢弃:如果序列号比
oldest_seq还旧,说明已经超时了,直接扔掉
避坑指南:我曾经在乱序包处理上吃过亏。有个客户反馈视频偶尔花屏,查了两天才发现是乱序包插入时没有更新 newest_seq,导致后续的 NACK 请求全部算错。记住:只有比当前 newest_seq 更新的包,才需要更新这个指针。
4.3 重传请求去重
NACK 去重是个老生常谈的问题,但真正做好的不多。我见过最粗暴的做法是:收到一个包就发一个 NACK,结果网络里全是重复请求。
去重的核心思路是:同一个序列号,在指定时间窗口内只发一次 NACK。具体实现我分三层:
| 层级 | 策略 | 实现方式 |
|---|---|---|
| 第一层 | 槽位状态去重 | 检查 slot.state,已消费或待处理的包不发 NACK |
| 第二层 | NACK 计数去重 | 每个 slot 记录 nack_count,超过阈值(如3次)不再请求 |
| 第三层 | 时间窗口去重 | 记录上次 NACK 时间戳,间隔小于 20ms 的请求直接忽略 |
这里我贴一段实际项目中的去重逻辑:
int should_send_nack(packet_buffer_t *buf, uint16_t seq) {
uint32_t idx = seq & PACKET_BUFFER_MASK;
packet_slot_t *slot = &buf->slots[idx];
// 第一层:槽位状态检查
if (slot->state != 0) {
return 0; // 已经收到了,不需要NACK
}
// 第二层:NACK次数限制
if (slot->nack_count >= MAX_NACK_RETRIES) {
return 0; // 已经重试太多次了,放弃
}
// 第三层:时间窗口检查
uint32_t now = get_time_ms();
if (now - slot->last_nack_time < NACK_INTERVAL_MS) {
return 0; // 发得太频繁了,等一等
}
return 1; // 可以发NACK
}
注意:MAX_NACK_RETRIES 不要设太大。我一般设 3~5 次。设大了会引发 NACK 风暴,设小了丢包恢复不了。这个值跟网络 RTT 强相关,建议做成可配置的。
4.4 超时丢弃策略
不是所有丢包都值得重传。如果一个包超时太久还没到,说明网络已经放弃它了,继续等只会增加延迟。
超时丢弃的核心是水线机制。我设计了一个滑动时间窗口:
- 硬水线:绝对超时时间,比如 500ms。超过这个时间的包,直接丢弃。
- 软水线:相对超时时间,根据当前网络 RTT 动态调整。RTT 越大,水线越长。
具体实现时,我每收到一个包就检查 oldest_seq 指向的槽位:
void check_timeout(packet_buffer_t *buf) {
uint32_t now = get_time_ms();
uint16_t seq = buf->oldest_seq;
while (seq != buf->newest_seq) {
uint32_t idx = seq & PACKET_BUFFER_MASK;
packet_slot_t *slot = &buf->slots[idx];
if (slot->state == 0) {
// 这个包还没收到,检查是否超时
if (now - slot->last_nack_time > buf->watermark_ms) {
// 超时了,标记为已消费,上层会收到一个丢包通知
slot->state = 2;
slot->data = NULL;
slot->length = 0;
// 记录丢包统计
buf->stats.packet_lost++;
} else {
break; // 还没超时,后面的包更不可能超时
}
} else if (slot->state == 2) {
// 已消费的包,直接跳过
seq++;
continue;
}
seq++;
}
buf->oldest_seq = seq;
}
这里有个细节:为什么用 while 循环而不是 for?因为一次可能超时多个包。比如网络突然断了 200ms,恢复后可能有一批包都超时了。一次性清理干净,避免后续处理卡住。
经验之谈:水线值怎么设?我一般用 RTT × 3 + 100ms 作为基准。比如 RTT 是 50ms,水线就是 250ms。如果丢包率超过 5%,我会动态增加水线,给重传更多时间。但上限不超过 1000ms,否则延迟就不可接受了。
4.5 整体架构图
说了这么多,咱们用一张图把整个 NACK Module 的流程串起来:
4.6 性能优化与调试要点
最后,分享几个我在调试 NACK Module 时常用的技巧:
- 打日志要克制:NACK 模块是高频路径,每发一个 NACK 就打日志,CPU 直接被打满。我一般只统计计数,出问题时再 dump。
- 用环形缓冲区做调试记录:保留最近 1000 个 NACK 事件的环形日志,出问题时直接拉出来分析。
- 监控三个指标:NACK 发送率(每秒多少个)、NACK 重传成功率、缓冲区水位(当前待处理包数量)。这三个指标能快速定位问题。
调试口诀:NACK 风暴看去重,丢包恢复看水线,缓冲区满看超时。记住这三句话,90% 的问题都能快速定位。
嗯,NACK Module 的实现就讲到这里。代码量不大,但细节很多。每个设计决策背后都有实际项目的血泪教训。希望你能把这些经验用在自己的项目里,少走一些弯路。