
做实时音视频优化的人对RTT往返时延应该都不陌生。线上反馈画面卡顿我第一反应往往不是看码率而是先看往返时延是不是涨了。WebRTC里的RTT计算方法可以说是整个链路质量评估的地基丢包要不要重传、码率要不要下调、抖动缓冲窗口要拉多大底层都离不开一个可信的往返时延数值。我见过不少刚接触WebRTC的同学知道RTT重要但一遇到“LSR”“DLSR”“Compact NTP”这些名词就懵了。其实RTT计算并没有那么玄乎核心就藏在RTCP的SR/RR报文里搞清楚那几个字段是怎么来的、公式是怎么推出来的后面读源码、调问题时都会顺很多。这篇文章就把WebRTC里RTT计算的来龙去脉拆开讲一遍从协议标准讲到源码实现再补充一些我在实际工程里踩过的坑和验证方法适合正在做音视频传输优化、或者想彻底搞懂WebRTC拥塞控制底层的开发者。1. RTT到底解决什么问题从一次卡顿说起先别急着看公式我们得先想明白一件事RTT在WebRTC里面到底承担了什么角色。假设现在有一条音视频链路发送端不断推流接收端播放。网络抖动了一下某个视频包丢了。接收端发现丢包会通过RTCP NACK反馈给发送端请求重传。发送端收到NACK后要不要立刻重传这里就有一个等待时间窗口的问题。如果RTT是50ms丢包反馈一般在半个RTT左右就能到发送端重传在下一个半个RTT就能回来接收端稍微在抖动缓冲区里等一下就能等到重传包。但如果RTT是500ms重传等回来黄花菜都凉了这时候还不如直接用FEC前向纠错去冗余恢复或者降低码率让网络别再继续恶化。这是RTT在丢包恢复链路里的作用。再往上一层WebRTC的拥塞控制也是一直在盯着RTT和相关延迟指标。经典的GCC拥塞控制算法中有过延迟overuse检测、码率调节等多个模块RTT本身虽然不直接参与拥塞判断但带宽估计的增益计算、探测窗口的决策、以及“链路到底恢复了没有”的判断都需要RTT作为关键输入。再说得更直白一点RTT是所有端到端时间反馈里最基础、最容易拿到的一把“尺子”。相比单向延迟RTT不需要两端时钟同步测量起来更简单相比纯丢包率RTT能更早地反映链路拥塞的苗头因为队列排队会导致延迟先升高然后才开始丢包。所以RTT测量是否准确直接决定上层一堆策略的鲁棒性。理解了这个背景再看RTCP的SR/RR机制就顺理成章了。RTT不是WebRTC自己发明的测量手段它沿用的是RTP/RTCP协议栈里几十年前的成熟设计。先把标准搞懂剩下的实现细节不过是标准的具体落地。2. 先懂标准再谈实现RTCP SR/RR 与 LSR/DLSR 的RTT计算2.1 一次完整的SR/RR交互时间轴上发生了什么WebRTC里最标准的RTT测量靠的是RTCP的两个报文发送端周期性地发SR也就是Sender Report报告自己的发送统计接收端收流后周期性地往回发RR也就是Receiver Report报告收到数据和链路质量的信息。RTT的测量就藏在这组SR/RR的往返中。为了后续读代码方便我先把两端叫清楚A是SR的发送端也是最后算RTT的一方B是SR的接收端也是RR的发送方。整个过程按时间顺序拆解如下A在t0时刻发送SR报文里带了一个关键字段本地NTP时间戳。这个时间戳是A打在报文上的“出厂日期”。B在t1时刻收到SRB会把收到SR时的本地时间记下来这个时间就是后面算DLSR的起点。同时B会把SR里的NTP时间戳取出来存到某个地方等自己发RR时回填到LSR字段。过了一段时间B在t2时刻决定发送RR。在发送那一刻B计算“从t1到t2之间我等了多久”这个等待时间就是DLSR。然后B把之前存下来的A的NTP时间戳放进report block的LSR字段一起发回给A。A在t3时刻收到RR此时A读出自己本地当前时间。A从RR里取出LSR和DLSR套用公式算出这条链路的往返时间。这里面有一个关键点必须注意SR发出的NTP时间戳是64位的但RR里回显LSR时只取“中间32位”。这是RFC 3550的设计因为RR的report block空间有限不需要完整的64位时间戳中32位已经能提供足够的时间分辨率。我这里先给一个带箭头的通俗版本方便大家脑子里有图A发SR ——→ B收SR ——→ B等了一会儿 ——→ B发RR ——→ A收RRA最终算RTT时手里有三个东西自己当前时间、自己曾经发出SR时的时间被B回显成LSR、以及B在中间等待了多久DLSR。三者的关系用一句话概括就是总经过时间减去接收端自己拖延的时间剩下的就是两段链路上的纯传输时间。2.2 公式怎么来的为什么不需要两端时钟同步RTT的计算公式看起来很简洁RTT A收到RR时的当前时间 - LSR - DLSR很多第一次接触这个公式的人会嘀咕LSR是B回显的A的时间戳DLSR是B自己的等待时间这两者的时间基准都不一样怎么能直接相减这就是这套设计的巧妙之处。LSR并不是B的本地时间它本质上是“A自己的发送时间被B原样复制回来”。所以对A来说它手里拿到的两个时间值——当前时间和LSR——都是以A本地时钟为基准的属于“同源时间”相减天然合理。从另一个角度推导一下。A的SR发出后经过正向链路延迟Δ1到达BB收到了SR。B等了DLSR这么久才发出RRRR再经过反向链路延迟Δ2回到A。那么从A的视角看“当前时间减去LSR”其实就是整个交互的总时长总时长 Δ1 DLSR Δ2这个总时长里包含了链路上真实的两个单程传输时间也包含了B处理耽搁的时间。我们想要的是Δ1加Δ2但手里拿到的是包含了DLSR的总时长所以直接把DLSR减掉就得到了RTTRTT (Δ1 DLSR Δ2) - DLSR Δ1 Δ2整个过程完全在A本地时间线上完成计算不需要知道B的时钟是什么样。这里可以用一个生活化的类比帮助理解你寄了一个包裹包裹上盖了“寄出日期”对方收到后记下收货日期又拖了几天才给你回了一封签收信你收到签收信后用今天的日期减去寄出日期再减去对方在中间“拖了几天”剩下的就是包裹在路上的时间。整个计算不需要对方家的日历跟你家的日历保持同步你只需要一个可靠的“对方拖延天数”的数字。理解了这一点再看DLSR和LSR的具体字段就轻松了。只要B回填的LSR准确、DLSR准确A的测量结果就一定准。这也是后面工程坑的根源之一——如果对端实现不靠谱LSR或DLSR填错A算出来的RTT就注定是错的。2.3 SR/RR报文结构中的关键字段RTCP报文看起来很繁琐但为了算RTT核心只需要关注下面这几个字段。我用一个表格把关键信息整理出来方便后面抓包对照。字段所在位置长度单位/精度含义NTP TimestampSR头部64bit秒2^-32秒SR发送时刻的64位NTP时间戳LSRRR的report block32bit1/65536秒B最近收到的一个SR中的NTP时间戳中32位DLSRRR的report block32bit1/65536秒B从收到最近SR到发送当前RR之间的延迟SSRCreport block32bit无对应发送端同步源的标识用于匹配SR/RR这里面最容易出问题的是LSR和DLSR的“中32位”和“1/65536秒”这个单位。SR头部里的NTP时间戳是64位的高32位表示秒低32位表示秒的小数部分小数部分单位为2^-32秒。而report block里的LSR字段RF则把它定义为“NTP时间戳中32位”也就是把64位时间戳去掉最高16位秒和最低16位小数后的中间部分。换算之后LSR代表的时间粒度是1/65536秒反正比毫秒还要细一个数量级足够音频视频的往返测量用了。实际解析报文时要把网络字节序转成主机字节序然后把32位无符号值当成1/65536秒的计数来用。RTT以同样的单位减完之后除以65536得到秒。举个例子如果算出来的RTT计数值是6554那么RTT 6554 / 65536秒大约是100.1毫秒。3. 打开WebRTC源码看RTT估计器的实现路径3.1 接收端如何记录LSR和DLSR看完了标准我们再回到WebRTC的工程实现里。虽然标准定义了整套机制但落地到代码时还是有不少值得注意的细节。先说接收端。WebRTC作为一个被广泛使用的开源框架它的RTCP收发是高度程序化的。接收端在收到SR报文时会把SR里的NTP时间戳以“中间32位”的格式保存下来同时启动一个秒表记录从收到SR到发送下一个RR之间的延迟。这个延迟就是DLSR。这里有一个很容易被忽略的点DLSR不是简单地“收到SR后等5秒再发RR”。如果接收端有多个SSRC的数据流每个流的report block都要带各自对应的LSR和DLSR因为不同流的SR到达时刻可能不同。在WebRTC的实现里DLSR是动态计算的在发送RR之前才用“当前时间减去收到SR的时间”算出来而不是先算好存着。这样做的好处是即使RR因为流量控制被推迟了很久DLSR依然能反映真实的等待时间。如果接收端从来没有收到过某个SSRC的SR那对应的LSR就是0。此时RR的report block里LSR和DLSR都不能用于计算RTT发送端看到LSR为0就会直接跳过。这个逻辑在很多第三方实现里处理得不好经常出现LSR为0还硬算RTT的情况结果算出来一个负值或巨大值在带宽估计里造成莫名其妙的波动。3.2 发送端如何从RR中还原RTT发送端是RTT计算真正发生的地方。WebRTC的RTP/RTCP模块在收到RR后会对每个report block做解析。核心逻辑用伪代码示意如下for (auto block : receiver_report.report_blocks) { // LSR为0说明对端还没收到过我们的SR无法计算RTT if (block.lsr ! 0) { // 取当前本地紧凑NTP时间与LSR同基准 uint32_t now CompactNtp(GetCurrentNtpTime()); // 注意这里的减法可能会跨越紧凑NTP的回绕边界 int64_t rtt_ticks static_castint64_t(now) - block.lsr - block.dlsr; // 换算成秒并过滤掉明显不合理的值 if (rtt_ticks 0 rtt_ticks kMaxRttTicks) { double rtt_seconds rtt_ticks / 65536.0; rtcp_rtt_stats-OnRttUpdate(rtt_seconds); } } }这段代码有几个细节值得展开。CompactNtp是一个关键函数它的作用是把64位NTP时间戳压缩成中32位对应我们前面说的LSR粒度。发送端保存当前时间的紧凑格式再与LSR做差保证了基准一致。计算结果有一个负值和最大值的过滤。网络环境再糟糕RTT也不可能是负数如果出现负值往往意味着DLSR偏大或者数据包乱序导致的时间错位直接丢弃要比累计进统计里安全。最大值过滤的逻辑也很实用防止NTP回绕或异常大延迟破坏整个带宽估计曲线。过滤之后RTT值会送入RttStats对象。这个对象内部维护了最近一次测量值、累计测量次数、滑动窗口内的最低值时最差值等统计信息。拥塞控制模块和其他上层策略不直接读某个原始测量值而是通过RttStats拿一个相对稳定的估计避免单次噪声导致误判。3.3 RTT估计的平滑策略和多路反馈融合这里我想展开讲一下RTT平滑的问题。很多从零开始实现RTT测量的朋友容易犯一个错误把第一次测到的RTT当成“当前RTT”直接去调拥塞控制参数。实际网络中单次测量受很多因素干扰——路由器转发队列的瞬时抖动、接收端处理器的调度延迟、甚至WIFI网卡省电模式下的突发缓冲都会让某一次RTT明显偏离真实值。WebRTC内部并不会把某一次新鲜出炉的RTT直接当作最终结果。常见做法是维护一个平滑值比如指数加权移动平均或者干脆使用过去若干次测量里较小的一个分位数。这样做的逻辑很简单链路拥塞时RTT会普遍抬高单次低抖动不应影响整体链路恢复时RTT需要尽快降下来所以平滑窗口又不能太长。我自己在实际工程里调试带宽估计时习惯把原始RTT、平滑RTT和窗口内最低RTT三组数据打到日志里一起看。原始RTT反映瞬时抖动平滑RTT反映趋势最低RTT反映链路理想状态。三者差异如果持续过大说明链路队列排得很深拥塞已经发生了。另外WebRTC在真实系统中不只是依赖RTCP SR/RR这一路RTT。建连时的ICE连通性检测本身就能测到基础RTT那部分数据可以在媒体还没开始流动时提供一个初始值让重传定时器和带宽估计算法不用从空集开始猜。TWCC传输层反馈也在间接提供延迟信息但它更多用于单向延迟变化趋势的判断不能直接替代RTT测量。不同的反馈路径合在一起才构成了WebRTC对链路状态更完整的感知。4. 别把辅助手段当主路TWCC、ICE与时钟偏差问题4.1 TWCC能测RTT吗很多人看到Transport-wide Congestion ControlTWCC反馈里带着接收时间戳会忍不住想能不能直接用反馈包里的时间戳来算RTT。我们先看TWCC的工作原理发送端给每个RTP包分配一个全局递增的transport sequence number接收端记录每个包的到达时间并且周期性地把“某个包的到达时间”和“后续各包相对该包的到达时间差”打包回传。关键就在这里TWCC反馈里的时间基准是接收端本地时钟的增量而不是和发送端共享的绝对时间。发送端拿到反馈后可以精确还原出接收端看来各个包到达的相对间隔却不知道某一个包的绝对到达时刻。因此TWCC无法直接给出RTT的绝对值它擅长的是另一个维度检测单向延迟梯度的变化。如果某个包的到达间隔差异越来越大说明接收端的包正在经历越来越深的排队延迟。那能不能反推理论上如果发送端记录了自己发某个包的时刻又能在收到TWCC反馈时知道包序号和对应的到达增量结合反馈包本身的生成延迟是可以勉强估算出一个RTT的。但这里面引入了太多假设尤其是对端反馈生成延迟很难精确掌握测出来的RTT误差很大。所以WebRTC正式实现里RTCP SR/RR依然是RTT绝对值的主来源TWCC则负责高频的相对变化信号。两者配合一个是绝对尺子一个是高倍放大镜。4.2 NTP回绕和单端时钟偏差的隐患时钟问题永远是延迟测量的老大难。RTT计算巧妙规避了两端时钟不同步的问题因为它只在一端本地做差。但有一个坑容易被忽视就是紧凑NTP的回绕。前面提到report block里的LSR只占32位以1/65536秒为步进。2的32次方除以65536大约是65536秒也就是18.2小时左右。这意味着每过18个多小时紧凑NTP就会从最大值跳回0。如果某次SR/RR交互恰好跨越了这个边界直接的32位减法会产生一个极大的值滤掉它或者使用带符号整数处理回绕是必要的。实际通话很少持续18小时以上但会议系统挂机几小时不挂断的场景确实存在该做的防御逻辑还是要做。另外还有一个工程现象值得注意DLSR的测量依赖接收端的本地时钟在一个短时间窗口内是稳定的。这个条件在移动端通常问题不大但如果运行在虚拟化环境或者有省电逻辑的设备上系统的睡眠、调频、暂停调度都可能让DLSR产生偏差。我在排查一个模糊问题时曾经遇到过对端DLSR偶尔比真实延迟大几百毫秒的案例追了半天发现是接收端进程被系统挂起导致收到SR和真正发出RR之间的墙钟时间明明已经过去了1秒代码里却用一个单调时钟去计算DLSR最终RTT测算异常。5. 工程现场RTT计算容易踩的坑速查表这部分我整理一个速查表都是我在实际项目里见过或者自己踩过的坑方便大家排查问题时快速对照。坑点现象排查与规避DLSR漏填或填0算出来的RTT偏小码率控制偏激进抓包看RR的DLSR字段互通测试时用多个工具交叉验证LSR为0对应report block无法计算RTT代码里遇到LSR为0直接跳过不要硬算LSR/字节序错误RTT忽大忽小或出现奇怪数值确认网络字节序转主机字节序逻辑无误Compact NTP回绕某次RTT出现巨大的离谱值用带符号32位差值处理回绕并加最大阈值过滤RTCP反馈周期过长RTT滞后严重拥塞响应慢配合TWCC高频延迟变化信号或显式缩短RTCP间隔非对称链路RTT高但下行发送方向没有拥塞结合单向延迟梯度和丢包率共同判断单次RTT直接用于策略码率波动大体验不稳采用平滑策略区分原始RTT和趋势RTT这里挑两个最典型的展开讲讲。第一个是DLSR填0的问题。WebRTC官方实现的接收端会正确填写DLSR但我们经常会跟一些自研的、嵌入式的甚至硬件终端的对端互通。某些简化实现根本不理会DLSR或者因为没有正确保存“上次收到SR的时刻”而无法计算DLSR于是填0。这时候发送端算出来的RTT会比真实值少一个接收端的处理延迟看起来链路飞快但实际上消息在远端排队了。如果接收端处理延迟很大这会导致拥塞控制严重误判码率持续上调最终引发丢包。第二个是字节序问题。RTCP报文在网络传输时是大端序但很多开发者在自己写RTP/RTCP解析器时习惯直接用指针把字段读成本机整数忘了做网络序到主机序的转换。LSR这种字段一旦反了所有差值都会变成天文数字然后被最大值过滤器兜底丢弃导致RTT永远没有更新。这个问题不多见但一旦发生极难排查因为它不会崩溃只是统计始终不更新。6. 怎么验证你的RTT算得准实测步骤理论知识再多最终还是要回到验证。我在做传输质量调优的时候常用一套简单可靠的方法来确认RTT计算链路是否正常。6.1 用netem模拟固定延迟Linux上的netem是验证延迟测量最直接的工具。假设机器有eth0网卡我想模拟单向100ms的延迟执行tc qdisc add dev eth0 root netem delay 100ms注意netem的delay参数是单向延迟。如果发送端和接收端都经过这个环节RTT应该大约是200ms。跑一个WebRTC通话或测试流看统计里的roundTripTime是否落在200ms附近。这里有个小坑netem默认只模拟出向流量也就是从本机发出的方向。如果只想模拟一个方向可以只在对应主机上执行。如果想模拟两个方向两边都加一次delay就能实现。测完别忘清理规则tc qdisc del dev eth0 root6.2 抓包手工验算比看统计更可靠的验证方式是抓包手工验算。用Wireshark抓RTCP包过滤条件可以是rtcp也可以直接过滤rtcp.type 200SR和rtcp.type 201RR。找到一对对应的SR和RR后从RR的report block里读出LSR和DLSR再找到这条RR在接收端的到达时刻用Wireshark里显示的相对时间换算成紧凑NTP刻度套公式计算。对照WebRTC统计里的RTT值两者应该基本一致。有时候Wireshark版本不同对RTCP字段的解析名称会有差异但核心的LSR、DLSR字段从RTCP协议诞生起就没变过只要眼力好都能找到。6.3 通过getStats接口观察WebRTC标准统计接口里也暴露了RTT。通过RTCOutboundRtpStreamStats可以拿到roundTripTime和roundTripTimeMeasurements。前者是当前RTT估计值单位秒后者是累计有效测量次数。在网页里调一下getStats就能把发送端的RTT读出来const stats await peerConnection.getStats(); stats.forEach(report { if (report.type outbound-rtp report.rtpStreamId) { console.log(RTT:, report.roundTripTime, 秒测量次数:, report.roundTripTimeMeasurements); } });这个接口在Chrome系浏览器里很实用。如果抓包算出来的值和getStats的值对不上优先怀疑代码里对LSR/DLSR的读取是否有误或者RTCP报文是否经历了代理改写。我在一次对接第三方硬件终端的经历中就是靠这组方法定位问题的抓包看DLSR异常getStats里RTT在低值区间波动最终发现在对端实现中把DLSR写死成了0。后来跟对方确认整改后RTT测量立即恢复正常。7. 对RTT测量和调优的一点心得做了多年实时音视频传输优化我最大的体会是RTT是一个看起来简单、用起来要小心的指标。它不像延迟梯度那样对排队敏感也不像丢包率那样直接反映拥塞但它是所有反馈链路里最基础的一环。拿RTT去判断链路好坏之前先确认它的测量路径是否纯净这可能是我反复强调最多的一点。实际调优时我习惯把RTT和丢包率、抖动、单向延迟梯度放在一张趋势图上看。RTT缓慢升高而丢包没起来通常说明链路开始排队RTT突然跳高又回落很可能只是网络路径切换RTT持续稳定但丢包率上升则更可能是硬件或无线空口问题。单看任何一个指标下结论都容易误判。如果非要给新人一条具体的建议我会说先学会抓RTCP包手动把SR/RR里的LSR和DLSR算明白。这一关过了WebRTC那些看似复杂的拥塞控制参数就都变得有迹可循了。RTT计算本身只有几行代码那么简单难的是你愿意为了验证这几行代码的准确性沉下心去抓一次包、算一笔账。这一点功夫花下去后面排查任何链路问题都会顺手很多。