数据通道的流控制与背压:bufferedAmount 属性、bufferedamountlow 事件、发送窗口管理

各位同学,今天我们来聊聊数据通道里一个特别容易踩坑的话题——流控制与背压。

说实话,我刚开始做 WebRTC 的时候,觉得数据通道不就是个「高级版 WebSocket」嘛,发就完了。结果第一次上线,用户反馈视频通话时文字消息经常丢失,甚至整个页面卡死。排查了半天,问题就出在——我根本没管发送缓冲区。

嗯,这节课我们就把它彻底讲清楚。

一、为什么需要流控制?

数据通道底层用的是 SCTP 协议。它确实有拥塞控制,但那是传输层的机制。到了应用层,你如果一股脑往通道里塞数据,缓冲区会爆。

你想想看,发送端拼命发,接收端处理不过来,数据就会在发送缓冲区堆积。堆积到一定程度,浏览器会直接丢弃数据包,或者干脆断开连接。

我在项目中遇到过最典型的情况:一个实时协作白板应用,用户快速画了几十条线,每条线都通过数据通道发送坐标点。结果后面画的线全丢了,用户以为程序有 bug。

其实不是 bug,是背压没处理好。

二、bufferedAmount 属性——你的缓冲区水位计

每个 RTCDataChannel 对象都有一个 bufferedAmount 属性。它表示当前排队等待发送的字节数。

说白了,就是你的数据通道里还有多少数据没发出去。

核心概念:

  • bufferedAmount 是只读属性,单位是字节
  • 它包含所有已排队但尚未发送到网络层的数据
  • 每次调用 send() 后,这个值会增加
  • 数据真正发出后,这个值会减少

来看一个简单的监控示例:

const channel = peerConnection.createDataChannel('myChannel');

// 每次发送后检查缓冲区
function sendData(data) {
  const before = channel.bufferedAmount;
  channel.send(data);
  const after = channel.bufferedAmount;
  
  console.log(`发送前: ${before} 字节, 发送后: ${after} 字节`);
  
  if (after > 65536) { // 64KB
    console.warn('缓冲区较大,建议暂停发送');
  }
}

我个人习惯在发送重要数据前,先检查一下 bufferedAmount。如果超过某个阈值,就等一等再发。

三、bufferedamountlow 事件——缓冲区低水位通知

光靠轮询 bufferedAmount 太累了。WebRTC 提供了一个事件:bufferedamountlow

你可以设置一个低水位阈值 bufferedAmountLowThreshold。当 bufferedAmount 降到这个阈值以下时,事件就会触发。

这个设计很巧妙。你想想看:

  • 当缓冲区水位高时,你暂停发送
  • 当水位降到阈值以下,事件通知你「可以继续发了」
  • 这就像水龙头和水池的关系

我的经验:阈值设置多少合适?

我一般设为 0。这样每次缓冲区清空时都会触发事件,控制最精细。但如果你的数据量很大,可以设成 16KB 或 64KB,减少事件频率。

代码示例:

const channel = peerConnection.createDataChannel('controlChannel');

// 设置低水位阈值
channel.bufferedAmountLowThreshold = 0;

// 监听低水位事件
channel.addEventListener('bufferedamountlow', () => {
  console.log('缓冲区已清空,可以继续发送');
  flushPendingData(); // 发送积压的数据
});

// 发送数据时控制节奏
function sendWithBackpressure(data) {
  const maxBuffer = 65536; // 64KB
  
  if (channel.bufferedAmount > maxBuffer) {
    // 缓冲区满了,先缓存起来
    pendingQueue.push(data);
    console.log(`缓冲区 ${channel.bufferedAmount} 字节,暂停发送`);
    return;
  }
  
  channel.send(data);
}

function flushPendingData() {
  while (pendingQueue.length > 0) {
    const data = pendingQueue.shift();
    if (channel.bufferedAmount > 65536) {
      // 又满了,剩下的下次再发
      pendingQueue.unshift(data);
      break;
    }
    channel.send(data);
  }
}

我曾经在一个文件传输工具里用过这个模式。用户拖入一个大文件,程序分片发送。如果不加控制,几百个分片瞬间塞进缓冲区,浏览器直接罢工。用了 bufferedamountlow 事件后,发送变得平滑,接收端也能稳定处理。

四、发送窗口管理——实战中的背压策略

光有事件还不够,我们需要一套完整的发送窗口管理策略。说白了,就是决定「什么时候发、发多少、发多快」。

我总结了一套实用的策略,分为三个层次:

1. 静态窗口限制

设定一个硬性上限,比如缓冲区不超过 256KB。超过就排队。

const MAX_BUFFER = 256 * 1024; // 256KB

function canSend(channel) {
  return channel.bufferedAmount < MAX_BUFFER;
}

2. 动态窗口调整

根据接收端的处理能力动态调整。怎么知道接收端的能力?可以通过另一个数据通道发送反馈消息。

动态窗口示例:

// 发送端
let sendWindow = 64 * 1024; // 初始窗口 64KB

// 接收端反馈处理能力
feedbackChannel.addEventListener('message', (e) => {
  const feedback = JSON.parse(e.data);
  if (feedback.type === 'window_update') {
    sendWindow = feedback.newWindow;
    console.log(`窗口更新为: ${sendWindow} 字节`);
  }
});

// 发送时使用当前窗口
function sendWithDynamicWindow(data) {
  if (channel.bufferedAmount + data.byteLength > sendWindow) {
    pendingQueue.push(data);
    return;
  }
  channel.send(data);
}

3. 自适应速率控制

结合 RTT(往返时间)和丢包率来调整发送速率。这个比较复杂,但效果最好。

我曾在视频会议系统中实现过:

  • 如果 RTT 小于 100ms,全速发送
  • RTT 在 100-300ms 之间,降速 50%
  • RTT 超过 300ms,只发关键数据

五、知识体系图

下面这张图总结了数据通道流控制的完整逻辑:

数据通道流控制与背压知识体系 发送端应用 发送缓冲区 SCTP / 网络 send() 传输 bufferedAmount bufferedamountlow 低水位阈值 静态窗口限制 动态窗口调整 自适应速率控制 目标:平滑发送 · 避免丢包 · 稳定连接

六、避坑指南

最后分享几个我踩过的坑:

我曾经犯过的错误:

  • 忽略 bufferedAmount 的初始值:刚创建通道时,bufferedAmount 是 0,但此时通道可能还没准备好。一定要等 open 事件后再发送。
  • 在 bufferedamountlow 事件里无限递归:如果你在事件处理函数里又调用了 send(),而阈值设得太低,可能触发新的事件,形成死循环。
  • 阈值设得太大:设成 1MB 的话,等事件触发时,缓冲区里可能已经积压了大量数据,接收端压力很大。
  • 忘记处理通道关闭:通道关闭后,bufferedAmount 不再更新,积压的数据会丢失。记得监听 close 事件做清理。

好了,这节课的内容就是这些。流控制看起来简单,但做扎实了,能避免很多线上问题。下次你写数据通道代码时,记得先问问自己:缓冲区水位控制好了吗?

一句话总结:

bufferedAmount 是水位计,bufferedamountlow 是警报器,发送窗口管理是水龙头。三者配合,才能让数据通道稳定运行。


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