13、Pacing与发送平滑:Pacer的作用、漏桶算法、令牌桶算法、GCC与Pacer的配合

各位好,我是你们的WebRTC老司机。今天咱们聊聊Pacer,也就是发送平滑模块。

说实话,我刚开始接触WebRTC的时候,觉得Pacer就是个“限速器”,没啥技术含量。后来被线上问题狠狠教育了一回——那次视频通话卡得跟幻灯片似的,用户投诉电话都打爆了。我排查了半天,发现罪魁祸首就是发送端突发数据太多,把网络缓冲区撑爆了。嗯,从那以后,我再也不敢小看Pacer了。

Pacer到底在解决什么问题?

你想想看,视频编码器出来的数据是“一阵一阵”的。比如一帧画面编码完,突然丢出几十KB甚至几百KB的数据。如果这时候直接往网络上怼,会发生什么?

  • 网络缓冲区瞬间被填满,丢包率飙升
  • RTT(往返时延)剧烈抖动,拥塞控制算法直接懵圈
  • 接收端缓冲区也扛不住,视频播放一卡一卡的

说白了,Pacer的作用就是把“暴饮暴食”变成“少食多餐”。它把编码器吐出来的大块数据,拆成小包,按照一个平稳的速率发送出去。这样网络就不会被突然的流量峰值冲垮。

核心目标:将突发流量整形为平滑的发送流,避免网络拥塞和缓冲区膨胀。

漏桶算法 vs 令牌桶算法

Pacer的实现,说白了就两种经典算法:漏桶和令牌桶。我在项目中两种都折腾过,各有各的脾气。

漏桶算法(Leaky Bucket)

漏桶的思路很简单:想象一个桶,底部有个小洞。数据像水一样倒进去,不管倒得多猛,流出去的速度永远是小洞限定的速率。桶满了,再倒水就溢出了(丢包)。

我个人的习惯是,用漏桶做严格的速率限制。比如你设定发送速率是1Mbps,那漏桶保证平均速率绝对不会超过1Mbps。但它的缺点也很明显——如果桶里没水了,发送速率会瞬间降到0,造成“饥饿”现象。

// 漏桶算法伪代码(简化版)
class LeakyBucket {
  double capacity;      // 桶容量(字节)
  double leakRate;      // 漏水速率(字节/秒)
  double water;         // 当前水量
  int64_t lastTime;     // 上次漏水时间

  bool canSend(int packetSize) {
    int64_t now = getTimeMs();
    double elapsed = (now - lastTime) / 1000.0;
    water = max(0.0, water - elapsed * leakRate);  // 先漏水
    lastTime = now;

    if (water + packetSize <= capacity) {
      water += packetSize;
      return true;  // 可以发送
    } else {
      return false; // 桶满了,丢弃或排队
    }
  }
};

避坑指南:我曾经在低码率场景下(比如30kbps)用漏桶,结果发现发送端频繁“断流”。原因是桶容量设得太小,数据稍微积压就溢出了。后来我把容量调大到码率的2倍,问题才解决。

令牌桶算法(Token Bucket)

令牌桶就灵活多了。它不直接限制数据流,而是限制“令牌”的产生速率。每个数据包发送前,必须先拿到足够数量的令牌。令牌以恒定速率生成,但可以累积(最多到桶容量)。

令牌桶的好处是:允许短时间内的突发。比如你平时码率1Mbps,但突然有一帧关键帧数据量很大,只要桶里攒了足够的令牌,就可以一次性发出去。这在视频通话中非常实用——关键帧不能丢,否则画面会花屏好几秒。

// 令牌桶算法伪代码
class TokenBucket {
  double maxTokens;     // 桶容量(令牌数)
  double fillRate;      // 令牌生成速率(令牌/秒)
  double tokens;        // 当前令牌数
  int64_t lastTime;     // 上次填充时间

  bool canSend(int packetSize) {
    int64_t now = getTimeMs();
    double elapsed = (now - lastTime) / 1000.0;
    tokens = min(maxTokens, tokens + elapsed * fillRate); // 先填充令牌
    lastTime = now;

    if (tokens >= packetSize) {
      tokens -= packetSize;
      return true;  // 有足够令牌,发送
    } else {
      return false; // 令牌不足,等待
    }
  }
};

注意:令牌桶的桶容量不能设得太大。否则长时间不发送数据,桶里攒了一堆令牌,突然一股脑全发出去,跟没做Pacing一样。我一般把桶容量设为码率的1~2倍(以字节计)。

GCC与Pacer的配合

GCC(Google Congestion Control)是WebRTC的拥塞控制核心。它根据网络状况动态调整目标码率。但GCC只负责“定目标”,真正执行发送的是Pacer。

它们俩的配合,说白了就是一句话:GCC定速,Pacer执行

具体流程是这样的:

  1. GCC的基于延迟的控制器(Delay-based Controller)和基于丢包的控制器(Loss-based Controller)各自估算出一个码率。
  2. 取两者的最小值,作为最终的目标码率。
  3. 这个目标码率被喂给Pacer,作为它的发送速率上限。
  4. Pacer按照这个速率,把编码后的数据包平滑地发送出去。

我在项目中遇到过一个问题:GCC突然把码率从2Mbps降到200kbps,但Pacer的桶里还积压着大量数据。结果Pacer继续以2Mbps的速率发送积压数据,导致网络拥塞加剧。后来我加了一个逻辑:当GCC下调码率时,Pacer必须立即清空或丢弃部分积压数据,否则拥塞控制就是个摆设。

关键配合点:Pacer的发送速率必须实时跟随GCC的码率调整,不能有滞后。否则拥塞控制算法会“误判”网络状况。

Pacer在WebRTC中的实际实现

WebRTC的Pacer实现(位于modules/pacing/目录)用的是令牌桶+优先级队列的组合。它把数据包分成不同的优先级(音频最高,视频次之,FEC和重传包最低),然后按照优先级和令牌桶的约束来发送。

我记得有一次调试一个音频卡顿的问题,发现Pacer把音频包和视频包混在一起发送,导致音频包被视频包“堵”住了。解决方案是:音频包走独立的Pacer队列,并且优先级设为最高。这样即使视频码率再高,音频包也能优先发送。

下面这张图展示了Pacer在WebRTC发送链路中的位置和内部结构:

WebRTC发送链路与Pacer结构 视频编码器 音频编码器 Pacer(发送平滑) 令牌桶 优先级队列 发送速率 = GCC目标码率 网络 (UDP/IP) GCC拥塞控制 基于延迟 + 基于丢包 目标码率 RTT/丢包/延迟 图例: 编码器/网络模块 Pacer模块 GCC模块 反馈路径

总结一下

Pacer不是可有可无的“装饰品”,它是WebRTC发送链路中的交通警察。没有它,GCC再聪明也没用——因为突发流量会让网络瞬间恶化,GCC根本来不及反应。

我个人建议,如果你在调优WebRTC的发送性能,先检查Pacer的配置:

  • 令牌桶容量是否合理?
  • 优先级队列是否区分了音频和视频?
  • Pacer的发送速率是否实时跟随GCC的调整?

这三个点搞定,你的视频通话流畅度至少提升一个档次。

最后一个小技巧:在WebRTC的日志中搜索“Pacer”,看看有没有“Overuse”或“Underuse”的警告。如果有,说明Pacer和GCC的配合出了问题,需要调整参数。


公众号:蓝海资料掘金营,微信deep3321