04 · WebRTC 低延迟优化实录:出画面从 11.5 秒到百毫秒级
本系列第 4 篇 | 2026-07 | 标签:WebRTC、低延迟、GStreamer、Amazon KVS
视频通路有了(第 1~2 篇),接 WebRTC 推流。方案:VPU 硬编出的 H264 →
Amazon KVS WebRTC C SDK → 自建信令(不依赖 AWS 云服务)。第一版功能就通了 ——
然后开始了一段密度最高的优化期:一天之内,出画面从 11.5 秒压到 1.23 秒;
首帧延迟从秒级压到几十毫秒。这篇按时间线复盘每一步,所有数字都是板上实测。
起点:能跑,但没人忍得了
第一版:HTTP 轮询式信令 + 等 ICE 候选收集齐再交换。
症状:从呼叫到出画面要 11.5 秒,而且每次都不太一样。
先立规矩:优化不是猜的。动手前先埋了三个探针,把延迟拆账:
- 采集延迟探针:帧打上 CLOCK_MONOTONIC 时间戳(pts_us),订阅侧收到时相减,
得到"摄像头→编码→共享内存→WebRTC"的端内耗时 - 端到端首帧计时:浏览器页面里从发 offer 到第一帧渲染
- 稳态延迟拆账:RTT / jitter buffer / 解码各占多少
方法论一句话:先把账拆开,再决定动哪一刀。下面每个优化都能用探针复测。
第一轮:信令与候选(11.5s → 591ms)
| 改动 | 原理 | 效果 |
|---|---|---|
| HTTP 信令换 websocket + trickle ICE | 不等候选收集齐,边收集边交换 | 出画面 11.5s → 1.23s |
| 摄像头常开 | 不再等 viewer 接入才起管线(起管线本身秒级) | — |
| viewer 接入时强制 IDR | 连接建立后立刻催一个关键帧,不用等下一个 GOP | 首帧 1025ms → 591ms |
强制 IDR 用 GStreamer 的 force-key-unit 事件实现。实测过 v4l2slh264enc
对 GstForceKeyUnit 的响应:55ms 内出关键帧 —— 完全够用,不需要重启编码器
或改 GOP 这种大动作。
WebRTC 首帧的本质约束:连接建立后,订阅者必须等到一个可独立解码的关键帧
(SPS+PPS+IDR 一个访问单元)才能出画面。所有首帧优化都在回答一个问题:
怎么让这帧最快出现。
第二轮:TURN 的教训
两块实测记录:
- 板子侧不配 TURN,公网首帧 2801ms → 1476ms
- 局域网两端都配 TURN 时首帧 866ms,都不配降到 232ms
结论:TURN 的代价是对称的。局域网/直连可达的场景,任何一端都别配;
TURN 只该留给真的打洞失败的那一侧。本项目最终形态:板子只配 STUN,
TURN 配置下发给浏览器侧。
第三轮:线程模型
三个修复,主题是"别让慢的东西堵住快的":
- 候选处理挪到 worker 线程(顺带修了一个候选被丢弃的 bug):回调线程里做
重活会阻塞整个信令流程 - 发送线程解耦:发送队列满时丢帧,绝不让网络背压倒灌进采集/编码线程 ——
“网络再烂,采集节奏不动”。这条和第 7 篇音频的"过期数据宁可丢不要发"
是同一个思想的两个面 - CLOSED 时后台异步拆连接 + PLI→IDR 去抖:断开清理不占调用点,
多个 PLI 合并成一次 IDR 请求
第四轮:决定性的一击(0~1000ms 随机 → 20~40ms)
首帧延迟在 0~1000ms 之间随机抖,复现不了规律。根因:
编码器侧的强制 IDR 请求状态没有复位,只有第一次生效,后续全部被吞。
第一次碰巧赶上 GOP 边界就快,否则等满一个 GOP(1s)。修完首帧稳定塌缩到
20~40ms。
这是整个系列性价比最高的一次修复:一行状态复位级别的改动,
消灭了最大的延迟方差来源。教训:"只生效一次"的功能等于没有功能,
对这类状态机要多打几次验证。
同期的两个正确性修复:
- 收流侧判断关键帧改成自己扫 Annex-B NAL 头 —— 原来读的是一块未初始化内存,
行为纯看运气(这种 bug 测试时可能永远不复现) - 解析浏览器的 mDNS(.local)混淆候选:现代浏览器默认把主机候选混淆成
xxxx.local,不解析就丢了一半候选
第五轮:采集端(230ms → 20ms)
采集延迟探针显示端内就占了 230ms。各种缓冲策略调了个遍没用,
最后发现是初始化脚本 sensor 名字截断导致协商走了另一条路径(第 2 篇坑四)。
修完直采 ~8ms。
再次印证那篇的结论顺序:先验证配置是否真的生效,再怀疑管线结构。
最终架构下的实测基线(2026-08 板测)
拆进程(第 5 篇)+ 主路 libcamerasrc(第 6 篇)之后:
| 读数 | 局域网 | 公网 5G |
|---|---|---|
| 采集延迟(摄像头→编码→共享内存→WebRTC) | 24~30ms | 同左 |
| 跨进程强制 IDR 响应 | 21~34ms | 同左 |
| 发布者丢帧 | 0 | 0 |
| 呼叫→浏览器首帧 | ~120ms | ~690ms |
| 稳态 RTT / fps | 2ms / 30 | 88ms / 29 |
| PLI 次数 | 1 | 0(关键帧靠强制 IDR,不用催) |
24~30ms 的采集延迟由 libcamerasrc 主导:比裸 v4l2src 的 ~8ms 多出一截,
是 libcamera request 流转 + IPA 跑 3A/AE 的固定代价 —— 我们为了 3A 主动选的
(见第 6 篇),不是回归。
⚠ 测延迟的两条纪律
- SDK 日志级别必须是 INFO(3) 且只重定向到文件、不要
tee——
DEBUG 日志会把 ICE/DTLS 握手拖慢几百毫秒,tee同样拖。
开着 DEBUG 测出来的数全是假的。 - 对照实验用 videotestsrc 替掉相机源,把采集变量隔离干净
(第 6 篇用它定位了 libcamerasrc 的 CPU 归属)。
关键帧自包含:中途接入的唯一前提
订阅者随时可能接入(WebRTC 中途加入、共享内存新订阅者),它拿到的第一帧必须
SPS+PPS+IDR 自包含。管线靠 h264parse config-interval=-1 + alignment=au 保证。
反面教材提前预警(第 6 篇细讲):一次重构把这个属性弄丢了(默认 0 = 参数集只在
流头发一次)—— 结果是中途接入黑屏,而启动链路一切正常。这不是优化项,
是正确性前提。
小结:低延迟清单
- trickle ICE,别等候选收齐
- 相机常开 + 接入即催 IDR;确认催帧机制每次都生效
- TURN 只给真正需要的那一侧
- 发送与采集解耦,网络背压不上溯
- 关键帧自包含(config-interval)
- 探针先行:先拆账,再动刀;测数时关 DEBUG、别 tee
下一篇:05 · 一个摄像头,多个消费者
—— webrtc 和人脸识别都要画面,一颗摄像头一个编码器实例怎么办?
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)