1. 从一次卡顿排查说起:为什么要啃 SRS 的 WebRTC 播放源码
大概半年前,我负责的一个在线协作白板项目遇到一个很典型的问题:用户反馈在弱网环境下,白板上的光标移动会出现明显的“跳跃感”,延迟忽高忽低,有时候甚至直接黑屏几秒。这个白板底层用的是 SRS 的 WebRTC 播放能力,服务端版本是 5.0,客户端走的是标准 WHEP 流程。当时我第一反应是网络抖动,但抓包之后发现 RTP 包到达节奏其实还算均匀,问题出在服务端到客户端这一段的数据分发上。
那次排查逼着我把 SRS 里 WebRTC 播放相关的源码从头到尾读了一遍,从srs_app_rtc_conn.cpp到srs_app_rtc_source.cpp,再到srs_app_rtc_dtls.cpp和srs_app_rtc_sdp.cpp,前后大概花了两周时间。读完之后很多之前“知其然不知其所以然”的设计一下子通了,比如为什么 SRS 要用SrsRtcConnection和SrsRtcPublishStream两层结构、为什么播放端的SrsRtcPlayStream要单独维护一个SrsRtcConsumer、NACK 和 PLI 到底在哪个环节被触发。
这篇文章就是那次源码阅读的完整整理。我会从整体架构讲到关键类,再到具体的数据流转和排查技巧,尽量把“为什么这么设计”讲清楚。适合已经用过 SRS 但想深入理解 WebRTC 播放链路的同学,也适合正在做实时音视频、需要自己改 SRS 源码的开发者。如果你只是想让 SRS 跑起来,那官方文档就够了;但如果你想在出问题时能快速定位、甚至自己动手改,那这篇内容应该能帮到你。
2. SRS WebRTC 播放的整体架构与核心类拆解
2.1 播放链路在 SRS 里的位置
SRS 的 WebRTC 播放,本质上是把一路已经存在于服务端的媒体流,通过 WebRTC 的传输通道分发给订阅者。这里有个容易混淆的点:SRS 里 WebRTC 既可以做推流(publish),也可以做播放(play),两者共用同一套 ICE/DTLS/SRTP 基础设施,但业务逻辑完全分开。播放端的入口在SrsRtcConnection里,当收到一个PLAY类型的 SDP offer 时,会创建一个SrsRtcPlayStream对象来专门处理这路订阅。
整个播放链路可以粗略分成四层:传输层(ICE/DTLS/SRTP)、会话层(SrsRtcConnection)、流层(SrsRtcPlayStream)、消费者层(SrsRtcConsumer)。传输层负责把加密的 RTP 包收进来、发出去;会话层管理这个 PeerConnection 的生命周期;流层负责把媒体源和这个订阅者关联起来;消费者层则是实际从源里取数据、做缓存、按需重传的地方。
我当初读源码时最大的困惑是:为什么播放端要单独搞一个SrsRtcConsumer,而不是直接让SrsRtcPlayStream去源里拿数据?后来在srs_app_rtc_source.cpp里找到了答案——SrsRtcConsumer继承自SrsConsumer,它复用了 SRS 在 RTMP 时代就沉淀下来的消费者模型,包括消息队列、超时控制、丢包统计这些。这样设计的好处是 WebRTC 播放和 RTMP 播放能共享同一套源管理逻辑,减少重复代码,也方便统一做流控。
2.2 核心类职责对照
为了让大家有个清晰的全局观,我把播放链路里最关键的几个类整理成了一张表。这张表是我自己读源码时画的,后来发现每次排查问题都能用上。
| 类名 | 所在文件 | 核心职责 | 关键成员 |
|---|---|---|---|
SrsRtcConnection | srs_app_rtc_conn.cpp | 管理单个 PeerConnection 的完整生命周期,处理 SDP 协商、ICE、DTLS | play_streams_、publish_stream_、dtls_、ice_ |
SrsRtcPlayStream | srs_app_rtc_conn.cpp | 代表一路播放订阅,管理该订阅下的所有 consumer | req_、consumers_、source_ |
SrsRtcConsumer | srs_app_rtc_conn.cpp | 实际从源取数据、缓存、触发 NACK/PLI | consumer_、queue_、nack_timer_ |
SrsRtcSource | srs_app_rtc_source.cpp | 管理一路媒体源,维护所有订阅者列表 | consumers_、can_publish_、stream_desc_ |
SrsRtcPublishStream | srs_app_rtc_conn.cpp | 推流端逻辑,负责把收到的 RTP 包写入源 | source_、rtp_queue_ |
SrsRtcDtls | srs_app_rtc_dtls.cpp | DTLS 握手与 SRTP 密钥导出 | ssl_、srtp_recv_key_、srtp_send_key_ |
SrsRtcSdp | srs_app_rtc_sdp.cpp | SDP 解析与生成,包括 offer/answer 处理 | media_descs_、ice_ufrag_ |
这张表里我特意把SrsRtcPublishStream也放进来了,因为播放和推流在源码里是成对出现的,理解推流端有助于理解播放端的数据来源。比如SrsRtcSource里的on_rtp方法,就是推流端写入、播放端读取的交汇点。
2.3 为什么播放端要独立于推流端
这里展开说一下设计上的取舍。SRS 早期版本里,WebRTC 的推流和播放是耦合在一个SrsRtcConnection里的,后来拆成了SrsRtcPublishStream和SrsRtcPlayStream两个独立对象。拆分的直接原因是:一个 PeerConnection 理论上可以同时推流和播放,但实际业务里这两种角色的生命周期、错误处理、资源占用差异很大。推流端更关心 RTP 包的完整性和时序,播放端更关心分发的实时性和重传效率。
拆开之后还有个好处:播放端的 consumer 可以独立做流控。比如某个订阅者网络很差,SrsRtcConsumer的队列满了之后可以选择丢弃旧包而不是阻塞整个源。这个逻辑在srs_app_rtc_conn.cpp的SrsRtcConsumer::on_rtp里能看到,当队列长度超过阈值时,会触发queue_->set_overflow(),后续的包会被直接丢掉,同时通过 NACK 机制让客户端主动请求重传。这种“宁可丢包也不阻塞”的策略,在实时场景里比“保证每个包都送到”更重要。
3. 播放源码的关键流程与实操要点
3.1 从 SDP offer 到 play stream 创建
播放的起点是客户端发来的 SDP offer。SRS 收到 offer 后,在SrsRtcConnection::on_sdp_offer里会先判断这是推流还是播放。判断依据是 SDP 里有没有sendonly或recvonly属性。如果是recvonly,说明客户端只想接收,那就是播放请求。
判断为播放后,SRS 会做几件事:解析 SDP 里的媒体描述,提取出mid、payload type、ssrc、rtcp-mux这些信息;然后根据mid去SrsRtcSource里查找对应的源。这里有个细节:SRS 支持通过 URL 参数指定要播放的流,比如?stream=livestream,这个参数在SrsRtcPlayStream构造时会被解析出来,用来定位源。
源码里SrsRtcPlayStream::SrsRtcPlayStream的构造函数大概长这样(我简化了错误处理):
SrsRtcPlayStream::SrsRtcPlayStream(SrsRtcConnection* session, const SrsSdp& local_sdp) { session_ = session; req_ = new SrsRequest(); // 从 SDP 里提取 stream url req_->parse(local_sdp.media_descs_[0].stream_url_); // 查找或创建源 source_ = _srs_rtc_sources->fetch_or_create(req_); // 为每个媒体描述创建一个 consumer for (int i = 0; i < local_sdp.media_descs_.size(); i++) { SrsRtcConsumer* consumer = new SrsRtcConsumer(this, session_); consumers_.push_back(consumer); source_->consumer_add(consumer); } }这段代码里最关键的是fetch_or_create。如果源不存在,SRS 会创建一个空的SrsRtcSource,但此时还没有推流端写入数据,所以播放端会一直等。这个设计支持了“先播放后推流”的场景,但实际用的时候要注意:如果源一直没数据,客户端可能会超时断开。
提示:如果你在调试时发现播放端一直黑屏,先确认
SrsRtcSource的consumers_列表里有没有你的 consumer,再看source_->can_publish_是否为 true。如果源是空的,can_publish_会是 false,这时候播放端拿不到任何数据。
3.2 RTP 包的分发与缓存机制
播放端拿到数据的过程,本质上是SrsRtcSource把推流端写入的 RTP 包分发给所有 consumer。推流端在SrsRtcPublishStream::on_rtp里收到包后,会调用source_->on_rtp(pkt)。SrsRtcSource::on_rtp里会遍历consumers_列表,对每个 consumer 调用consumer->on_rtp(pkt)。
SrsRtcConsumer::on_rtp是播放链路里最核心的方法之一。它做了三件事:第一,把包放进queue_(一个SrsRtpQueue对象);第二,更新统计信息,比如收到的包数、字节数;第三,检查是否需要触发 NACK。这里有个容易忽略的点:queue_是有容量限制的,默认是 1024 个包。如果客户端消费速度跟不上,队列会满,这时候on_rtp会返回错误,但不会阻塞推流端。
我当初排查卡顿问题时,就是在SrsRtcConsumer::on_rtp里加了一行日志,打印队列长度。结果发现弱网时队列经常在 800 到 1000 之间波动,说明客户端确实消费不过来。后来调整了queue_的容量和客户端的 jitter buffer 参数,问题才缓解。
// 简化后的 on_rtp 逻辑 srs_error_t SrsRtcConsumer::on_rtp(SrsRtpPacket* pkt) { // 入队,如果队列满会返回错误 srs_error_t err = queue_->push(pkt); if (err != srs_success) { // 队列满,记录日志但不阻塞 srs_warn("rtc consumer queue overflow, drop packet"); return err; } // 更新统计 stat_->on_rtp(pkt->size()); // 检查 NACK if (nack_enabled_) { nack_timer_->update(pkt->header_.get_seq()); } return srs_success; }3.3 NACK 与 PLI 的触发时机
NACK(Negative Acknowledgement)和 PLI(Picture Loss Indication)是 WebRTC 里两个重要的反馈机制。NACK 用来请求重传丢失的 RTP 包,PLI 用来请求关键帧。在 SRS 的播放链路里,这两个机制的触发点都在SrsRtcConsumer里。
NACK 的触发逻辑在nack_timer_里。SrsRtcNackTimer会定期检查收到的包序列号,如果发现某个序列号长时间没到,就会生成一个 NACK 包发给客户端。这里有个参数叫nack_max_delay,默认是 100ms,意思是如果某个包超过 100ms 还没到,就认为丢了,触发重传请求。这个值不能设太小,否则网络稍微抖动就会疯狂发 NACK,反而加重拥塞;也不能设太大,否则客户端等太久才重传,体验会变差。
PLI 的触发更直接:当SrsRtcConsumer发现客户端请求关键帧,或者自己检测到丢包严重时,会调用source_->request_keyframe()。这个请求会一路传到推流端,推流端收到后会强制编码器产生一个关键帧。我在实际项目里遇到过 PLI 风暴的问题:客户端因为某个 bug 频繁发 PLI,导致推流端不断生成关键帧,码率飙升。后来在SrsRtcConsumer里加了一个 PLI 频率限制,比如 1 秒内最多响应一次,问题才解决。
注意:NACK 和 PLI 的配置在
srs_app_rtc_conn.cpp的SrsRtcConsumer构造函数里,可以通过SrsRtcConfig调整。如果你在做低延迟场景,建议把nack_max_delay调到 50ms 左右,同时开启pli_with_nack,让 PLI 和 NACK 协同工作。
4. 实操过程与核心环节实现
4.1 搭建一个可调试的 SRS WebRTC 播放环境
要读源码,光看是不够的,得有一个能跑起来、能打断点的环境。我一般用 Docker 起一个 SRS 实例,然后本地用 GDB 附加进去。具体步骤是这样的:
第一步,拉取 SRS 源码并编译带调试信息的版本。SRS 官方推荐用./configure && make,但默认是 Release 模式,没有调试符号。我一般会改成:
./configure --with-debug=on --with-rtc=on --with-ssl=on make -j4第二步,写一个最简单的 SRS 配置文件,只开 WebRTC 播放相关的功能:
listen 1935; max_connections 1000; srs_log_tank console; srs_log_level trace; rtc_server { enabled on; listen 8000; candidate 127.0.0.1; } vhost __defaultVhost__ { rtc { enabled on; rtmp_to_rtc on; rtc_to_rtmp on; } }第三步,用 FFmpeg 推一路 RTMP 流进去,SRS 会自动转成 WebRTC 可播放的源:
ffmpeg -re -i test.mp4 -c copy -f flv rtmp://127.0.0.1/live/livestream第四步,用浏览器打开 SRS 自带的播放页面,或者自己写一个简单的 WHEP 客户端。SRS 的trunk/research/players/目录下有个rtc_player.html,可以直接用。
这套环境搭好之后,你就可以在SrsRtcConsumer::on_rtp里打断点,观察每个 RTP 包的序列号、时间戳、队列长度。我当初就是靠这个环境,发现了一个队列溢出的边界条件:当队列长度刚好等于容量时,push会成功但pop会失败,导致包被卡住。后来在源码里加了一个queue_->size() >= queue_->capacity()的判断才解决。
4.2 关键参数的计算与选择
SRS 的 WebRTC 播放有几个关键参数,直接影响体验。我整理了一个表格,包含参数名、默认值、推荐值和计算依据。
| 参数名 | 默认值 | 推荐值 | 计算依据 |
|---|---|---|---|
queue_capacity | 1024 | 512~2048 | 根据客户端 jitter buffer 大小和网络 RTT 计算 |
nack_max_delay | 100ms | 50~80ms | 约等于 1.5 倍网络 RTT |
pli_interval | 无限制 | 1000ms | 避免 PLI 风暴,1 秒最多一次 |
rtp_timeout | 30s | 10~15s | 超过这个时间没收到包就认为断流 |
dtls_timeout | 10s | 5~8s | DTLS 握手超时,弱网下适当放宽 |
拿queue_capacity来说,它的计算逻辑是这样的:假设客户端 jitter buffer 是 200ms,网络 RTT 是 100ms,那么客户端最多可能积压 300ms 的数据。如果视频码率是 2Mbps,每包 1200 字节,那么 300ms 的数据大约是 2Mbps * 0.3s / 8 / 1200 ≈ 62 个包。考虑到突发流量,队列容量设为 512 到 1024 比较合适。如果设得太小,弱网时容易丢包;设得太大,内存占用高,而且延迟会增加。
nack_max_delay的计算更直接:它应该略大于网络 RTT,这样包丢失后能在客户端还没超时之前触发重传。如果 RTT 是 50ms,那nack_max_delay设为 75ms 左右比较合理。我实测下来,50ms 到 80ms 之间是个比较稳的区间。
4.3 从源码看 DTLS 与 SRTP 的衔接
WebRTC 播放的数据是加密的,所以 SRS 在播放链路里必须处理 DTLS 和 SRTP。DTLS 负责握手和密钥交换,SRTP 负责媒体数据的加密传输。在源码里,这部分逻辑主要在SrsRtcDtls和SrsRtcConnection里。
DTLS 握手的过程是这样的:客户端发来 ClientHello,SRS 回复 ServerHello 和证书,双方交换密钥材料,最后导出 SRTP 的加密密钥。导出的密钥有两个:srtp_recv_key_和srtp_send_key_,分别用于接收和发送方向。这里有个细节:SRS 用的是SSL_export_keying_material来导出密钥,导出标签是EXTRACTOR-dtls_srtp,这是 WebRTC 标准规定的。
SRTP 加密在SrsRtcConnection::on_rtp里处理。当 SRS 要发送一个 RTP 包给客户端时,会先调用srtp_protect加密,然后通过 UDP socket 发出去。接收方向则相反,收到 UDP 包后先srtp_unprotect解密,再交给上层处理。
我当初调试 DTLS 时遇到过一个坑:SRS 默认用的证书是自签名的,浏览器会报警告。如果是在生产环境,需要换成受信任的证书。另外,DTLS 握手对时间敏感,如果服务端和客户端时间差太大,握手会失败。我建议在服务端开启 NTP 同步,确保时间误差在 1 秒以内。
提示:如果你在浏览器控制台看到
DTLS handshake failed,先检查证书是否过期,再检查 ICE candidate 是否可达。SRS 的日志里会打印详细的 DTLS 错误码,比如SSL_ERROR_WANT_READ表示还在等数据,SSL_ERROR_SSL表示握手失败。
5. 常见问题与排查技巧实录
5.1 播放黑屏但 ICE 已连接
这是最常见的问题之一。ICE 连接成功说明网络通道没问题,但黑屏说明媒体数据没到。排查思路是这样的:
第一,检查SrsRtcSource里有没有数据。可以在SrsRtcSource::on_rtp里加日志,看推流端有没有写入。如果没有,说明推流端有问题,跟播放端无关。
第二,检查SrsRtcConsumer的队列。如果队列一直是空的,说明源里没数据,或者 consumer 没被正确添加到源的consumers_列表里。可以在SrsRtcPlayStream构造函数里加日志,确认 consumer 创建成功。
第三,检查 SDP 协商。有时候 offer 和 answer 的 payload type 不匹配,导致 RTP 包被丢弃。可以在SrsRtcSdp::parse里打印解析出的 payload type,跟客户端发的对比。
我遇到过一次黑屏是因为 SDP 里的ssrc冲突:推流端和播放端用了同一个ssrc,导致 SRS 在分发时混淆了。后来在SrsRtcPlayStream里加了一个ssrc重写逻辑,给每个 consumer 分配独立的ssrc,问题才解决。
5.2 延迟忽高忽低
延迟波动通常跟 jitter buffer 和 NACK 有关。如果客户端 jitter buffer 设得太大,延迟会高但稳定;设得太小,延迟低但容易抖动。SRS 这边能调的主要是nack_max_delay和queue_capacity。
我的经验是:先抓包看 RTP 包的到达间隔。如果间隔很均匀,但延迟还是波动,那问题可能在客户端。如果间隔本身就不均匀,那问题在服务端到客户端这一段。这时候可以尝试调大queue_capacity,让 SRS 多缓存一些包,平滑突发流量。但注意,缓存多了延迟也会增加,需要权衡。
还有一个容易被忽略的点:SRS 的SrsRtcConsumer在队列满时会丢包,丢包后客户端会发 NACK 请求重传。如果重传的包又丢了,会触发更多 NACK,形成恶性循环。我一般会在SrsRtcConsumer里加一个丢包率统计,如果丢包率超过 10%,就主动降低码率或者请求关键帧。
5.3 常见问题速查表
为了方便大家快速定位,我把播放链路里常见的问题和排查方法整理成了表格。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 黑屏,ICE 已连接 | 源无数据 | 检查SrsRtcSource::on_rtp日志 | 确认推流端正常 |
| 黑屏,ICE 未连接 | ICE candidate 不可达 | 检查rtc_server的candidate配置 | 改为公网 IP 或正确内网 IP |
| 延迟高 | jitter buffer 过大 | 抓包看 RTP 到达间隔 | 调小客户端 jitter buffer |
| 延迟波动 | NACK 频繁 | 检查nack_max_delay | 调整为 1.5 倍 RTT |
| 花屏 | 丢包严重 | 检查丢包率统计 | 开启 PLI,请求关键帧 |
| 声音断续 | 音频包丢失 | 检查音频 RTP 序列号 | 调大音频队列容量 |
| DTLS 握手失败 | 证书问题 | 检查证书有效期 | 更换受信任证书 |
| 播放几秒后断开 | 超时 | 检查rtp_timeout | 调大超时时间 |
这张表是我自己踩坑总结的,不一定覆盖所有情况,但大部分常见问题都能对上。特别是“花屏”那一项,很多时候不是编码问题,而是丢包导致的。SRS 的 NACK 机制能缓解,但如果丢包率太高,还是得从网络层面解决。
5.4 几个独家避坑技巧
第一个技巧:在SrsRtcConsumer里加一个“慢消费”日志。当队列长度超过容量的 80% 时,打印一条警告,包含当前队列长度、丢包数、NACK 数。这样你就能在问题恶化之前发现苗头。我当初就是靠这个日志,提前发现了一个客户端消费速度慢的问题。
第二个技巧:用tcpdump抓包时,加上-s 0参数抓完整包,然后用 Wireshark 的rtp过滤器分析。Wireshark 能直接显示 RTP 序列号、时间戳、丢包情况,比看日志直观得多。我一般会同时抓服务端和客户端的包,对比两边的序列号,就能定位丢包发生在哪一段。
第三个技巧:如果要做压力测试,不要只用一路流。SRS 的 WebRTC 播放性能跟并发数关系很大。我建议用ffmpeg推多路流,然后用多个浏览器标签同时播放,观察 CPU 和内存占用。我实测下来,单核 CPU 大概能支撑 50 到 100 路 720p 的 WebRTC 播放,具体取决于码率和丢包率。
第四个技巧:修改源码后,一定要跑一遍 SRS 自带的单元测试。SRS 的trunk/src/utest/目录下有srs_rtc_conn_test.cpp和srs_rtc_source_test.cpp,能覆盖大部分核心逻辑。我当初改SrsRtcConsumer的队列逻辑时,就是靠单元测试发现了一个边界条件:当队列为空时调用pop会返回错误,但上层没处理这个错误,导致空指针。
6. 从源码延伸:还能怎么优化
读完源码之后,我对 SRS 的 WebRTC 播放链路有了更深的掌控感。除了排查问题,还能做一些定制优化。比如,SRS 默认的 NACK 是全局开启的,但有些场景下(比如纯音频)不需要 NACK,可以在SrsRtcConsumer里根据媒体类型动态开关。再比如,SRS 的 PLI 请求是直接转发给推流端的,如果推流端不支持动态关键帧,可以在 SRS 这边缓存一个最近的关键帧,收到 PLI 时直接重发,减少对推流端的依赖。
还有一个方向是 QoS 分级。SRS 目前对所有 consumer 一视同仁,但实际上不同用户的网络质量差异很大。可以在SrsRtcConsumer里加一个网络质量评估模块,根据丢包率和 RTT 动态调整队列容量和 NACK 策略。网络好的用户用低延迟策略,网络差的用户用高可靠策略。这个改动稍微大一点,但效果很明显。
最后分享一个小技巧:如果你在改 SRS 源码,建议用git diff生成补丁文件,而不是直接改源码。这样升级 SRS 版本时,可以先把补丁应用到新版本上,再解决冲突。我当初就是直接改源码,结果升级到 5.0 时花了整整一天才把改动合并回去。后来学乖了,所有改动都用补丁管理,升级时轻松很多。