# RTC 入门 05：带宽没跟上，声音为什么越排越晚

作者：Hex4C59 · 发布于 2026-10-15

原文：https://hex4c59.cc/articles/rtc-05-send-queue/


手机一直在采集声音，编码器也一直成功输出，发送日志里没有明显报错。通话开始时接话还算及时，过一会儿，对方却越来越晚才听到你说的话。

这种延迟可以发生在包丢失以前：新数据产生得比路径处理得快，暂时发不走的内容就排在前面。[第四篇](https://hex4c59.cc/articles/rtc-04-loss-recovery/)讨论怎样补回缺失声音；这次先检查原包和恢复数据有没有挤进同一条越来越长的队列。

本篇沿用前面的 20 毫秒音频块。需要知道样本、编码数据和网络包的区别，也需要第一篇的播放期限概念；不会写程序仍能跟着计算。读完应当能够算完整发送预算，判断队列会增长、保持还是回落，并区分降低编码码率与控制发送节奏。

文中的容量、包长和时刻均为教学设定。Python 实验只推进一条计算时间轴，不采集声音、不发网络包，也不测任何设备或网络的真实性能。

## 32 kbit/s 的声音，为什么用掉 48 kbit/s

**码率**表示每秒对应多少比特数据；1 字节是 8 比特，本篇使用十进制单位，1 kbit/s 等于每秒 1000 比特。先沿用第二篇的预算：编码音频为 32 kbit/s，每包覆盖 20 毫秒声音。

```text
每秒包数 = 1000 ÷ 20 = 50 包
每包编码音频 = 32,000 ÷ 50 ÷ 8 = 80 字节
```

80 字节只包含编码音频。沿用[第三篇的报头条件](https://hex4c59.cc/articles/rtc-03-sequence-gap/)，每包还有 20 字节 IPv4 头、8 字节 UDP 头和 12 字节 RTP 固定头，共 40 字节。这里没有 IP 选项、RTP 扩展或安全附加字段：

```text
每个 IP 包 = 80 + 40 = 120 字节
每秒 IP 数据 = 120 × 50 = 6000 字节
IP 层码率 = 6000 × 8 = 48,000 bit/s = 48 kbit/s
```

“IP 层”是本次记账的边界：把完整 IP 包都算进去，尚未计入其外面的链路层封装。接下来给出的传输能力，也必须使用这个边界，才能比较大小。

本文说的**可用传输能力**，是这条流在当前路径上能够使用的处理速率，不是套餐宣传的宽带速率，也不是路由器无线连接显示的最高速率。为了先看清因果，假设它固定为 IP 层的 40 kbit/s，只服务本文这条流。

于是，编码器虽然只产生 32 kbit/s，实际需要处理的 IP 数据却是 48 kbit/s。每秒多来的 8 kbit，必须等待、被丢弃，或通过改变产出等办法消除；把暂存空间扩大，不会让这条路径每秒多处理数据。

恢复数据也要用同样的边界记账。RFC 4588 要求原始数据与重传一起受可接受的发送速率约束。若统计的完整 Opus 包已经含有带内 FEC，就不能再把同一份冗余加一次。[^retransmission][^opus]

## 每 20 毫秒来一包，每包却要忙 24 毫秒

现在只看一个瓶颈：它是路径上限制这条流处理速度的阶段。包按到达顺序处理，前一包完成后才能处理下一包，这叫**先入先出**。没有丢弃，开始时也没有积压。

40 kbit/s 相当于每秒 5000 字节，每毫秒 5 字节。处理一个 120 字节包需要：

```text
120 ÷ 5 = 24 毫秒
```

这里的“处理”特指按给定速率完成该包传输所需的时间，不含传播到对端、解码和设备输出。包来到瓶颈的时刻，也不是麦克风采样时刻。

| 第几个包 | 来到瓶颈 | 开始处理 | 开始前已等待 | 处理完成 |
| --- | --- | --- | --- | --- |
| 1 | 0 毫秒 | 0 毫秒 | 0 毫秒 | 24 毫秒 |
| 2 | 20 毫秒 | 24 毫秒 | 4 毫秒 | 48 毫秒 |
| 3 | 40 毫秒 | 48 毫秒 | 8 毫秒 | 72 毫秒 |
| 50 | 980 毫秒 | 1176 毫秒 | 196 毫秒 | 1200 毫秒 |

每个新包与上一包相隔 20 毫秒，瓶颈却要多忙 4 毫秒。第 50 个包前面累计的等待是：

```text
(50 − 1) × (24 − 20) = 196 毫秒
```

196 毫秒还只是排队。第 50 个包从来到瓶颈到处理完成，要用 `196 + 24 = 220` 毫秒；之后还有其他阶段。即使每包最后都能送到，这种持续增长也会消耗第一篇讨论的播放预算。实时媒体的拥塞控制需要兼顾可用吞吐与低延迟，不能只检查是否发生丢包。[^requirements]

下面比较两组从空队列开始的情况：保持 32 kbit/s 编码音频，或从第一包起改为 16 kbit/s。两组都保持每包 20 毫秒与每包 40 字节报头。先预测：把传输能力调到 48 kbit/s，保持原码率的曲线还会往上走吗？

<figure class="interactive-diagram" style="margin:1.8rem 0;padding:1.3rem 0;border-top:1px solid var(--code-border);border-bottom:1px solid var(--code-border);container-type:inline-size">
    <iframe id="interactive-diagram-09c625a970ba0ca1b1424bfd4eea8e11-0" src="https://hex4c59.cc/articles/rtc-05-send-queue/queue-growth.html" title="改变假设的传输能力，比较两种编码码率下前 50 包的排队等待；不含传输本包所需时间" width="100%" height="860" loading="lazy" sandbox="allow-scripts" style="display:block;width:100%;border:0;background:transparent;--figure-height:860px;--figure-mobile-height:1140px;--figure-compact-height:1020px"></iframe>
    <figcaption style="font-size:.78rem;line-height:1.8;margin-top:.7rem;color:var(--content-secondary)">改变假设的传输能力，比较两种编码码率下前 50 包的排队等待；不含传输本包所需时间 · <a href="https://hex4c59.cc/articles/rtc-05-send-queue/queue-growth.html" target="_blank" rel="noopener">在独立窗口中查看</a></figcaption>
  </figure>


48 kbit/s 时，120 字节恰好需要 20 毫秒，原方案也能在下一包到来时完成上一包；从空队列开始，等待不再增长。这个结论依赖固定能力、固定包长和均匀到达，实际短时波动仍可能造成排队。

## 降低码率，先改变的是什么

降低编码码率，是让同样时长的声音使用更少的编码数据表示。这里仍采集并播放一秒声音，每包仍覆盖 20 毫秒；没有通过少采一段声音或放慢播放来减少数据。质量取舍与编码模式、内容和配置有关，本篇不测听感。Opus 提供码率和可变码率控制，目标值也不能保证真实短窗口里每包都一样大。[^encoder]

为方便计算，下面暂把每包固定为按目标换算出来的大小，传输能力仍为 40 kbit/s：

| 编码音频码率 | 每包音频 | 加上报头后的每包大小 | 总加入速率 | 有积压时的变化 |
| --- | --- | --- | --- | --- |
| 32 kbit/s | 80 字节 | 120 字节 | 48 kbit/s | 每秒多 8 kbit |
| 24 kbit/s | 60 字节 | 100 字节 | 40 kbit/s | 已有积压保持 |
| 16 kbit/s | 40 字节 | 80 字节 | 32 kbit/s | 每秒少 8 kbit，直到不再积压 |

**降到 24 kbit/s，可以停止继续积压，但不一定消除已经存在的等待。** 想象前面已经排着一批数据，之后每秒又来 40 kbit、发走 40 kbit：旧数据确实在离开，新数据也等量补进，队列规模保持。

如果希望排队回落，需要在这段时间里让新加入量小于可处理量，或者采用其他有效的过期数据处理策略。以 16 kbit/s 编码方案为例，IP 数据加入为 32 kbit/s，每秒有 `40 − 32 = 8 kbit` 用于消化积压。

独立设定：调整时，排在新包前面的待传工作为 1000 字节，即 8000 bit。在均匀加入、能力固定且不丢弃的连续速率模型中：

```text
消化积压所需时间 = 8000 ÷ (40,000 − 32,000) = 1 秒
```

“消化积压”指整体等待回落，不是原来的那 1000 字节一直不动。它们会先被发送，后来的新数据也会继续排队，只是队列逐渐缩短。下面的逐包实验会看到，第一包等待 200 毫秒，而第 51 包在第 1000 毫秒到来时，已经不再需要排队。

真实通话还需要给能力波动、控制流量和恢复流量留出空间。这里的 16、24、32 是为了分清增长、保持和回落而设的三组数，不是推荐码率，也没有测出适用于所有通话的安全余量。

## Pacing 改的是发送时机，不会凭空增加容量

除了少产生数据，还可以把一批待发包分散到不同的发送时刻，这叫 **Pacing，发送平滑**。负责安排这些时刻的组件常称为 Pacer。WebRTC 的相关文档说明，编码后成批交来的包可能造成短时突发，Pacer 先把它们放进队列，再安排发送；多路媒体还需要调度。[^pacing]

这解释了 Pacing 为什么有用：平均量合适，也可能在一个很短的窗口里集中送出过多数据。但如果长期产出已经超过能力，仅把发送排得更均匀，仍然要为多出来的数据找地方等待。

继续使用原方案：每 20 毫秒来一个 120 字节包，IP 数据总量为 48 kbit/s。现在在瓶颈前加一个理想 Pacer，按 IP 层 40 kbit/s 安排相邻包，首包立即提交，后续两包的最小发送间隔为：

```text
120 × 8 ÷ 40,000 = 0.024 秒 = 24 毫秒
```

第 2 包在 20 毫秒产生，Pacer 到 24 毫秒才提交它；第 50 包在 980 毫秒产生，到 1176 毫秒才提交。由于间隔与下游处理能力匹配，下游不再额外排队，但 Pacer 自己已经积下等待。

| 原来的编码产出保持不变 | 第 50 包在 Pacer 等待 | 在下游等待 | 两处合计等待 |
| --- | --- | --- | --- |
| 不加 Pacer | 0 毫秒 | 196 毫秒 | 196 毫秒 |
| 理想 Pacer 设为 40 kbit/s | 196 毫秒 | 0 毫秒 | 196 毫秒 |

这是两级固定速率模型的结果，没有证明真实系统里两处等待总能恰好互换。它说明：只看下游队列清空，可能漏掉本机积压；控制持续产出和安排发送时机，需要一起考虑。

本模型的 Pacer 只按固定间隔放行包：距离上一次提交还没过满规定时间，下一个包就继续等。它不是 WebRTC Pacer 的实现。真实实现还涉及包优先级、可配置的开销记账与其他调度行为，不能直接把本文 IP 层参数填进一个未核对统计范围的接口。[^pacing]

## 运行模型，先预测再核对

[下载 https://hex4c59.cc/articles/rtc-05-send-queue/send_queue.py](send_queue.py)。需要 Python 3.9 或更新版本，没有第三方依赖。在保存脚本的目录运行：

```bash
python3 send_queue.py
```

2026 年 10 月 15 日，使用 Python 3.9.6 运行默认的 50 包案例，关键输出为：

```text
IP packet: 120 bytes; service: 24 ms
Offered IP rate: 48 kbit/s
Capacity: 40 kbit/s; initial queue: 0 bytes
Last packet: pacer wait=0 ms; bottleneck wait=196 ms; total wait=196 ms
```

输出中的 `arrival_ms` 是来到本模型第一个阶段的时刻；`finish_ms` 是瓶颈处理完成时刻。所有时刻都在同一条构造时间轴上，不是程序实际执行了多久。内部用有理数计算，避免把浮点舍入当作刚好赶上的差异。

接着只加 Pacer。运行前先判断两处等待分别会变成多少：

```bash
python3 send_queue.py --pacer-kbps 40
```

应看到 Pacer 等待 196 毫秒、瓶颈等待 0 毫秒，合计仍为 196 毫秒。再给两次独立实验各放入 1000 字节初始积压，分别改变新数据的编码码率：

```bash
python3 send_queue.py --payload-kbps 24 --initial-queue-bytes 1000
python3 send_queue.py --payload-kbps 16 --initial-queue-bytes 1000 --packets 51
```

第一条命令里，第一包和第 50 包都等待 200 毫秒。第二条里，第一包等 200 毫秒，第 2 包等 196 毫秒，最终第 51 包等待为零。初始积压都设在瓶颈前，没有同时给 Pacer 填入另一份初始数据。

若希望检查每一行，可以创建 CSV：

```bash
python3 send_queue.py --csv queue-trace.csv
```

默认运行只打印结果；指定 `--csv` 才新建文件，已有同名文件时拒绝覆盖。也可调整 `--capacity-kbps`、`--interval-ms`、`--overhead-bytes` 与 `--packets`。当前固定包长模型要求按参数换算出的音频字节数为整数，不会把半个字节伪装成实际包长。

脚本没有编码器、真实网络、包丢弃、传播时延或播放调度，也没有自动估计容量。命令行输出与交互图都在检查已声明的模型；它们不能证明真实网络有 40 kbit/s 能力，或某个码率一定不会迟到。

## 现实里不知道容量，怎样开始调整

前面是题目直接给出 40 kbit/s。真实通话需要根据反馈估计当前可用能力，并据此调整，这个过程属于**拥塞控制**。其中 BWE（Bandwidth Estimation，带宽估计）负责形成供控制使用的能力估计；它不是从网卡读到一个永远准确的上限。

先把三个量分开：编码器实际产生多少、发送端实际提交多少、接收端实际收到多少。它们还要采用同一统计边界和对应的时间窗口。

例如，在稳定、无丢失、无积压的独立条件下，接收端一秒收到 4000 字节 IP 数据，对应 32 kbit/s。下面两个假设世界都能产生这个观察：

| 假设的可用能力 | 实际发送 | 实际接收 |
| --- | --- | --- |
| 40 kbit/s | 32 kbit/s | 32 kbit/s |
| 100 kbit/s | 32 kbit/s | 32 kbit/s |

表里的能力是给定假设，32 kbit/s 才是观察到的接收吞吐。只发这么多、也收到这么多，不能单独证明路径最多只能承受这么多。RFC 8836 也明确区分应用没有更多数据可发与网络约束。[^requirements]

接收端还可以反馈哪些包到了、何时到达；发送端结合对应的发送时刻、大小和队列情况分析，再调整编码产出、发送安排及恢复预算。RFC 8888 给出了一种携带包接收与到达时间等信息的 RTCP 拥塞反馈格式；反馈格式提供观察信息，具体控制算法仍需决定怎样使用。[^feedback]

反馈也需要传输时间，编码目标变化主要影响后续新数据，队列里的包不会因一次调整自动消失。因此排查“已经降码率，为什么还延迟”时，需要继续看实际产出是否下降、总预算是否漏算、队列是否回落。本篇先建立这些量的关系，尚未实现容量估计或完整控制算法。

## 换一组数字，检查能否独立判断

统一使用本篇的 IP 层边界：每包 20 毫秒音频，每包 40 字节报头，可用能力固定 40 kbit/s，不丢弃。

1. 编码音频改为 20 kbit/s，每包多少音频字节、总共多少字节，总加入速率是多少？从空队列开始，会持续积压吗？
2. 仍用 20 kbit/s 方案，开始时已有 500 字节积压。连续速率模型中需多久消化？能否通过再扩大暂存空间缩短这个时间？
3. 编码音频保持 32 kbit/s，只把 Pacer 设为 40 kbit/s。下游等待变成零，是否说明通话等待已经解决？
4. 一秒收到 4000 字节 IP 数据，是否足以把可用能力设为恰好 32 kbit/s？还缺什么判断？

<details>
<summary>展开换算、答案与回看位置</summary>

1. 音频字节为 `20,000 × 0.020 ÷ 8 = 50`，加报头是 90 字节。每秒 50 包，总量为 `90 × 50 × 8 = 36,000 bit/s`，即 36 kbit/s，小于 40 kbit/s。本题均匀输入、空队列起步，不会持续积压。若得到 20 kbit/s 总量，回看第一节的统计边界。
2. 初始积压 `500 × 8 = 4000 bit`，剩余能力 `40,000 − 36,000 = 4000 bit/s`，需 1 秒。扩大空间没有改变这两个速率。逐包检查可运行 `python3 send_queue.py --payload-kbps 20 --initial-queue-bytes 500 --packets 51`，第 51 包在 1000 毫秒到来时等待为零。
3. 没有。本文固定模型里，第 50 包改为在 Pacer 等待 196 毫秒，总等待保持。若只看到下游的零就判断成功，回看两级等待表。
4. 不足以。这只给出该窗口实际接收的吞吐；还需确认实际发送量、应用是否有更多数据、等待与丢失的变化，并结合反馈估计。40 与 100 kbit/s 两个假设世界都与这个接收结果相容。

</details>

## 排查时，把产出、提交和收到连起来

一条“目标码率已降低”的日志，没有说明编码器实际输出、协议开销与旧积压发生了什么。对同一时间窗口，可以关联以下边界：

| 记录 | 要回答的问题 |
| --- | --- |
| 编码目标与实际 Opus 字节数 | 新产出是否真的下降，短窗口是否仍有突发 |
| 报头、恢复与控制数据的记账范围 | 总量有没有漏算或重复计算 |
| 进入与离开 Pacer 的时刻 | 本机有多少数据、最旧数据已经等了多久 |
| 交给底层传输后的发送与反馈记录 | 等待是否还发生在后续路径，哪些包何时被接收 |
| 播放期限与实际使用结果 | 送到的数据是否仍赶上原来的声音区间 |

尤其要分清“交给底层传输”与“对端收到”。包离开一个应用队列之后，系统或路径上的其他队列仍可能等待；本机调度慢也可能造成积压，不能把所有队列增长都直接归因于网络容量下降。WebRTC 的 Pacer 文档把包交给底层传输后的部分明确放在其职责之外。[^pacing]

开头那通越来越慢的电话，首先要检查的是同一统计范围内，持续加入的数据是否超过实际可用能力。编码音频 32 kbit/s 加上本例报头后已经是 48 kbit/s；40 kbit/s 的阶段每包多忙 4 毫秒，等待便逐包累积。减少新产出可以停止增长或消化积压，Pacing 则安排数据何时离开本机，两者要对照整条路径判断。

[第六篇](https://hex4c59.cc/articles/rtc-06-delay-trend/)继续处理容量并不直接已知的问题：怎样比较发送间隔与到达间隔，从记录里看到传输耗时的变化，以及这些观察还不能证明什么。

[回看第四篇：丢包后，重传、FEC 和 PLC 怎么选](https://hex4c59.cc/articles/rtc-04-loss-recovery/) · [系列目录](https://hex4c59.cc/articles/rtc-from-zero/)

## 参考资料

资料核对于 2026 年 10 月 15 日。本文所有速率、排队时刻和等待结果都来自已声明的教学模型，脚本执行用于核对模型计算，没有对应真实网络或通话质量测试。

[^requirements]: [RFC 8836 §1—§2：实时交互媒体的拥塞控制要求](https://www.rfc-editor.org/rfc/rfc8836.html#section-2)，2021 年 1 月发布，讨论低延迟、应用受限、排队与网络竞争条件。
[^pacing]: [WebRTC 官方 Paced Sending 文档](https://webrtc.googlesource.com/src/+/refs/heads/main/modules/pacing/g3doc/index.md)，说明发送平滑、排队、记账与底层传输边界。链接指向持续更新的主分支，本文只引用访问日核对的机制说明。
[^encoder]: [libopus 1.6 编码器控制 API](https://opus-codec.org/docs/opus_api-1.6/group__opus__encoderctls.html)，说明目标码率与可变码率设置；本文没有执行真实编码或评价这些目标的听感。
[^retransmission]: [RFC 4588 §7：重传与拥塞控制](https://www.rfc-editor.org/rfc/rfc4588.html#section-7)，2006 年 7 月发布，要求将原始数据与重传共同纳入可接受的发送速率。
[^opus]: [RFC 7587 §3.3：Opus 带内 FEC](https://www.rfc-editor.org/rfc/rfc7587.html#section-3.3)，2015 年 6 月发布，说明恢复冗余随 Opus 包携带；完整包大小已包含其中实际存在的数据。
[^feedback]: [RFC 8888 §3：用于拥塞控制的 RTCP 反馈](https://www.rfc-editor.org/rfc/rfc8888.html#section-3)，2021 年 1 月发布，说明包接收、到达时刻等反馈，以及发送和接收记录的位置要求。

