数据通道的流控制与背压: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,只发关键数据
五、知识体系图
下面这张图总结了数据通道流控制的完整逻辑:
六、避坑指南
最后分享几个我踩过的坑:
我曾经犯过的错误:
- 忽略 bufferedAmount 的初始值:刚创建通道时,bufferedAmount 是 0,但此时通道可能还没准备好。一定要等
open事件后再发送。 - 在 bufferedamountlow 事件里无限递归:如果你在事件处理函数里又调用了
send(),而阈值设得太低,可能触发新的事件,形成死循环。 - 阈值设得太大:设成 1MB 的话,等事件触发时,缓冲区里可能已经积压了大量数据,接收端压力很大。
- 忘记处理通道关闭:通道关闭后,
bufferedAmount不再更新,积压的数据会丢失。记得监听close事件做清理。
好了,这节课的内容就是这些。流控制看起来简单,但做扎实了,能避免很多线上问题。下次你写数据通道代码时,记得先问问自己:缓冲区水位控制好了吗?
一句话总结:
bufferedAmount 是水位计,bufferedamountlow 是警报器,发送窗口管理是水龙头。三者配合,才能让数据通道稳定运行。
公众号:蓝海资料掘金营,微信deep3321