本系列第 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同左
发布者丢帧00
呼叫→浏览器首帧~120ms~690ms
稳态 RTT / fps2ms / 3088ms / 29
PLI 次数10(关键帧靠强制 IDR,不用催)

24~30ms 的采集延迟由 libcamerasrc 主导:比裸 v4l2src 的 ~8ms 多出一截,
是 libcamera request 流转 + IPA 跑 3A/AE 的固定代价 —— 我们为了 3A 主动选的
(见第 6 篇),不是回归。

⚠ 测延迟的两条纪律

  1. SDK 日志级别必须是 INFO(3) 且只重定向到文件、不要 tee ——
    DEBUG 日志会把 ICE/DTLS 握手拖慢几百毫秒,tee 同样拖。
    开着 DEBUG 测出来的数全是假的。
  2. 对照实验用 videotestsrc 替掉相机源,把采集变量隔离干净
    (第 6 篇用它定位了 libcamerasrc 的 CPU 归属)。

关键帧自包含:中途接入的唯一前提

订阅者随时可能接入(WebRTC 中途加入、共享内存新订阅者),它拿到的第一帧必须
SPS+PPS+IDR 自包含。管线靠 h264parse config-interval=-1 + alignment=au 保证。

反面教材提前预警(第 6 篇细讲):一次重构把这个属性弄丢了(默认 0 = 参数集只在
流头发一次)—— 结果是中途接入黑屏,而启动链路一切正常。这不是优化项,
是正确性前提。

小结:低延迟清单

  1. trickle ICE,别等候选收齐
  2. 相机常开 + 接入即催 IDR;确认催帧机制每次都生效
  3. TURN 只给真正需要的那一侧
  4. 发送与采集解耦,网络背压不上溯
  5. 关键帧自包含(config-interval)
  6. 探针先行:先拆账,再动刀;测数时关 DEBUG、别 tee

下一篇:05 · 一个摄像头,多个消费者
—— webrtc 和人脸识别都要画面,一颗摄像头一个编码器实例怎么办?

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐