mediasoup 实现了与 WebRTC 的 NACK 对接。由于暂时未实现 FEC,因此, NACK 对于 mediasoup 丢包恢复能力就至关重要了。
1. 发送端
1.1. 静态结构
从服务器的视角看,Consumer(以 SimpleConsumer 为例) 从 Producer 拉取(或被推送)数据使用 RtpStreamSend 发给客户端。RtpStreamSend 使用 RtpRetransmissionBuffer 缓存已发送报文,每一个报文是一个 Item。
1.2. 调用流程
下图展示了 NACK 报文处理的调用流程,逻辑非常清晰。NACK 报文从 Transport 来,经 Consumer 处理,又通过 Transport 将重传报文发送出去。
2. 接收端
2.1. 静态结构
从服务器角度看,Producer 从客户端收取数据,交给 RtpStreamRecv 处理。RtpStreamRecv 使用 NackGenerator 来处理 NACK 请求。NackGenerator 会自主发起 NACK 请求,NACK 请求会通过回调 RtpStream、Producer 发送出去。
NACK 请求项:
struct NackInfo
{
// 创建时间
uint64_t createdAtMs{ 0u };
// 报文序列号
uint16_t seq{ 0u };
// 触发第一次发送的最小序列号(没有实现)
uint16_t sendAtSeq{ 0u };
// 发送时间
uint64_t sentAtMs{ 0u };
// 发送次数
uint8_t retries{ 0u };
};
NackGenerator 的重要接口:
// 所有收到报文都要传递到这里处理
bool ReceivePacket(RTC::RtpPacket* packet, bool isRecovered);
// RTT 用来控制重传请求间隔,超过 1 个 RTT 还没有收到报文,可以继续请求重传
void UpdateRtt(uint32_t rtt);
NackGenerator 的重要属性:
// 保存 NACK 请求项(丢失的报文)
std::map<uint16_t, NackInfo, RTC::SeqManager<uint16_t>::SeqLowerThan> nackList;
// 保存关键帧报文,用来协助收缩 nackList(具体请参考 WebRTC 实现)
std::set<uint16_t, RTC::SeqManager<uint16_t>::SeqLowerThan> keyFrameList;
// 保存恢复报文,这里指 RTX 报文
std::set<uint16_t, RTC::SeqManager<uint16_t>::SeqLowerThan> recoveredList;
2.2. 调用流程
从 Transport 收到的报文回调到 Producer,Producer 通过 SSRC(或 RID)查找对应的 RtpStreamRecv,RtpStreamRecv 调用 NackGenerator 来处理 NACK。NackGenerator 使用报文和定时器两种方式驱动 NACK 请求的发送。RTCP 报文经过层层回调通过 Transport 发送出去。
2.3. 发起时机
发起 NACK 请求的限制条件:
1)NACK 请求项创建 10ms 后才允许发送(可以配置)。
2)达到最大请求次数删除 NACK 请求项。
发起 NACK 请求的触发条件:
1)序列号触发:当前收到的报文最大序列号大于设置的 sendAtSeq,且还未请求过,则可以发送。
2)定时器触发:还未发送过或上一次发送的时间距离现在超过一个 RTT。
std::vector<uint16_t> NackGenerator::GetNackBatch(NackFilter filter)
{
const uint64_t nowMs = DepLibUV::GetTimeMs();
std::vector<uint16_t> nackBatch;
auto it = this->nackList.begin();
while (it != this->nackList.end()) {
NackInfo& nackInfo = it->second;
const uint16_t seq = nackInfo.seq;
// static constexpr unsigned int SendNackDelay{ 10u }; // In ms.
// 限制创建后延迟多长时间发送
if (this->sendNackDelayMs > 0
&& nowMs - nackInfo.createdAtMs < this->sendNackDelayMs) {
++it;
continue;
}
// 基于序列号触发(第一次发送)
if (filter == NackFilter::SEQ && nackInfo.sentAtMs == 0
&& (nackInfo.sendAtSeq == this->lastSeq ||
SeqManager<uint16_t>::IsSeqHigherThan(this->lastSeq, nackInfo.sendAtSeq))) {
nackBatch.emplace_back(seq);
nackInfo.retries++;
nackInfo.sentAtMs = nowMs;
// 已经达到最大重传次数,移除请求项
if (nackInfo.retries >= MaxNackRetries) {
it = this->nackList.erase(it);
}
else {
++it;
}
continue;
}
// 基于时间戳触发
if (filter == NackFilter::TIME && (nackInfo.sentAtMs == 0 ||
nowMs - nackInfo.sentAtMs >= (this->rtt > 0u ? this->rtt : DefaultRtt))) {
nackBatch.emplace_back(seq);
nackInfo.retries++;
nackInfo.sentAtMs = nowMs;
// 已经达到最大重传次数,移除请求项
if (nackInfo.retries >= MaxNackRetries) {
it = this->nackList.erase(it);
}
else {
++it;
}
continue;
}
++it;
}
return nackBatch;
}
3. 与 WebRTC 实现差异
关于 WebRTC 的 NACK 实现请参考《深入浅出WebRTC—NACK》。
3.1. 序列号触发
mediasoup 创建 NACK 请求项时,设置的触发序列号就等于报文序列号。
this->nackList.emplace(std::make_pair(
seq,
NackInfo{
DepLibUV::GetTimeMs(),
seq,
seq, // 这里设置触发序列号等于丢失报文的序列号
}));
意味着绝大部分情况会立即发送(乱序情况除外),这显然是不合理的。mediasoup 使用另外一个机制来补偿,那就是请求项创建一定时间后才允许发送。
// static constexpr unsigned int SendNackDelay{ 10u }; // In ms.
// 限制创建后延迟多长时间发送
if (this->sendNackDelayMs > 0
&& nowMs - nackInfo.createdAtMs < this->sendNackDelayMs) {
++it;
continue;
}
WebRTC 使用直方图统计的乱序情况来设置触发序列号,相对来说更加合理。
// 使用 50% 分位序列号抖动值设置触发序列号
NackInfo nack_info(seq_num, seq_num + WaitNumberOfPackets(0.5), clock_->CurrentTime());
// 从直方图查询指定概率下的抖动值
int NackRequester::WaitNumberOfPackets(float probability) const {
if (reordering_histogram_.NumValues() == 0)
return 0;
return reordering_histogram_.InverseCdf(probability);
}
3.2. 恢复报文队列
由于 mediasoup 没有实现 FEC,其 recoveredList 中只会保存 RTX 报文,且是序列号大于 lastSeq(最近收到的最大报文序列号)。这就比较诡异了,RTX 报文的序列号怎么会大于 lastSeq?
if (isRecovered)
{
this->recoveredList.insert(seq);
// Remove old ones so we don't accumulate recovered packets.
auto it = this->recoveredList.lower_bound(seq - MaxPacketAge);
if (it != this->recoveredList.begin())
{
this->recoveredList.erase(this->recoveredList.begin(), it);
}
// Do not let a packet pass if it's newer than last seen seq and came via RTX.
return false;
}
WebRTC 实现逻辑类似,但是 WebRTC 存在 FEC 报文,是有可能存在通过 FEC 恢复报文的序列号大于 lastSeq 的情况。
if (is_recovered) {
recovered_list_.insert(seq_num);
// 恢复报文太多,清理下
auto it = recovered_list_.lower_bound(seq_num - kMaxPacketAge);
if (it != recovered_list_.begin())
recovered_list_.erase(recovered_list_.begin(), it);
// Do not send nack for packets recovered by FEC or RTX.
return 0;
}
WebRTC 使用恢复报文列表是因为恢复报文会影响乱序统计,而乱序统计又会影响触发序列号的确定。但 mediasoup 在没有 FEC 的前提下增加这样一个恢复报文队列的目的是什么没搞明白。
3.3. 缓存队列长度
mediasoup 发送端 NACK 缓存使用最大数量和最大延迟两个方面的限制。
// 最大缓存报文数
static constexpr size_t RetransmissionBufferMaxItems{ 2500u };
// 最大缓存时间,视频:2000ms,音频:1000ms
const uint32_t RtpStreamSend::MaxRetransmissionDelayForVideoMs{ 2000u };
const uint32_t RtpStreamSend::MaxRetransmissionDelayForAudioMs{ 1000u };
WebRTC 的限制更加复杂。外部可以设置一个小于 9600 的缓存数量限制,同时达到时间长度限制和缓存数量限制才会清理报文。除此之外,增加了两个安全限制条件:终极数量限制和终极时长限制,满足这两个条件中其中一个即需收缩队列。
// 终极数量限制(无视其他条件)
static constexpr size_t kMaxCapacity = 9600;
// 时长限制:max(1000ms, 3x rtt)
static constexpr TimeDelta kMinPacketDuration = TimeDelta::Seconds(1);
static constexpr int kMinPacketDurationRtt = 3;
// 终极时长限制:3倍 max(1000ms, 3x rtt)
static constexpr int kPacketCullingDelayFactor = 3;
4. 总结
本文分析了 mediasoup NACK 实现原理。可以看到,mediasoup NACK 实现与 WebRTC NACK 实现非常相似,但有一定程度的简化, 有理由认为借鉴了 WebRTC 实现。从代码角度看,mediasoup 的实现更加简洁,是一份优秀的 NACK 实现范本。