3、网络探测:带宽估计方法、RTT测量、丢包率计算
网络探测,说白了就是给网络“把脉”。
你想想看,自适应码率的核心是“根据网络状况调整”。但如果你连网络状况都不知道,那调整个什么劲?所以,这一章我们得把底层的探测手段搞清楚。我个人习惯把网络探测分成三个维度:带宽、延迟、丢包。这三个指标,基本能勾勒出当前网络的“健康画像”。
3.1 带宽估计方法
带宽估计,是码率自适应的“眼睛”。眼睛不好使,后面全是瞎猜。
常用的方法有两种:基于探测的(Probe-based) 和 基于模型的(Model-based)。
- 基于探测:主动发数据包,看能跑多快。比如,客户端发一个“探测突发”,服务器回一个“探测响应”,通过计算吞吐量来估算带宽。优点是实时性强,缺点是会额外消耗带宽。
- 基于模型:通过历史下载速率、缓冲区状态等数据,用数学模型(比如卡尔曼滤波、EWMA)来预测带宽。优点是开销小,缺点是对突发网络变化反应慢。
我在项目中遇到过一个问题:用纯探测法,在弱网环境下,探测包本身会加剧网络拥塞,导致带宽估计值偏低。后来我们改成了“混合模式”——平时用模型法,当检测到网络剧烈变化时,才触发一次轻量级探测。
核心公式:吞吐量 = 数据量 / 下载时间
这是最朴素的带宽估计方法。但要注意,这里的“下载时间”要排除掉初始缓冲和尾部拖尾的时间,否则结果会偏小。
下面是一个简单的带宽估计代码示例,我用的是滑动窗口平均法:
class BandwidthEstimator:
def __init__(self, window_size=5):
self.window = [] # 存储最近N次的吞吐量
self.window_size = window_size
def update(self, bytes_downloaded, duration_ms):
if duration_ms <= 0:
return
throughput = (bytes_downloaded * 8) / (duration_ms / 1000) # bps
self.window.append(throughput)
if len(self.window) > self.window_size:
self.window.pop(0)
def estimate(self):
if not self.window:
return 0
# 去掉最大值和最小值,防止异常值干扰
sorted_vals = sorted(self.window)
trimmed = sorted_vals[1:-1] if len(sorted_vals) > 2 else sorted_vals
return sum(trimmed) / len(trimmed)
嗯,这里要注意:滑动窗口的大小很关键。窗口太大,反应迟钝;窗口太小,容易受抖动影响。我个人习惯在直播场景用3-5的窗口,点播场景用8-10。
3.2 RTT测量
RTT(Round-Trip Time),就是数据包从客户端到服务器再回来的总时间。它直接影响码率切换的“响应速度”。
测量RTT,最经典的方法是时间戳法。客户端在发送请求时带上一个时间戳 t1,服务器在响应时带上自己的时间戳 t2,客户端收到响应时记录 t3。那么:
- RTT = t3 - t1(简单粗暴,但包含服务器处理时间)
- 单向延迟 = (t3 - t1) - (t2 - t1) / 2(需要服务器和客户端时钟同步,实际中很难)
我曾经踩过一个坑:在移动网络下,RTT的抖动非常大。有时候一次测量是50ms,下一次就变成500ms。如果直接用单次RTT做决策,码率会像过山车一样忽高忽低。后来我引入了RTT平滑器,用指数加权移动平均(EWMA)来处理:
class RTTSmoother:
def __init__(self, alpha=0.3):
self.smoothed_rtt = None
self.alpha = alpha # 平滑因子,越大越敏感
def update(self, raw_rtt_ms):
if self.smoothed_rtt is None:
self.smoothed_rtt = raw_rtt_ms
else:
self.smoothed_rtt = (1 - self.alpha) * self.smoothed_rtt + self.alpha * raw_rtt_ms
return self.smoothed_rtt
避坑指南:我曾经在4G网络下发现RTT突然飙升到800ms,但实际带宽并没有下降。后来排查发现,是基站切换导致的短暂延迟。如果这时候因为RTT高就降码率,反而会引发卡顿。所以,RTT最好和带宽、丢包率联合判断,不要单独作为降码率的依据。
3.3 丢包率计算
丢包率,是网络拥塞的“晴雨表”。
计算丢包率,通常有两种方式:
- 序列号法:每个数据包都有一个递增的序列号。接收端通过检查序列号是否连续,来判断是否丢包。比如,收到了包1、包2、包4,那包3就丢了。丢包率 = 丢失包数 / 期望收到的总包数。
- NACK/重传法:接收端发现丢包后,会发送NACK请求重传。通过统计NACK的数量,可以间接推算丢包率。这种方法在实时通信中更常用。
嗯,这里有个细节:丢包率不能只看绝对值,还要看分布。比如,连续丢3个包和间隔丢3个包,对视频质量的影响完全不同。连续丢包会导致画面“花屏”,而间隔丢包可能只是轻微的马赛克。
注意:在计算丢包率时,一定要排除“乱序”导致的误判。网络中的包可能因为路由不同而乱序到达,比如包3比包2先到。如果简单按序列号判断,就会误以为包2丢了。我建议在计算丢包率时,设置一个“乱序容忍窗口”(比如50ms),窗口内的乱序包不算丢包。
下面是一个丢包率计算的简化实现:
class PacketLossCalculator:
def __init__(self, window_ms=1000):
self.received_seq = set()
self.window_start = None
self.window_ms = window_ms
def on_packet(self, seq, timestamp_ms):
if self.window_start is None:
self.window_start = timestamp_ms
self.received_seq.add(seq)
# 每1秒计算一次丢包率
if timestamp_ms - self.window_start >= self.window_ms:
expected_seq = max(self.received_seq) - min(self.received_seq) + 1
lost = expected_seq - len(self.received_seq)
loss_rate = lost / expected_seq if expected_seq > 0 else 0
# 重置窗口
self.received_seq.clear()
self.window_start = timestamp_ms
return loss_rate
return None
3.4 三者的协同关系
带宽、RTT、丢包率,这三个指标不是孤立的。它们之间有着内在的关联:
| 场景 | 带宽 | RTT | 丢包率 | 可能原因 |
|---|---|---|---|---|
| 正常 | 稳定 | 低且平稳 | 接近0 | 网络良好 |
| 轻度拥塞 | 下降 | 升高 | 轻微上升 | 带宽不足,开始排队 |
| 严重拥塞 | 骤降 | 飙升 | 明显上升 | 网络过载,大量丢包 |
| 无线抖动 | 波动 | 间歇性升高 | 偶发丢包 | 信号不稳定,基站切换 |
我个人习惯的做法是:以带宽估计为主,RTT和丢包率作为辅助校验。比如,带宽估计显示网络很好,但RTT突然飙升且丢包率上升,那我会认为带宽估计可能“过于乐观”,需要降码率保流畅。
下面这张图,是我自己总结的网络探测决策流程:
好了,网络探测这块就讲到这里。记住一句话:没有完美的探测方法,只有合适的组合策略。在实际项目中,多试试不同的参数组合,找到最适合你场景的那一套。