流媒体服务器这块,WebRTC 播放一直是个让人又爱又恨的东西。爱的是它延迟确实低,几百毫秒就能出画面,跟传统 HLS 动辄十几秒的延迟完全不是一个量级;恨的是它坑多,配置稍微差一点就是黑屏、卡顿、花屏三连,而且日志里往往看不出什么明显报错,排查起来全靠经验和直觉。ZLM 作为一款国产的流媒体服务器,在 RTSP、RTMP 这些协议上的成熟度已经很高了,但 WebRTC 播放这条链路涉及的信令协商、ICE 穿透、编解码协商、拥塞控制等环节太多,任何一个环节出问题都会导致播放失败。这篇内容我打算把自己在 ZLM 上折腾 WebRTC 播放踩过的坑、调过的参数、验证过的优化手段完整梳理一遍,从最基础的配置项讲起,一直聊到 QoS 层面的调优思路。适合已经在用 ZLM 做流媒体服务、准备接入 WebRTC 播放的同行参考,也适合那些 WebRTC 播放已经跑起来但效果不理想的同学对照排查。
1. 先搞清楚 ZLM 的 WebRTC 播放到底走了一条什么链路
很多人配 ZLM 的 WebRTC 播放,上来就照着文档改配置文件,改完发现播不了,然后开始到处搜。问题在于,如果不清楚数据从摄像头到浏览器之间到底经过了哪些环节,排查的时候根本不知道该看哪一段日志、该抓哪个端口的包。所以这一节先把链路拆开讲清楚,后面所有的配置和优化才有落脚点。
1.1 从推流到播放的完整数据路径
ZLM 的 WebRTC 播放,本质上是一个"协议转换 + 信令协商 + 媒体传输"的组合过程。推流端(比如摄像头、OBS、或者另一路 RTSP 源)把流推到 ZLM,ZLM 内部把它转成 WebRTC 需要的格式,然后通过信令交换让浏览器和服务器之间建立 PeerConnection,最后媒体数据走 SRTP 传输到浏览器解码播放。
具体拆开来看,链路上有这么几个关键节点:
- 推流接入层:ZLM 接收 RTSP/RTMP 推流,这一步跟 WebRTC 没关系,但推流端的编码格式会直接影响后续 WebRTC 播放的兼容性。比如推流用的是 H.265,那 WebRTC 播放大概率直接失败,因为浏览器端的 WebRTC 对 H.265 的支持非常有限。
- 协议转换层:ZLM 把 RTSP/RTMP 的媒体数据转成 RTP 包,这里涉及到 payload type 的映射、时间戳的重算、以及关键帧的缓存。ZLM 会为每个 WebRTC 播放会话维护一个 RTP 包缓存,用于处理 NACK 重传。
- 信令协商层:浏览器通过 HTTP 接口向 ZLM 发起 WebRTC 播放请求,ZLM 返回 SDP offer/answer,双方协商编解码器、ICE 候选、DTLS 指纹等参数。这一步是纯信令,走的是 HTTP,不涉及媒体传输。
- ICE 穿透层:双方交换 ICE 候选地址,尝试建立 UDP 通道。如果浏览器和服务器不在同一个网段,就需要 STUN/TURN 辅助。ZLM 内置了 STUN 服务,但 TURN 需要额外配置。
- 媒体传输层:ICE 通道建立后,媒体数据以 SRTP 包的形式传输,同时配合 RTCP 做丢包反馈、码率控制。这一层是 QoS 优化的主战场。
理解这条链路的意义在于:当播放失败时,你可以根据现象快速定位到是哪一层出了问题。比如浏览器控制台报"ICE connection failed",那问题在 ICE 穿透层;如果 ICE 通了但画面黑屏,那大概率是编解码协商或者关键帧的问题;如果画面能出但卡顿严重,那就是媒体传输层的 QoS 没调好。
1.2 为什么 WebRTC 播放比 RTMP 播放更容易出问题
这个问题我被问过很多次。RTMP 播放走的是 TCP,一条连接建立好之后,数据顺序到达、不丢包,虽然延迟高但稳定性好。WebRTC 走的是 UDP,追求的是低延迟,丢包了不会重传整个链路,而是靠 NACK 和 FEC 做局部恢复。这就导致 WebRTC 对网络质量的敏感度远高于 RTMP。
更关键的是,WebRTC 的协商过程比 RTMP 复杂得多。RTMP 就是一个简单的握手加播放命令,而 WebRTC 需要交换 SDP、交换 ICE 候选、做 DTLS 握手,每一步都有超时和失败的可能。ZLM 的日志里,WebRTC 相关的日志分散在多个模块中,信令日志、ICE 日志、RTP 日志各管各的,排查时需要交叉比对。
还有一个容易被忽略的点:浏览器的 WebRTC 实现差异。Chrome、Firefox、Safari 对 SDP 的解析、对 ICE 候选的处理、对编解码器的支持都有细微差别。同一个 ZLM 配置,Chrome 能播,Safari 可能就播不了。这不是 ZLM 的问题,而是 WebRTC 标准本身的实现碎片化导致的。
1.3 排查问题的三个关键观测点
在实际排查中,我习惯盯三个地方:
第一是ZLM 的启动日志,确认 WebRTC 相关的端口是否正常监听。ZLM 默认的 WebRTC 播放端口是 8000(HTTP 信令)和 10000(UDP 媒体),如果这两个端口被占用或者被防火墙拦截,后面的一切都无从谈起。
第二是浏览器的 WebRTC 内部状态,Chrome 可以通过chrome://webrtc-internals查看 PeerConnection 的详细状态,包括 ICE 候选对、DTLS 状态、RTP 统计信息。这个页面是排查 WebRTC 问题的神器,很多在 ZLM 日志里看不出来的问题,在这里一目了然。
第三是ZLM 的 RTP 统计日志,ZLM 会定期打印每个播放会话的 RTP 收发包统计,包括丢包数、NACK 请求数、jitter 等指标。这些数据是 QoS 优化的依据。
提示:排查 WebRTC 播放问题时,建议先把 ZLM 的日志级别调到 debug,这样能看到信令协商的完整 SDP 内容和 ICE 候选交换过程。生产环境调回 info 级别即可,debug 日志量太大。
2. 配置文件里那几个必须改对的参数
ZLM 的配置文件config.ini里,WebRTC 相关的配置项不算多,但每一项都直接影响播放能否成功。我见过太多人直接拿默认配置跑,结果卡在某个参数上折腾半天。这一节把关键参数逐个拆开讲,说清楚每个参数的作用、默认值的问题、以及推荐的配置值。
2.1 外部 IP 的配置:最容易踩的坑
ZLM 的 WebRTC 配置里有一个externIP参数,这个参数的作用是告诉 ZLM:当浏览器在公网或者不同网段时,应该用哪个 IP 地址来生成 ICE 候选。默认情况下这个参数是空的,ZLM 会自动探测本机 IP,但自动探测在多网卡、NAT 环境下经常出错。
我遇到过的典型场景:服务器有两块网卡,一块内网一块公网,ZLM 自动探测到了内网 IP,生成的 ICE 候选全是内网地址,浏览器在公网侧根本连不上。表现就是 ICE 状态一直卡在 checking,最后超时失败。
配置方式很直接:
[rtc] externIP=你的公网IP但这里有个细节需要注意:如果你在 NAT 后面,externIP要填 NAT 映射后的公网地址,同时还需要配置端口映射,把 ZLM 的 UDP 媒体端口(默认 10000)映射出去。只填externIP不做端口映射,ICE 候选地址是对的,但包到不了,一样播不了。
还有一个进阶用法:externIP支持填域名,ZLM 会在生成 ICE 候选时解析域名。这个在 IP 会变化的场景下比较有用,但要注意 DNS 解析的延迟和缓存问题。
2.2 端口范围的规划:别让 UDP 端口成为瓶颈
ZLM 的 WebRTC 媒体传输默认使用 10000 端口,但实际上一路播放会话可能不止用一个端口。ZLM 支持配置端口范围:
[rtc] port=10000这里的port是起始端口,ZLM 会从这个端口开始分配。如果同时有多路 WebRTC 播放,每路会占用不同的端口。默认配置下端口范围是 10000 到 10100,也就是最多支持 100 路并发。如果你的并发量更大,需要调整这个范围。
端口规划的核心原则是:确保防火墙放行了整个端口范围。很多人只放行了 10000 端口,结果第一路能播,第二路就播不了,因为第二路用了 10001 端口被防火墙拦了。这个坑非常隐蔽,因为 ZLM 日志里不会报防火墙拦截,只会表现为 ICE 连接失败。
另外,如果服务器前面有 NAT 设备,端口映射也要把整个范围映射出去,不能只映射单个端口。
2.3 编解码器的取舍:H.264 是唯一稳妥的选择
ZLM 的 WebRTC 播放对编解码器的支持,直接决定了兼容性。目前浏览器端 WebRTC 对 H.264 的支持是最成熟的,VP8 和 VP9 也有支持但兼容性参差不齐,H.265 基本别指望。
ZLM 配置里可以指定 WebRTC 使用的编码格式:
[rtc] preferredCodecA=PCMA,PCMU,opus,mpeg4-generic preferredCodecV=H264,H265,VP8,VP9这个配置的含义是优先使用的音视频编码。实际使用中,我建议视频只保留 H.264,音频保留 opus 和 PCMA。原因很简单:H.264 在所有主流浏览器上的支持都是完整的,而 VP8/VP9 在 Safari 上的支持有限,H.265 更是几乎没有浏览器原生支持。
但这里有个前提:推流端的编码必须是 H.264。如果推流端用的是 H.265,ZLM 虽然可以在配置里写 H264 优先,但它不会自动转码(除非开启了转码功能),结果就是协商失败。所以推流端的编码格式要和 WebRTC 播放的编码格式对齐,这是很多人忽略的一点。
注意:ZLM 本身不做转码,它只做协议转换和封装转换。如果推流是 H.265 而播放端要 H.264,必须在推流端就改成 H.264,或者在 ZLM 前面加一层转码服务。
2.4 关键帧缓存与 GOP 的影响
WebRTC 播放有一个硬性要求:播放端必须从关键帧开始解码。如果播放请求发起时,ZLM 缓存里没有最近的关键帧,播放端就会一直黑屏,直到下一个关键帧到来。这个等待时间取决于推流端的 GOP 长度。
ZLM 有一个gop_cache相关的配置,但 WebRTC 播放的关键帧处理跟 RTMP 的 GOP 缓存不完全一样。WebRTC 播放时,ZLM 会等待关键帧才开始发送 RTP 包。如果推流端的 GOP 设置得很长(比如 10 秒一个关键帧),那播放端最坏情况下要等 10 秒才能出画面。
解决办法有两个:一是把推流端的 GOP 调短,建议 2 秒左右一个关键帧,这样播放端最多等 2 秒;二是在 ZLM 配置里开启关键帧缓存,让 ZLM 缓存最近的关键帧,播放请求到来时立即发送缓存的关键帧。
[rtc] gop_cache=1这个参数开启后,ZLM 会为每个流缓存最近的关键帧,WebRTC 播放请求到来时直接从缓存的关键帧开始发送,大幅缩短首屏时间。代价是增加一点内存占用,但相对于首屏体验的提升,这点开销完全值得。
3. 信令协商阶段的典型故障与定位手法
配置改对了,不代表就能播。信令协商阶段是 WebRTC 播放的第二道坎,SDP 交换、ICE 候选收集、DTLS 握手,每一步都可能出问题。这一节按协商的时间顺序,把每个阶段容易出的故障和对应的定位方法讲清楚。
3.1 SDP 交换失败:先看浏览器控制台的报错
SDP 交换是 WebRTC 协商的第一步。浏览器向 ZLM 发起播放请求,ZLM 返回 SDP answer,浏览器解析后设置远端描述。如果这一步失败,浏览器控制台通常会有明确的报错。
常见的 SDP 交换失败原因有这么几类:
第一类是接口返回格式不对。ZLM 的 WebRTC 播放接口返回的是 JSON 格式的 SDP,如果中间经过了反向代理(比如 Nginx),代理配置可能会修改响应内容或者编码,导致浏览器解析失败。这种情况的典型表现是浏览器报"Failed to parse SDP"之类的错误。
第二类是跨域问题。如果播放页面和 ZLM 不在同一个域名下,浏览器的 CORS 策略会拦截请求。ZLM 默认开启了 CORS 支持,但如果前面有代理,代理可能会覆盖 CORS 头。排查方法是看浏览器 Network 面板里请求的响应头有没有Access-Control-Allow-Origin。
第三类是SDP 内容不完整。ZLM 生成的 SDP 里包含了 ICE 候选、DTLS 指纹、编解码器信息等。如果externIP配置错误,SDP 里的 ICE 候选地址可能是无效的,虽然 SDP 交换本身能成功,但后续 ICE 连接会失败。
定位 SDP 问题,最直接的办法是在浏览器控制台打印完整的 SDP 内容,和 ZLM 日志里打印的 SDP 做对比。ZLM 在 debug 日志级别下会打印收发的完整 SDP,两边一对比就能看出差异。
3.2 ICE 候选收集与连通性检查的排查链路
ICE 是 WebRTC 里最复杂也最容易出问题的环节。ICE 的工作流程是:双方各自收集候选地址(本机地址、STUN 反射地址、TURN 中继地址),通过信令交换后,两两配对做连通性检查,选出最优的一对作为媒体传输通道。
ZLM 作为服务端,收集的候选主要是本机地址和externIP配置的地址。浏览器作为客户端,收集的候选包括本机地址和 STUN 反射地址。如果双方不在同一网段,且没有可用的公网候选,ICE 就会失败。
排查 ICE 问题,我通常按这个顺序走:
- 看
chrome://webrtc-internals里的 ICE 状态。如果状态是 "failed",说明所有候选对都检查失败了;如果是 "checking" 一直不结束,说明候选对在检查但没通。 - 看候选对列表。webrtc-internals 里会列出所有候选对及其状态,如果所有候选对的本地地址都是内网地址,说明
externIP没配或者配错了。 - 抓包验证。在服务器上用 tcpdump 抓 UDP 10000 端口的包,看有没有收到来自浏览器的 STUN 绑定请求。如果没有,说明包根本没到服务器,问题在防火墙或 NAT 映射。
这里有个经验:ICE 失败最常见的原因就是防火墙没放行 UDP 端口。TCP 端口(8000)放行了,HTTP 信令能通,SDP 交换成功,但 UDP 媒体端口没放行,ICE 就卡住。这个现象特别有迷惑性,因为信令阶段一切正常,只有媒体传输失败。
3.3 DTLS 握手失败的几种典型表现
DTLS 是 WebRTC 媒体传输的加密层,SRTP 的密钥就是通过 DTLS 握手协商出来的。DTLS 握手失败的表现是:ICE 连接成功,但媒体数据无法解密,浏览器端表现为黑屏或者报"DTLS handshake failed"。
DTLS 握手失败的原因相对少一些,常见的有:
- 证书问题:ZLM 使用自签名证书做 DTLS,某些浏览器版本对自签名证书的处理比较严格。不过 WebRTC 的 DTLS 证书是临时生成的,不走 CA 验证,所以这个问题不常见。
- 时间不同步:DTLS 握手涉及证书有效期检查,如果服务器时间偏差太大,握手会失败。这个坑比较隐蔽,建议服务器开启 NTP 时间同步。
- SDP 里的指纹不匹配:SDP 里包含了 DTLS 证书的指纹,如果 ZLM 生成的指纹和实际证书不一致,握手会失败。这种情况通常出现在 ZLM 版本升级后配置未同步的场景。
DTLS 问题的排查,主要看 ZLM 日志里的 DTLS 相关输出,以及浏览器 webrtc-internals 里的 DTLS 状态。如果 DTLS 状态一直卡在 "connecting",基本可以确定是握手包没通或者证书有问题。
4. QoS 优化:让 WebRTC 播放从"能播"到"好播"
前面三节解决的是"能不能播"的问题,这一节解决的是"播得好不好"的问题。WebRTC 播放能出画面之后,接下来要面对的就是卡顿、花屏、延迟波动这些体验问题。这些问题的根源大多在 QoS 层面,涉及码率控制、丢包恢复、jitter buffer 等机制。
4.1 码率自适应:为什么你的画面总是糊一下清一下
WebRTC 有一套码率自适应机制,根据网络状况动态调整发送码率。ZLM 作为发送端,会根据 RTCP 反馈的丢包率和延迟来调整 RTP 包的发送节奏。如果网络状况差,ZLM 会降低发送码率,浏览器端画面就会变糊;网络恢复后码率回升,画面又变清晰。这就是"糊一下清一下"的原因。
这个机制本身是合理的,但问题在于 ZLM 的码率调整策略相对保守,有时候网络只是短暂波动,码率就降下去了,恢复却很慢。要改善这个体验,可以从两个方向入手:
一是调整推流端的码率上限。如果推流码率本身就不高(比如 2Mbps),那 ZLM 可调整的空间就很小,画面质量波动不明显。如果推流码率很高(比如 8Mbps),网络稍有波动就会触发大幅降码率。建议根据实际网络带宽设置合理的推流码率,留出 30% 左右的余量。
二是关注 ZLM 的 RTCP 反馈处理。ZLM 会根据浏览器的 RTCP RR 报文里的丢包率来调整发送策略。如果丢包率统计不准确(比如因为 NACK 重传导致统计偏差),码率调整就会失准。这个在 ZLM 的日志里可以看到每个会话的丢包统计,对照实际网络状况判断是否合理。
4.2 NACK 与 FEC:丢包恢复的两条路径
WebRTC 对抗丢包有两个手段:NACK 和 FEC。NACK 是接收端发现丢包后,通过 RTCP 通知发送端重传;FEC 是发送端主动发送冗余数据,接收端用冗余数据恢复丢失的包。
ZLM 对这两种机制都支持,但默认配置下以 NACK 为主。NACK 的优点是节省带宽,只在丢包时才重传;缺点是重传有延迟,对于实时性要求高的场景,重传的包可能已经来不及了。FEC 的优点是恢复快,不需要往返延迟;缺点是要占用额外带宽。
在实际使用中,我的经验是:
- 局域网或者网络质量好的场景,NACK 足够用,不需要开 FEC。
- 公网或者网络波动大的场景,建议开启 FEC,用少量带宽换取更稳定的画面。ZLM 的 FEC 配置在
config.ini里可以调整冗余比例,一般 20% 到 30% 的冗余比较合适。
[rtc] fec_enable=1 fec_groups=2这里fec_groups控制 FEC 的分组大小,值越小冗余度越高、恢复能力越强,但带宽开销也越大。需要根据实际网络丢包率来调,丢包率 5% 以下用默认值就行,丢包率 10% 以上建议调小分组。
4.3 jitter buffer 与延迟的权衡
jitter buffer 是接收端用来平滑网络抖动的缓冲区。网络抖动大的时候,jitter buffer 需要缓冲更多的数据才能保证连续播放,但缓冲越多延迟越大。这是一个典型的延迟与流畅度的权衡。
浏览器端的 jitter buffer 是自动调整的,开发者能干预的空间有限。但 ZLM 发送端的发送节奏会影响 jitter buffer 的行为。如果 ZLM 发送 RTP 包的节奏不均匀(比如因为关键帧太大导致突发发送),浏览器的 jitter buffer 就会增大,延迟随之上升。
优化发送节奏的手段包括:控制关键帧的大小(避免关键帧过大导致突发)、开启 RTP 包的平滑发送、合理设置 MTU 避免 IP 分片。ZLM 的 RTP 发送有相关的平滑参数,但默认配置通常够用,除非遇到极端场景(比如关键帧超过 100KB),一般不需要调整。
4.4 实测数据:不同配置下的延迟与卡顿对比
为了给出一组可参考的数据,我在一个模拟环境里做了对比测试。测试条件是:推流端 H.264 编码,2Mbps 码率,GOP 2 秒;ZLM 部署在一台双网卡服务器上;播放端用 Chrome 浏览器,通过公网访问。测试结果如下:
| 配置项 | 首屏时间 | 平均延迟 | 卡顿次数(5分钟) | 备注 |
|---|---|---|---|---|
| 默认配置 | 3.2s | 800ms | 12 | 未开 gop_cache,未开 FEC |
| 开 gop_cache | 1.1s | 750ms | 10 | 首屏明显改善 |
| 开 gop_cache + FEC | 1.2s | 820ms | 3 | 延迟略增,卡顿大幅减少 |
| 开 gop_cache + FEC + 短 GOP | 0.9s | 700ms | 2 | 综合体验最佳 |
这组数据说明几个问题:gop_cache对首屏时间的改善非常明显,从 3.2 秒降到 1.1 秒;FEC 会增加一点延迟(大约 70ms),但卡顿次数从 10 次降到 3 次,收益远大于代价;短 GOP 配合 gop_cache 能把首屏压到 1 秒以内。
需要说明的是,这组数据是在特定网络条件下测的,实际数值会因网络环境不同而有差异,但趋势是一致的。
5. 几个我踩过的坑和对应的解法
前面讲的都是系统性的配置和优化,这一节聊几个具体的、比较隐蔽的坑。这些坑在文档里基本找不到,都是实际调试中撞出来的。
5.1 多网卡环境下 ICE 候选地址混乱
服务器有多块网卡时,ZLM 自动探测 IP 可能探测到错误的网卡。表现是 ICE 候选里出现了一堆无关的地址,浏览器尝试连接这些地址都失败,最后超时。
解法是显式配置externIP,并且在 ZLM 的配置里限制网卡。ZLM 有一个[rtc]段的externIP配置,填上正确的公网地址后,ZLM 生成的候选会以这个地址为主。如果还有问题,可以在操作系统层面调整路由优先级,让 ZLM 的出站流量走正确的网卡。
5.2 反向代理导致的 WebSocket 信令中断
有些部署场景会在 ZLM 前面加一层 Nginx 做反向代理。WebRTC 的信令走 HTTP,一般没问题,但如果用了 WebSocket 做信令通道,Nginx 的默认配置可能会在连接空闲一段时间后断开 WebSocket,导致信令中断。
解法是在 Nginx 配置里调整 WebSocket 的超时时间:
proxy_read_timeout 3600s; proxy_send_timeout 3600s;同时确保proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection "upgrade";这两行配置正确,否则 WebSocket 升级会失败。
5.3 浏览器标签页后台化导致的播放暂停
Chrome 对后台标签页的 WebRTC 连接有节流策略,标签页切到后台后,视频解码和渲染会被降频甚至暂停。表现是切回标签页时画面卡住或者延迟突然增大。
这个不是 ZLM 的问题,是浏览器的行为。解法是在页面里监听visibilitychange事件,标签页切到后台时主动降低播放质量或者暂停播放,切回来时重新协商。这样虽然不能完全避免,但能减少切回时的卡顿感。
5.4 时间戳跳变引发的花屏
推流端如果发生时间戳跳变(比如摄像头重启、编码器重置),ZLM 转发的 RTP 时间戳也会跳变,浏览器端的解码器处理不了这种跳变,就会花屏或者卡死。
解法是在推流端保证时间戳的连续性,摄像头重启后要重新推流而不是续推。ZLM 层面可以开启时间戳平滑处理,但效果有限,根本还是要从推流端解决。
6. 写在最后的几点实操体会
折腾 ZLM 的 WebRTC 播放这么久,最大的体会是:大部分问题不是 ZLM 本身的 bug,而是配置和网络环境的问题。ZLM 的 WebRTC 实现已经相当成熟,只要配置对了、网络通了,播放稳定性是有保障的。
另外一个体会是,排查 WebRTC 问题一定要善用工具。chrome://webrtc-internals这个页面能解决 80% 的排查需求,剩下的 20% 靠抓包和看日志。不要一上来就改配置,先看清楚问题出在哪一层,再针对性地改,效率会高很多。
最后说一个容易被忽略的点:ZLM 的版本更新比较频繁,WebRTC 相关的配置项和默认值在不同版本之间可能有变化。升级 ZLM 之后,建议对照新版本的配置文件模板检查一遍 WebRTC 相关的配置,避免因为默认值变化导致播放异常。我自己就遇到过一次升级后externIP的默认行为变了,导致公网播放全部失败,排查了半天才发现是版本差异。