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);
}

我个人习惯在插入包时做三步检查:

  1. 重复包检测:如果槽位状态不是空闲,说明已经收过了,直接丢弃
  2. 乱序包处理:如果序列号比 newest_seq 旧,但还在缓冲区范围内,正常插入
  3. 超老包丢弃:如果序列号比 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 的流程串起来:

NACK Module 整体架构与数据流 RTP 包输入 序列号管理 PacketBuffer NACK 去重 超时丢弃 NACK 发送 上层消费 网络重传 核心流程说明: 1. RTP包进入后,先经过序列号管理模块,判断是新包、乱序包还是重复包 2. 合法包存入PacketBuffer,同时触发NACK去重检查 3. 去重通过后发送NACK请求,等待网络重传 4. 超时丢弃模块定期清理过期包,避免缓冲区阻塞

4.6 性能优化与调试要点

最后,分享几个我在调试 NACK Module 时常用的技巧:

  • 打日志要克制:NACK 模块是高频路径,每发一个 NACK 就打日志,CPU 直接被打满。我一般只统计计数,出问题时再 dump。
  • 用环形缓冲区做调试记录:保留最近 1000 个 NACK 事件的环形日志,出问题时直接拉出来分析。
  • 监控三个指标:NACK 发送率(每秒多少个)、NACK 重传成功率、缓冲区水位(当前待处理包数量)。这三个指标能快速定位问题。

调试口诀:NACK 风暴看去重,丢包恢复看水线,缓冲区满看超时。记住这三句话,90% 的问题都能快速定位。

嗯,NACK Module 的实现就讲到这里。代码量不大,但细节很多。每个设计决策背后都有实际项目的血泪教训。希望你能把这些经验用在自己的项目里,少走一些弯路。