15、数据通道调试:使用 datachannel 的 bufferedAmount 和 onmessage 事件调试数据传输

数据通道(DataChannel)这东西,说白了就是 WebRTC 里的一条“隐形隧道”。你可以在两个浏览器之间直接传文本、传文件、传二进制数据,完全不需要经过服务器中转。听起来很爽对吧?但实际用起来,坑也不少。

我记得第一次做数据通道项目时,客户反馈说“文件传着传着就断了”,我查了半天日志,发现既不是网络问题,也不是信令问题。后来才意识到——是数据发送太快,接收端来不及处理,缓冲区爆了。嗯,从那以后,我就对 bufferedAmountonmessage 这两个东西特别上心。

15.1 数据通道的基本模型

先画个图,帮你理解数据通道的通信模型。别小看这张图,调试的时候你脑子里得有这个结构,才知道问题出在哪一环。

数据通道通信模型 发送端 (Peer A) 应用层数据 bufferedAmount 缓冲区 SCTP 协议层 ICE + DTLS 网络传输 接收端 (Peer B) ICE + DTLS SCTP 协议层 onmessage 事件 应用层处理 核心调试点:bufferedAmount(发送缓冲区水位) + onmessage(接收事件)

你看这个图,发送端的数据先进入 bufferedAmount 缓冲区,然后经过 SCTP 协议层、ICE 和 DTLS 加密通道,传到接收端。接收端通过 onmessage 事件把数据捞出来。任何一个环节出问题,数据就传不过去。

15.2 bufferedAmount:你的“水位计”

bufferedAmount 是什么?说白了就是“当前还有多少数据没发出去”。单位是字节。你每次调用 send() 方法,数据不会立刻飞到对方那里,而是先排队。这个队列的长度,就是 bufferedAmount

核心原则:发送数据前,先检查 bufferedAmount。如果它已经很大了,就别再往里塞了,否则数据会丢失或通道会关闭。

我在项目中遇到过这样一个场景:一个实时协作工具,用户同时拖拽多个文件。代码里直接循环调用 send(),结果小文件没问题,大文件传着传着就断了。为什么?因为 bufferedAmount 超过了阈值,浏览器直接报错。

15.2.1 如何监控 bufferedAmount

最简单的做法,就是定时打印 bufferedAmount 的值。我习惯在发送大文件时,每发送一个 chunk 就检查一次。

// 创建数据通道
const dataChannel = peerConnection.createDataChannel('fileTransfer');

// 发送大文件时的监控
function sendChunk(data) {
  // 检查缓冲区水位
  console.log(`当前缓冲区: ${dataChannel.bufferedAmount} 字节`);
  
  if (dataChannel.bufferedAmount < 65536) { // 64KB 阈值
    dataChannel.send(data);
  } else {
    // 缓冲区太满,等待
    console.warn('缓冲区已满,等待...');
    // 可以在这里做排队或延迟处理
  }
}

我的经验:阈值设多少合适?我个人习惯用 64KB 或 128KB。太小了浪费带宽,太大了容易爆。你可以根据实际网络状况动态调整。

15.2.2 bufferedamountlow 事件

除了手动检查,浏览器还提供了一个事件——bufferedamountlow。你可以设置一个“低水位线”,当缓冲区降到这个线以下时,事件就会触发。

// 设置低水位线为 32KB
dataChannel.bufferedAmountLowThreshold = 32768;

// 监听低水位事件
dataChannel.addEventListener('bufferedamountlow', () => {
  console.log('缓冲区已降到安全水位,可以继续发送');
  // 在这里触发下一批数据的发送
  sendNextBatch();
});

这个事件特别适合做“流控”。你想想看,如果每次 send() 都检查 bufferedAmount,代码会变得很啰嗦。用事件驱动的方式,代码更干净,性能也更好。

注意:bufferedamountlow 事件只在 bufferedAmount 从高于阈值降到低于阈值时触发一次。如果你连续发送,它不会重复触发。所以配合一个“待发送队列”来使用效果最好。

15.3 onmessage:接收端的“耳朵”

接收端靠 onmessage 事件来接收数据。这个事件看似简单,但调试时最容易出问题的地方恰恰在这里。

15.3.1 基本用法

dataChannel.onmessage = (event) => {
  const data = event.data;
  console.log(`收到数据,类型: ${typeof data}, 大小: ${data.size || data.length} 字节`);
  
  // 根据数据类型做不同处理
  if (data instanceof ArrayBuffer) {
    // 二进制数据
    handleBinaryData(data);
  } else if (typeof data === 'string') {
    // 文本数据
    handleTextData(data);
  } else {
    console.warn('未知数据类型:', data);
  }
};

15.3.2 调试 onmessage 的常见问题

我曾经遇到过一个很诡异的问题:接收端偶尔会漏掉一些数据包。查了半天,发现是 onmessage 回调里做了异步操作,导致处理顺序乱了。

为什么会这样?因为 onmessage 是同步触发的,但如果你在里面用了 setTimeoutfetch,数据处理的顺序就无法保证。特别是当数据包有依赖关系时,乱序处理会导致逻辑错误。

避坑指南:我曾经在 onmessage 里直接调用 await 处理数据,结果后面的数据包先到了,前面的还没处理完。解决方案是:用队列把收到的数据先存起来,再按顺序处理。

// 用队列保证处理顺序
const messageQueue = [];
let isProcessing = false;

dataChannel.onmessage = (event) => {
  messageQueue.push(event.data);
  if (!isProcessing) {
    processQueue();
  }
};

async function processQueue() {
  isProcessing = true;
  while (messageQueue.length > 0) {
    const data = messageQueue.shift();
    await handleData(data); // 异步处理
  }
  isProcessing = false;
}

15.4 实战:文件传输的流控与调试

光说不练假把式。我们来看一个完整的文件传输示例,把 bufferedAmountonmessage 结合起来用。

15.4.1 发送端代码

const CHUNK_SIZE = 16384; // 16KB 每块
const THRESHOLD = 65536;  // 64KB 缓冲区阈值

function sendFile(dataChannel, file) {
  const reader = new FileReader();
  let offset = 0;
  let totalSent = 0;
  
  reader.onload = (e) => {
    const chunk = e.target.result;
    
    // 流控检查
    if (dataChannel.bufferedAmount > THRESHOLD) {
      console.log(`缓冲区水位过高 (${dataChannel.bufferedAmount}), 等待...`);
      // 等待 bufferedamountlow 事件
      dataChannel.addEventListener('bufferedamountlow', () => {
        sendChunk(dataChannel, chunk, offset, file.size);
      }, { once: true });
    } else {
      sendChunk(dataChannel, chunk, offset, file.size);
    }
    
    offset += CHUNK_SIZE;
    totalSent += chunk.byteLength;
    
    // 继续读取下一块
    if (offset < file.size) {
      readNextChunk(reader, file, offset);
    } else {
      console.log(`文件发送完成,共 ${totalSent} 字节`);
    }
  };
  
  readNextChunk(reader, file, 0);
}

function sendChunk(dc, chunk, offset, totalSize) {
  // 发送前记录
  console.log(`发送块: offset=${offset}, size=${chunk.byteLength}, bufferedAmount=${dc.bufferedAmount}`);
  dc.send(chunk);
}

function readNextChunk(reader, file, offset) {
  const slice = file.slice(offset, offset + CHUNK_SIZE);
  reader.readAsArrayBuffer(slice);
}

15.4.2 接收端代码

let receivedBuffers = [];
let expectedSize = 0;

dataChannel.onmessage = (event) => {
  const data = event.data;
  
  if (data instanceof ArrayBuffer) {
    // 文件数据块
    receivedBuffers.push(data);
    const totalReceived = receivedBuffers.reduce((sum, buf) => sum + buf.byteLength, 0);
    console.log(`收到数据块: ${data.byteLength} 字节, 累计: ${totalReceived} 字节`);
    
    // 检查是否接收完成
    if (expectedSize > 0 && totalReceived >= expectedSize) {
      assembleFile(receivedBuffers);
    }
  } else if (typeof data === 'string') {
    // 元数据(文件名、大小等)
    const meta = JSON.parse(data);
    if (meta.type === 'fileInfo') {
      expectedSize = meta.size;
      console.log(`准备接收文件: ${meta.name}, 大小: ${meta.size} 字节`);
    }
  }
};

function assembleFile(buffers) {
  const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0);
  const result = new Uint8Array(totalLength);
  let offset = 0;
  
  for (const buf of buffers) {
    result.set(new Uint8Array(buf), offset);
    offset += buf.byteLength;
  }
  
  console.log(`文件重组完成,共 ${totalLength} 字节`);
  // 这里可以创建 Blob 并下载
}

15.5 调试技巧汇总

最后,我把这些年调试数据通道的经验整理成一张表,方便你快速定位问题。

现象 可能原因 调试方法
数据发送后对方收不到 bufferedAmount 溢出,数据被丢弃 打印 bufferedAmount 日志,检查是否超过阈值
接收端数据顺序错乱 onmessage 中做了异步操作 使用队列保证顺序处理
大文件传输中途断开 缓冲区持续满,通道超时关闭 设置 bufferedamountlow 事件做流控
小文件传输正常,大文件失败 单次 send 数据量过大 分块发送,每块不超过 64KB
接收端内存持续增长 未及时处理收到的数据 检查 onmessage 回调是否被阻塞

我的调试三板斧:

  1. send() 前后各打印一次 bufferedAmount
  2. onmessage 里打印数据序号和大小
  3. 用 Chrome 的 chrome://webrtc-internals 查看 SCTP 统计

这三招能解决 90% 的数据通道问题。

数据通道调试,说白了就是管好“发送端的水龙头”和“接收端的水桶”。bufferedAmount 告诉你水龙头开多大不会溢出来,onmessage 告诉你水桶接得及不及时。两个配合好了,数据传输就稳了。

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