一、引言
本文记录一次没有依赖常驻推流器的冷启动优化:我们如何拆开播放器、ICE、H.264 关键帧和 ZLMediaKit 轨道注册,沿状态机边界找到主要等待点,并用可验证的小改动把启动耗时降低约 71%。

**先说结论:**实测瓶颈不在 RK3588 的持续计算,而在启动状态机中的两个主要等待:publisher(推流端) 要在多个 ICE 路径中建立连接,以及连接完成后等待下一帧 H.264 IDR。第一轮压缩这两个等待后, 平均耗时从 6.49s 降到 1.92s。后续改动主要收敛 candidate 和失败恢复,平均速度只小幅变化。
二、项目背景:RK3588 上的双通道播放与网页实时流
我们优化的这个项目运行在 MYZR-RK3588-EK360 板子上。它的主要任务不是传统网络摄像机推流,而是按节目单 同时驱动两路 HDMI 播放和独立 DEP 音频输出。网页实时流是附加能力:Java 服务下发 streamControl (这是我们开发人员内部定义的一个应用层指令)后,设备把指定 HDMI 通道正在播放的内容发布到板端 ZLMediaKit,浏览器再从 ZLMediaKit 播放。
为了控制空闲资源,publisher 和共享编码 pipeline 不常驻。无人观看达到阈值后会被回收,下一次 streamControl 必须完整经历一次冷启动。因此,这里的"启动快"不能通过永远保持编码器和 WebRTC 连接在线来换取。
(一)GStreamer、WebRTC 和 ZLMediaKit 的数据链路

这里有两段 WebRTC:第一段是 播放器 publisher → 同机 ZLMediaKit ,第二段是 ZLMediaKit → 浏览器。本文优化的是第一段和 ZLM 媒体源注册。HTTP 返回播放地址后, 浏览器才开始第二段协商。
(二)启动控制链路

三、真实测试环境
| 项目 | 测试环境 | 获取方式 |
|---|---|---|
| 板卡 | MYZR-RK3588-EK360,aarch64 | /proc/device-tree/model |
| 系统 | Buildroot 2021.11 | /etc/os-release |
| Linux 内核 | 5.10.226,PREEMPT | uname -a |
| GStreamer | 1.22.9 | gst-inspect-1.0 --version |
| webrtcbin / Opus 插件 | 1.22.9 | gst-inspect-1.0 webrtcbin / opus |
| Rockchip MPP 插件 | gst-rockchip 1.14.4,提供 mpph264enc |
gst-inspect-1.0 rockchipmpp |
| ZLMediaKit | master@0e9e59b,构建时间 2026-08-13 16:30:41 |
/index/api/version |
| libsrtp | 2.5.0 | MediaServer 启动日志 |
| 交叉编译器 | GNU AArch64 Toolchain 10.3.1 | aarch64-none-linux-gnu-g++ --version |
| 测试设备 | 192.168.31.163,播放器 HTTP 8050,ZLM HTTP 65521 / RTC 65522 |
板端配置 |
四、WebRTC相关知识
可以把 ICE 类比为"给两块板找一条能通的网线和端口",candidate 是候选接线方案;IDR 是"可以从这里 独立开始解码"的视频同步点;track ready 则是媒体服务器确认这条视频或音频流水线已经真的进货,而不只是 管线名称存在。
五、先定义指标:什么时候才算启动完成
接口采用"回复即可以播放"语义。streamControl 不会在 SDP 交换完就立即返回,它继续查询 ZLM,直到 H.264 和 Opus 都满足 ready=true 且帧数大于 0:
// app/WebRtcStreamManager.cpp,当前最终实现节选
if (!track.value("ready", false) ||
!track.contains("frames") || !track["frames"].is_number()) {
continue;
}
if (track["frames"].get<double>() <= 0) continue;
if (codec == "H264") videoReady = true;
if (codec == "OPUS") audioReady = true;
if (videoReady && audioReady) {
ready = true;
status = "H264 and Opus tracks are ready";
}
修改/检查位置:
我们编写的代码app/WebRtcStreamManager.cpp:718 的 QueryZlMediaKitStreamReady()、:823 的 WaitForZlMediaKitStreamReady(),以及 app/main.cpp:1471 附近的 streamControl 响应逻辑。
| 时间指标 | 起止点 | 能回答什么 |
|---|---|---|
HTTP time_total |
命令发出 → 收到 200 和播放地址 | 用户感知的完整启动耗时,是版本比较主指标。 |
elapsedMs |
开始轮询 ZLM → 双轨 ready | 轨道注册等待,但它与 ICE/媒体传输可能重叠。 |
publisherAgeMs |
publisher 创建 → 双轨 ready | publisher 内部总生命周期。 |
connectedToReadyMs |
publisher CONNECTED → 双轨 ready | 定位关键帧和轨道注册。 |
这些内部时间不能简单相加。ICE/DTLS 建连、RTP 发送和 ZLM ready 轮询有重叠。本文所有版本间的速度对比 统一使用完整 HTTP time_total;内部时间只用于定位。更直白地说:HTTP time_total 从 streamControl 请求发出算到 HTTP 200,elapsedMs 则从开始轮询 ZLM 算到 H.264 + Opus ready,两者起点和统计范围都不同。5258ms 的 elapsedMs 并不意味着还应该再加到 6.49s 上,它只是完整 HTTP 请求内部的一段时间。
六、初始 6.49 秒是怎样拆出来的
(一)基线现象
一次典型冷启动从收到命令到返回地址约 6.49s,其中日志显示 ZLM 双轨 ready 轮询耗时 5258ms:
[14:12:44] Received streamControl message
[WebRTC-1] Shared hybrid H.264/Opus pipeline started
[14:12:47] Peer zlm-publisher-1 connection-state=2
[14:12:50] ZLMediaKit stream ready: H264 and Opus tracks are ready, elapsedMs=5258
[14:12:50] Response sent: {"code":200, ...}
(二)不要先怀疑 RK3588 性能,先看状态机在哪里等待
定位不是从"CPU 可能不够快"开始,而是按下面的顺序逐层缩小范围:
- 先用 HTTP
time_total测完整用户路径,得到代表值 6.49s。 - 再按日志事件排列状态机:
streamControl → pipeline → offer → CONNECTED → track ready → HTTP 200。 - 在基线日志中先看到
CONNECTED前有明显耗时,说明网络协商路径需要单独检查。 - 又看到
CONNECTED后 H.264 仍未 ready,说明连接成功并不等于视频已经可解码。 - 分别用 candidate 收敛和主动 IDR 改变这两个条件,观察对应阶段是否缩短。
- 根据对照结果确认:ICE 路径不确定和等待自然 IDR 是两个主要等待点。

后续加入细粒度打点后,正常样本中的 pipeline PLAYING 只有 3--9ms,offer 约 0.1--0.45s,首个板内 UDP candidate 只有 1--2ms,remote description 到 CONNECTED 为 78--95ms,CONNECTED 到双轨 ready 为 107--529ms。这组数据把"执行代码很慢"和"状态机在等待"区分开来:大头不是 CPU 持续计算,而是协商、 关键帧和媒体注册条件尚未满足。
(三)为什么判断 ICE 是瓶颈
- publisher 从创建到
connection-state=CONNECTED约 3.29s,远高于板内 UDP 应有的毫秒级路径。 - SDP 中存在多个 UDP/TCP candidate,播放器与同机 ZLM 仍需要做不必要的候选检查。
- 把 ZLM answer 收敛到板内 IPv4 UDP host candidate 后,连接阶段降到约 1.22--1.47s;进一步让 offer 从源头只生成一个板内 UDP candidate 后,本地 gathering 稳定到 1--2ms。
这是一条实验支持的判断链:先从状态日志发现连接时间异常,再改变 candidate 集合作为对照实验,连接时间 随之下降。它能确认 candidate 收敛有效,但由于第一轮还同时上线了 force-IDR,不能精确拆出各自对完整 HTTP 耗时的贡献。
(四)为什么判断 IDR 是瓶颈
- 连接成功后,Opus 可以持续出包,但 H.264 track ready 仍有约 3--6s 波动。
- 编码器 GOP 配置为帧率大小,但加入实时流的时刻是任意的,ZLM 可能正好从 GOP 中间开始接收。
- 在 publisher CONNECTED 后主动请求 IDR,ready 等待立即降到约 0.20--1.01s。
- 最终分阶段日志进一步确认
CONNECTED → ready只剩 107--529ms。
同样不是只凭经验认定:主动 IDR 上线后,H.264 ready 等待明显下降;最终日志又把 CONNECTED → ready 量化到 107--529ms。这里的严谨结论是"等待明显下降",而不是宣称所有 场景都不会再等待。
(五)为什么这次不是 RK3588 算力不足?
最终分段数据中,pipeline 进入 PLAYING 只需 3--9ms,正常 candidate gathering 只需 1--2ms, CONNECTED → ready 也已降到 107--529ms。主要耗时跟随状态转换而变化,没有表现为 CPU 上 持续数秒的计算。实时流启动慢不一定是编码性能不够,也可能是状态机在等待网络、关键帧或媒体注册条件。
(六)一个重要的反证:调 ZLM 单轨等待没有用
我们也测试了 wait_add_track_ms=1000。如果瓶颈是 ZLM 不确定"是否还有第二条轨",这个参数 应该直接缩短等待。但成功样本仍为 5.242s、5.254s、8.284s、8.489s,并出现一次 10s 超时。 这说明方向不对:SDP 已声明 H.264 和 Opus,真正缺的是可用连接和可解码 H.264 起点。
(七)从性能到确定性,再到尾部稳定性

试验 0 缩短 ZLM wait_add_track_ms
一个合理但没有命中瓶颈的参数实验
原问题
怀疑 ZLM 在只看到一条 track 时,会固定等待另一条 track,拖慢媒体源注册。
原理解释
wait_add_track_ms 用于"当前只有单轨且协议元数据没有明确轨道数"的场景。本项目的 WebRTC SDP 已明确声明 video 和 audio,因此它不是主要等待点。
修改位置
/root/ZLMediaKit/config.ini 的 [general];测试值从 3000 改为 1000,验证后恢复为 3000。安装包模板仍为 output/ZLMediaKit/config.ini:193。
核心代码/配置
[general]
# 试验值,最终未采用
wait_add_track_ms=1000
优化收益
0。没有形成更快且稳定的 HTTP 样本,且仍出现 10 秒超时。该方案已回退。
方案 1 ZLM answer 仅保留板内 UDP candidate,并在 CONNECTED 后强制 IDR
整个优化中收益最大的一轮
原问题
- 播放器和 ZLM 在同一块板上,却仍处理多个 UDP/TCP candidate,连接阶段约 3.29s。
- 连接成功后只能等待编码器自然输出下一个 IDR,H.264 ready 波动约 3--6s。
原理解释
同机 publisher 不需要公网 NAT 或 TCP 回退。把 answer 收敛为板内 UDP host candidate,可以减少无效 路径检查。连接一旦建立,再强制编码器产生 IDR,ZLM 就不必等待自然 GOP 边界。
修改位置
当轮历史实现:app/WebRtcStreamManager.cpp 的 answer candidate 过滤和 OnPeerConnectionStateNotify();app/main.cpp 的 zlm_publisher_ice_ip=auto 解析。当前最终实现位于 app/WebRtcStreamManager.cpp:421-506、:3190-3240 和 app/main.cpp:634-643。
核心代码
这段 answer 处理只保留一个可用的板内 UDP host candidate;后半段则在连接进入 CONNECTED 后,请求共享 H.264 编码器尽快产生 IDR。
// answer 中只接受 UDP host candidate,其余删除;当前实现节选
if (retainedMediaCandidate || !IsUdpHostCandidate(attribute)) {
gst_sdp_media_remove_attribute(media, j);
++removedCount;
continue;
}
// 当轮为 CONNECTED 后一次 IDR;最终实现仍保留这个阶段
if (peer->zlmPublisher &&
state == GST_WEBRTC_PEER_CONNECTION_STATE_CONNECTED) {
RequestPublisherIdr(peer, true);
}
优化收益
- 8 次成功冷启动:1.43--2.38s ,平均 1.92s。
- 相比 6.49s 缩短 4.57s ,耗时降低约 70%。
- 启动速度约为原来的 3.4 倍。
- ICE/连接阶段降至约 1.22--1.47s ;ready 等待降至 0.20--1.01s。
candidate 收敛和强制 IDR 在同一轮上线,因此 4.57s 是组合收益。没有单独的 A/B 样本能把这 4.57s 精确分摊给两个改动,本文不做虚构归因。
方案 2 让本地 publisher offer 从源头 UDP-only
平均收益很小,稳定性价值更大
原问题
方案 1 过滤的是 ZLM 返回的 answer,但 publisher 生成 offer 时仍可能枚举其他网卡和 TCP candidate, 本地 ICE gathering 仍存在波动,甚至偶发 10s gathering 超时。
原理解释
既然通信双方在同一块板上,最确定的拓扑就是指定网卡 IP 的 UDP host candidate。直接配置 webrtcbin 的 ICE agent,只允许 UDP 并绑定 192.168.31.163,比生成后再容忍多余 candidate 更可控。
修改位置
app/WebRtcStreamManager.cpp:337-419 的 ConfigurePublisherIceAgent() 和 ValidatePublisherOfferIceCandidates(); 调用点在 :1273-1280。
核心代码
这段代码真正做两件事:关闭 TCP candidate,并把 ICE agent 限制到指定的板内 IP。
GObject* iceAgent = nullptr;
g_object_get(webrtc, "ice-agent", &iceAgent, nullptr);
g_object_set(iceAgent,
"ice-tcp", FALSE,
"ice-udp", TRUE,
nullptr);
gboolean addressAdded = FALSE;
g_signal_emit_by_name(iceAgent, "add-local-ip-address",
publisherIceIp.c_str(), &addressAdded);
优化收益
| 轮次 | HTTP 冷启动 | 本地 gathering |
|---|---|---|
| 1 | 1.55s | 1--2ms |
| 2 | 2.07s | 1--2ms |
| 3 | 2.03s | 1--2ms |
| 4 | 1.76s | 1--2ms |
| 平均 | 1.85s | 1--2ms |
相对上一版 1.92s 只缩短约 0.07s ,提升约 3%--4%。主要收益是 offer 只包含一个板内 UDP candidate,并把正常 gathering 收敛到毫秒级。
方案 3 两阶段强制 IDR、首 candidate 等待和自动重试
不追求漂亮平均值,重点修复异常尾部
原问题
- 只在 CONNECTED 后发 IDR,仍可能错过 remote description 刚生效的更早窗口。
- UDP-only 后,正常 candidate 只需 1--2ms,但偶发一次完全没有及时出现;旧逻辑会等待 gathering complete,最坏 10s 后失败。
- 此前日志只有总等待,无法判断 offer、ZLM API、连接和 ready 分别消耗多少。
原理解释
第一次 IDR 放在 remote description 设置完成后,让编码器尽早准备同步帧;第二次放在真正 CONNECTED 后,确保传输路径已经可发送。UDP-only 场景只需要首个有效 candidate,不必等待完整 gathering。 如果 1s 仍没有 candidate,销毁这个异常 publisher 并重建一次,比等满 10s 更可控。
两阶段 IDR 的触发窗口
set-remote-description
│
├── IDR #1:尽早触发编码器产生同步帧
│
└── CONNECTED
│
└── IDR #2:确保 WebRTC 已进入可发送阶段
第一次的目标是提前准备关键帧,第二次的目标是覆盖传输真正可用后的窗口。代码用两个独立标志保证 每个 publisher 实例在每个阶段最多请求一次,因此重复状态通知不会反复触发。这个设计不是理论上保证 一定更快,而是实测用于降低 track ready 等待波动。
修改位置
app/WebRtcStreamManager.cpp:1225-1473 的 publisher 分阶段计时、首 candidate 等待和 remote-description IDR;:1848-1861 的自动重试;:3190-3240 的两阶段 IDR;声明位于 app/WebRtcStreamManager.h。
核心代码
前两段逻辑不是重新编码整条视频,而是向共享 H.264 编码器请求尽快产生下一帧 IDR;每个阶段只会请求一次。
// 阶段一:remote description 生效后请求 IDR
peer->remoteDescriptionSetAtMs.store(SteadyNowMs());
RequestPublisherIdr(peer.get(), false);
// 阶段二:CONNECTED 后再次请求 IDR
if (state == GST_WEBRTC_PEER_CONNECTION_STATE_CONNECTED) {
RequestPublisherIdr(peer, true);
}
这里不再等待完整 ICE gathering,只等待第一个有效 UDP candidate;正常路径是 1--2ms,等待上限为 1000ms。
// UDP-only:只等首个本地 candidate;常量值为 1000ms
peer->iceCondition.wait_for(
lock,
std::chrono::milliseconds(kPublisherLocalCandidateTimeoutMilliseconds),
[&peer] { return !peer->localCandidates.empty(); });
下面的恢复逻辑只匹配 candidate 超时,并且只重建一次 publisher,避免把其他错误掩盖成无限重试。
// 只对 candidate 超时自动重试一次
if (!publisherStarted &&
errorMsg.find("Timed out gathering local UDP ICE candidate") == 0) {
errorMsg.clear();
publisherStarted = StartZlMediaKitPublisher(channel, errorMsg);
}
优化收益
| 轮次 | HTTP 总耗时 | CONNECTED → ready | candidate |
|---|---|---|---|
| 1 | 2.350s | 107ms | 首次 1s 无 candidate,自动重建成功 |
| 2 | 1.937s | 529ms | 1ms |
| 3 | 2.104s | 520ms | 1ms |
| 4 | 1.667s | 116ms | 1ms |
- 四轮全部计入:平均 2.015s ,相对基线降低约 69%。
- 排除发生重试的一轮:三次常规平均 1.903s ,相对基线降低约 71%。
- 相对方案 2 的 1.85s,常规平均没有显著提速,样本内反而慢约 0.05s。
- 异常路径从"最多等 10s 后失败"变成"2.350s 内自动恢复并返回成功"。
- 首 candidate 的单次等待上限从 10s 降到 1s,等待窗口缩短 90%。
这轮的正确评价是稳定性优化。 它没有创造更低的平均值,但消除了 一类 10 秒失败,并让 CONNECTED → ready 稳定在 107--529ms。把它宣传成新的大幅提速并不准确。
七、所有真实数据放在一起
| 阶段 | 测试数据 | 相对上一有效版本 | 相对基线 |
|---|---|---|---|
| 基线 | 代表值 6.49s;内部 ready 5258ms | --- | --- |
| wait_add_track_ms=1000 | 内部 ready 5.242 / 5.254 / 8.284 / 8.489s;一次 10s 超时 | 无提升 | 无提升 |
| 方案 1 | 8 次 1.43--2.38s,平均 1.92s | 缩短 4.57s | 降低约 70% |
| 方案 2 | 1.55 / 2.07 / 2.03 / 1.76s,平均 1.8525s | 缩短约 0.07s,约 3%--4% | 降低约 71% |
| 最终常规路径 | 1.937 / 2.104 / 1.667s,平均 1.9027s | 无显著平均提升 | 降低 70.7% |
| 最终全样本 | 再计入 2.350s 自动重试轮,平均 2.0145s | 因包含异常恢复,慢约 0.16s | 降低 69.0% |

(一)最终启动时间线

这些内部阶段存在重叠,不能直接相加。尤其是 offer、ICE、RTP 发送和 ZLM ready 轮询可能并行推进;版本对比仍以端到端 HTTP time_total 为准。
目前实测观察到约 1 秒的主要固定耗时集中在 ZLM /index/api/webrtc 的 answer 阶段 (样本为 1.003--1.006s)。本文没有通过 ZLMediaKit 内部调用栈证明具体原因,因此不把这个 1 秒写成 已确认的根因。
(二)哪些是事实,哪些还只是当前推断
| 结论强度 | 本文能够支持的内容 |
|---|---|
| 已确认的事实 | candidate 数量收敛后,连接耗时下降;主动 IDR 后,H.264 ready 等待明显下降;UDP-only 后正常 gathering 稳定在 1--2ms;单次自动重试处理了一次 candidate 异常,并在 2.350s 内成功。 |
| 实验结果 | 第一轮 8 次成功样本平均 1.92s;方案 2 四次平均 1.8525s;最终三次常规平均 1.9027s,计入一次重试后四次平均 2.0145s。 |
| 当前无法精确证明 | 方案 1 中 candidate filter 与 force-IDR 各自贡献多少;ZLM answer 约 1 秒具体来自哪个内部定时器或等待条件;4--8 次样本不能证明长期 P95/P99。 |
candidate 收敛 + force IDR 同时上线,因此不能把 4.57 秒精确分摊到两个改动。
八、这些优化适用于什么场景?
本文的 UDP-only 和 host candidate 收敛建立在一个非常具体的拓扑上:publisher 与 ZLMediaKit 运行在同一块 RK3588 上,板内 IP 已知,不需要公网 NAT 穿透,不依赖 TURN,也不需要 TCP fallback。 在这些前提下,限制候选集合是在减少无意义的不确定性。
| 可以直接参考 | 不能直接照搬 UDP-only |
|---|---|
| publisher 与 ZLM 同机;网络拓扑固定;板内 UDP 可达;无需 TURN 和 TCP 回退。 | publisher 与 ZLM 不同机;跨 NAT 或公网 WebRTC;依赖 TURN relay;复杂多网卡;网络要求 TCP fallback。 |
本文优化的是这个项目的已知拓扑,而不是提出一个适用于所有 WebRTC 场景的通用 UDP-only 配置。在公网或未知网络中直接删除 TCP、STUN/TURN candidate,可能让连接能力倒退。
九、可以迁移到其他嵌入式流媒体项目的经验
- 先定义完成语义。"接口返回了"与"媒体能播放"不是一回事。只有先规定 track ready, 测出来的启动时间才有业务意义。
- **按状态机打点,不要先怀疑算力。**pipeline、offer、candidate、answer、CONNECTED、首个 IDR、track ready 每个边界都要有时间。RK3588 CPU 使用率正常不代表状态机没有等待。
- **同机通信应显式收敛网络拓扑。**候选越多并不等于更稳。已知双方同机时,绑定一个板内 UDP host candidate 能减少不确定路径。
- **关键帧要在消费者真正可接收时触发。**启动编码器时发 IDR 可能太早;只在 CONNECTED 后发又可能错过准备窗口。remote description 和 CONNECTED 两阶段覆盖更完整。
- **优化平均值也要优化尾部失败。**正常 1ms gathering 很漂亮,但一次 10s 超时足以破坏用户 体验。短超时、限定错误类型、只重试一次,比无限重试更容易控制。
- 保留无效实验。
wait_add_track_ms没有收益,反而帮助排除错误假设。技术复盘 如果只保留成功方案,会让后来者重复走弯路。 - **组合上线不能伪造单项收益。**candidate filter 和 IDR 在同一轮上线,只能报告组合收益。 没有独立 A/B 数据,就不要人为分摊。
- **样本量决定结论强度。**4--8 次测试能确认 6.49s 到约 1.9s 的数量级变化,但不能替代长期 P95/P99 压测。
- **资源约束属于架构条件。**常驻 publisher 可以更快,但资源不可接受。最终的约 1.7--2.1s 是在"无人观看回收、按需冷启动"约束下取得的结果。
(一)回答全文的四个问题
| 问题 | 答案 |
|---|---|
| 为什么慢? | 板内 publisher 处理多余 ICE 路径,加上连接后等待自然 H.264 IDR。 |
| 怎么定位? | 用状态日志拆出 CONNECTED 和 track ready 边界,再用 candidate 收敛、强制 IDR 做对照实验。 |
| 怎么优化? | answer 和 offer 都限制为板内 UDP host candidate;在 remote description 与 CONNECTED 两阶段强制 IDR;首 candidate 1s 超时后限定重试一次。 |
| 什么时候不能照搬? | publisher 与 ZLM 非同机、跨 NAT、公网、TURN、复杂多网卡或需要 TCP fallback 时,不能直接套用本文 UDP-only 策略。 |
最终常规冷启动平均约 1.90s,相对 6.49s 降低 70.7%。第一轮解决了主要性能瓶颈;第二轮主要改善 candidate 的确定性;第三轮主要改善异常路径和尾部稳定性。约 1.7--2.1s 是当前"按需冷启动、不常驻 publisher"的实际结果。若继续优化,应依次调查 ZLM answer 阶段实测约 1 秒的耗时、评估 publisher 常驻/复用,或讨论 ready 前提前返回。但后两者会改变资源约束或"回复即可以播放"的语义,因此当前停止。
