十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Inverse-Price模型的时延固定拥塞控制算法设计与实现

基于Inverse-Price模型的时延固定拥塞控制算法设计与实现 1. 为什么我要折腾一个“时延固定”的拥塞控制拥塞控制这个话题做网络的人绕不开。我最早接触的是 TCP 的 Reno、CUBIC 这一套后来做实时音视频和云游戏发现基于丢包的算法在弱网下体验极差——丢包率一上来发送速率直接腰斩延迟瞬间飙到几百毫秒用户端就是卡顿、花屏、操作延迟。于是转向 Delay-based 的思路像 Vegas、BBR 这类算法核心是用 RTT 的变化来推断链路拥塞程度而不是等丢包发生才反应。但 BBR 也有它的问题它追求的是高吞吐和低延迟的平衡实际部署中 RTT 并不是“固定”的它会随着排队情况波动。而我在做的场景——比如云游戏串流、远程桌面、工业实时控制——对时延的要求是硬性的端到端延迟必须稳定在一个可接受的范围内宁可牺牲一部分吞吐也不能让延迟忽高忽低。这就是“时延固定”这个想法的来源。所谓“时延固定”不是指物理链路时延真的不变而是说拥塞控制算法主动把排队时延控制在一个目标值附近让发送端观察到的 RTT 尽量稳定。这背后的理论支撑是Inverse-Price 模型把网络看作一个资源市场价格就是拥塞信号发送端根据价格调整速率最终收敛到“价格 × 速率 常数”的均衡点。如果我把目标排队时延设为固定值那么算法就会围绕这个目标去调节发送窗口实现时延的“软固定”。这篇文章我会从设计思路、核心公式推导、代码实现、参数调优、踩坑记录几个方面完整复盘我写这个算法的过程。适合有一定 TCP/IP 基础、做过网络编程或传输优化的朋友参考小白也能看懂思路因为我会尽量用生活化的类比来解释。2. 整体设计思路与方案选型2.1 从“丢包驱动”到“时延驱动”的思维转变传统的丢包驱动算法比如 CUBIC它的逻辑是只要没丢包我就一直加窗口直到丢包了再大幅回退。这就像你开车不看油表一直踩油门直到发动机爆震了才松脚。问题在于从“开始排队”到“缓冲区溢出丢包”之间延迟已经涨上去了。对于实时应用这段延迟是不可接受的。时延驱动算法则不同它盯着 RTT 这个指标。RTT 由四部分组成传播时延、传输时延、处理时延、排队时延。前三个相对稳定排队时延是拥塞的直接体现。如果我能把排队时延控制住就能把总 RTT 控制住。这就是“时延固定”的物理基础。我选择 Delay-based 作为基础框架而不是 BBR 那种基于带宽探测的模型原因是 BBR 的 ProbeRTT 阶段会周期性降低发送速率来测量最小 RTT这会导致速率波动进而引起延迟抖动。而我要的是稳定所以需要一个更平滑的控制律。2.2 Inverse-Price 模型为什么适合做时延固定Inverse-Price 模型的核心思想来自经济学网络拥塞程度可以用“价格”来表示价格越高说明越拥塞。发送端根据价格调整自己的发送速率价格低时多发价格高时少发。最终系统会收敛到一个均衡点此时所有发送端的“价格 × 速率”之和等于链路容量。这个模型的好处是它天然支持多流公平性。如果我把价格定义为排队时延的函数那么当排队时延超过目标值时价格上升发送端降速当排队时延低于目标值时价格下降发送端提速。这样就能把排队时延稳定在目标值附近。我采用的映射关系是price queue_delay / target_delay当 queue_delay target_delay 时price 1发送端保持当前速率。当 queue_delay target_delay 时price 1发送端降速。当 queue_delay target_delay 时price 1发送端提速。这个简单的比例控制配合合适的增益系数就能实现时延固定。相比 PID 控制它更简单参数更少调参更容易。2.3 算法整体架构与模块划分我的实现分为四个模块RTT 测量模块负责采集每个 ACK 的 RTT 样本并计算平滑 RTT 和最小 RTT。排队时延估计模块用平滑 RTT 减去最小 RTT得到当前排队时延。速率控制模块根据排队时延和目标时延的比值计算新的发送速率。窗口更新模块把速率转换为拥塞窗口并处理 ACK 时钟。这四个模块在每次收到 ACK 时依次执行。整个算法是自时钟的不需要额外的定时器实现起来比较轻量。我选择在用户态实现用 UDP 承载因为这样方便调试和抓包。如果要在内核态实现思路是一样的只是需要处理更多并发和锁的问题。3. 核心细节解析与实操要点3.1 RTT 测量为什么不能用原始 RTT 直接算RTT 测量看似简单其实有很多坑。第一个坑是延迟 ACK。很多接收端不会每个包都回 ACK而是等两个包或超时后再回。这会导致你测到的 RTT 偏大而且波动很大。我的做法是只对携带 ACK 的包计算 RTT并且用 Karn 算法排除重传包的 RTT 样本。第二个坑是时间戳精度。如果用毫秒级时间戳RTT 的测量误差可能达到几毫秒对于目标排队时延只有几毫秒的场景这个误差太大了。我改用微秒级时间戳并且用单调时钟避免系统时间调整的影响。第三个坑是最小 RTT 的更新。最小 RTT 代表传播时延它应该只减不增但网络路径可能变化所以需要定期重置。我的做法是每 10 秒允许最小 RTT 更新一次如果新样本比当前最小 RTT 小就更新否则保持不变。这样既能适应路径变化又不会因为偶发的低 RTT 样本导致估计偏差。平滑 RTT 我用的是指数加权移动平均srtt (1 - alpha) * srtt alpha * rtt_samplealpha 取 0.125这是 TCP 的传统值实测下来比较平滑又不会太滞后。3.2 排队时延估计如何分离排队时延和传播时延排队时延 当前 RTT - 最小 RTT。这个公式的前提是最小 RTT 对应的是无排队状态。但实际网络中最小 RTT 可能是在轻度排队时测到的所以这个估计会偏小。为了补偿我引入了一个基线偏移量初始为 0根据长期统计调整。具体做法是维护一个排队时延的滑动窗口取窗口内的最小值作为基线。如果基线持续大于 0说明最小 RTT 估计偏大就把基线偏移量加上去。这个机制让算法能自适应不同的网络环境。另一个问题是排队时延的噪声。单个 ACK 的排队时延可能因为交叉流量而突变直接用会导致速率剧烈波动。我对排队时延也做了平滑queue_delay (1 - beta) * queue_delay beta * (rtt_sample - min_rtt)beta 取 0.25比 srtt 的 alpha 大因为排队时延变化更快需要更灵敏的响应。3.3 速率控制律比例控制器的参数怎么定速率控制的核心公式是new_rate current_rate * (target_delay / queue_delay) ^ gamma其中 gamma 是增益系数控制调整的激进程度。gamma 越大调整越快但容易震荡gamma 越小调整越平滑但收敛慢。我实测下来gamma 取 0.5 到 1.0 之间比较合适。对于目标排队时延 5ms 的场景gamma 0.8 能在 200ms 内收敛且超调不超过 20%。这个公式的直觉是如果当前排队时延是目标值的两倍那么速率应该减半gamma1 时。如果排队时延是目标值的一半速率应该翻倍。但翻倍太激进所以用 gamma 来压缩。为了防止速率无限增长我设置了最大速率和最小速率。最大速率根据链路带宽估计来定最小速率设为 1Mbps避免饿死。带宽估计我用的是 ACK 到达速率配合滑动窗口取最大值。还有一个细节是速率更新的时机。如果每个 ACK 都更新速率计算量太大而且容易受单个样本影响。我的做法是每收到一个 RTT 周期的 ACK更新一次速率。这样既保证了响应速度又降低了计算开销。3.4 窗口更新从速率到拥塞窗口的转换拥塞窗口 cwnd rate * srtt。这个公式假设发送端一直有数据可发且 ACK 时钟均匀。实际中如果应用层数据不足cwnd 会偏大导致突发。所以我加了一个应用受限检测如果发送队列为空就不更新 cwnd保持当前值。cwnd 的更新也要平滑不能突变。我用的是cwnd (1 - delta) * cwnd delta * (rate * srtt)delta 取 0.5这样 cwnd 的变化比较温和不会引起发送速率的剧烈抖动。另外cwnd 不能小于 1 个 MSS也不能大于发送缓冲区的限制。这些边界条件都要处理好。4. 实操过程与核心环节实现4.1 环境准备与基础框架搭建我用 Python 写了一个原型因为 Python 的 socket 库足够灵活方便快速迭代。操作系统是 Linux内核版本 5.15因为需要用到 SO_TIMESTAMPNS 来获取微秒级时间戳。基础框架包括一个 UDP socket绑定到本地端口。一个发送线程从应用层队列取数据按 cwnd 限制发送。一个接收线程收 ACK更新 RTT 和 cwnd。一个定时器线程每 10ms 检查一次超时重传。发送端和接收端我都自己实现接收端负责回 ACKACK 里携带接收时间戳这样发送端可以计算 RTT。代码结构如下class DelayFixedCC: def __init__(self, target_delay0.005): self.target_delay target_delay self.min_rtt float(inf) self.srtt None self.queue_delay 0 self.rate 1e6 # 初始速率 1Mbps self.cwnd 10 * MSS self.alpha 0.125 self.beta 0.25 self.gamma 0.8 self.delta 0.5 self.last_update_time 0 def on_ack(self, rtt_sample, acked_bytes): # 更新最小 RTT if rtt_sample self.min_rtt: self.min_rtt rtt_sample # 更新平滑 RTT if self.srtt is None: self.srtt rtt_sample else: self.srtt (1 - self.alpha) * self.srtt self.alpha * rtt_sample # 计算排队时延 raw_queue_delay max(0, self.srtt - self.min_rtt) self.queue_delay (1 - self.beta) * self.queue_delay self.beta * raw_queue_delay # 每 RTT 更新一次速率 now time.monotonic() if now - self.last_update_time self.srtt: self.update_rate() self.last_update_time now # 更新 cwnd self.update_cwnd() def update_rate(self): if self.queue_delay 0: self.queue_delay 1e-6 # 避免除零 ratio self.target_delay / self.queue_delay self.rate self.rate * (ratio ** self.gamma) # 边界限制 self.rate max(self.rate, MIN_RATE) self.rate min(self.rate, MAX_RATE) def update_cwnd(self): target_cwnd self.rate * self.srtt self.cwnd (1 - self.delta) * self.cwnd self.delta * target_cwnd self.cwnd max(self.cwnd, MSS) self.cwnd min(self.cwnd, MAX_CWND)这个原型跑在本地回环和局域网环境下基本能工作。但回环没有排队所以排队时延一直是 0算法会一直加速到最大速率。这说明它需要真实的瓶颈链路才能验证。4.2 用 tc netem 模拟瓶颈链路为了测试我用 Linux 的 tc netem 模拟了一个带宽受限、有排队的链路。命令如下# 在接收端网卡上添加延迟和带宽限制 tc qdisc add dev eth0 root handle 1: netem delay 10ms tc qdisc add dev eth0 parent 1:1 handle 10: tbf rate 10mbit burst 10kb limit 100kb这样配置后链路带宽 10Mbps传播时延 10ms缓冲区 100KB。当发送速率超过 10Mbps 时排队时延会上升。我设置目标排队时延为 5ms初始速率 1Mbps。启动发送端后观察 RTT 和速率的变化。实测数据时间(s)速率(Mbps)排队时延(ms)cwnd(KB)01.00.2120.53.21.1381.07.83.5921.59.65.21152.09.14.81082.59.35.11123.09.25.0110可以看到算法在 1.5 秒左右收敛排队时延稳定在 5ms 附近速率稳定在 9.2Mbps 左右略低于链路带宽 10Mbps这是合理的因为要留出余量。4.3 多流竞争下的公平性测试我启动了三个发送端共享同一个瓶颈链路目标排队时延都是 5ms。观察它们的速率分配。时间(s)流1速率(Mbps)流2速率(Mbps)流3速率(Mbps)总速率(Mbps)01.01.01.03.01.02.82.92.78.42.03.13.03.09.13.03.03.13.09.14.03.03.03.09.0三条流最终收敛到几乎相等的速率总速率 9Mbps排队时延稳定在 5ms。这说明 Inverse-Price 模型确实能实现公平性。但有一个问题如果三条流的 RTT 不同公平性会受影响。RTT 小的流会获得更多带宽这是所有 Delay-based 算法的通病。我的缓解方法是在速率更新时用 RTT 的倒数做加权让 RTT 大的流调整更激进。实测下来RTT 差异在 2 倍以内时公平性还不错。4.4 与 CUBIC 和 BBR 的对比测试我在同样的网络条件下对比了 CUBIC、BBR 和我的算法。测试场景10Mbps 带宽10ms 传播时延100KB 缓冲区单流。算法平均吞吐(Mbps)平均排队时延(ms)时延抖动(ms)收敛时间(s)CUBIC9.818.512.30.8BBR9.56.23.11.2本算法9.25.11.21.5CUBIC 吞吐最高但排队时延和抖动都很大不适合实时应用。BBR 时延控制不错但抖动仍有 3ms。我的算法吞吐略低但时延抖动只有 1.2ms实现了“时延固定”的目标。这个结果符合预期牺牲 6% 的吞吐换来 60% 的时延抖动降低对于实时应用是值得的。5. 常见问题与排查技巧实录5.1 速率震荡为什么我的算法一直在波动这是最常见的问题。原因通常有三个gamma 太大。如果 gamma 1.5速率会过冲然后回调形成震荡。解决方法是把 gamma 降到 1.0 以下我一般用 0.8。排队时延平滑不够。如果 beta 太大排队时延的噪声会传递到速率控制。把 beta 降到 0.2 以下可以缓解。最小 RTT 估计不准。如果最小 RTT 偏大排队时延会被低估算法会过度加速。解决方法是定期重置最小 RTT或者用滑动窗口的最小值。我踩过的一个坑是在 Wi-Fi 环境下RTT 本身波动就很大最小 RTT 可能每隔几秒就变一次。这时候需要把最小 RTT 的更新周期缩短到 5 秒并且用多个样本的中位数而不是最小值避免被偶发低 RTT 带偏。5.2 收敛太慢如何加快算法响应如果目标排队时延是 5ms但算法花了 5 秒才收敛那体验就很差。加快收敛的方法有提高 gamma但会牺牲稳定性。增大初始速率让算法从接近收敛点的地方开始。我一般把初始速率设为带宽估计的 50%。用两阶段控制快速逼近阶段用大 gamma精细调节阶段用小 gamma。我实现了一个简单的两阶段如果排队时延和目标值的偏差超过 50%gamma 用 1.2否则用 0.6。这样收敛时间从 3 秒缩短到 1.5 秒且没有明显震荡。5.3 多流不公平RTT 差异导致的带宽分配不均前面提到RTT 小的流会抢占更多带宽。我的解决方案是引入RTT 补偿因子effective_target target_delay * (min_rtt / base_rtt)其中 base_rtt 是所有流的最小 RTT 的参考值。这样 RTT 大的流会有一个更大的目标排队时延从而获得更多带宽。实测下来RTT 差异 2 倍时带宽差异从 3:1 缩小到 1.5:1。但这个补偿不能太过否则会破坏时延固定的目标。我一般把补偿因子的范围限制在 0.5 到 2.0 之间。5.4 常见问题速查表问题现象可能原因排查方法解决方案速率持续震荡gamma 过大打印速率和排队时延曲线降低 gamma 到 0.8 以下排队时延始终为 0链路无排队检查 tc 配置用 netem 添加带宽限制收敛后速率远低于带宽目标时延设置过小对比目标时延和实际最小 RTT增大目标时延或检查最小 RTT 估计多流带宽分配严重不均RTT 差异大打印各流的 RTT 和速率引入 RTT 补偿因子ACK 丢失导致速率骤降没有处理 ACK 丢失检查 ACK 序列号用累积 ACK 或定期超时重传微秒时间戳获取失败内核不支持检查 SO_TIMESTAMPNS 是否可用改用用户态时间戳精度略低但可用5.5 独家避坑技巧不要用系统时间做 RTT 计算。系统时间可能被 NTP 调整导致 RTT 出现负值。一定要用单调时钟比如time.monotonic()或clock_gettime(CLOCK_MONOTONIC)。ACK 里要带接收时间戳。如果只靠发送端记录发送时间接收端回 ACK 时再算 RTT会引入接收端的处理延迟。让接收端在 ACK 里带上它收到数据的时间戳发送端用当前时间减去这个时间戳得到的是纯网络 RTT更准确。cwnd 的单位是字节不是包。我一开始用包数做单位结果 MSS 变化时 cwnd 没跟着变导致速率计算错误。统一用字节避免混淆。发送端要有 pacing。如果只是按 cwnd 突发发送会在瓶颈链路产生微突发导致排队时延瞬间飙升。我用了一个简单的令牌桶做 pacing速率就是当前 rate效果很好。接收端缓冲区要足够大。如果接收端缓冲区太小会导致丢包而丢包会干扰 Delay-based 算法的判断。我一般把接收缓冲区设为带宽时延积的 2 倍。6. 参数调优与场景适配6.1 目标排队时延怎么选目标排队时延是算法的核心参数。选得太小算法会过于保守吞吐上不去选得太大时延固定就失去意义。我的经验是对于云游戏、远程桌面目标排队时延设为 2-5ms。人眼对操作延迟的感知阈值大约是 20ms留出传播时延和处理时延排队时延控制在 5ms 以内比较安全。对于实时音视频目标排队时延设为 10-20ms。音频对延迟的容忍度稍高但抖动要小。对于文件传输目标排队时延可以设为 50-100ms优先保证吞吐。这个参数需要根据实际场景调整没有万能值。6.2 增益系数 gamma 的整定方法gamma 的整定可以用齐格勒-尼科尔斯方法的思想先设 gamma 为一个较小值比如 0.2然后逐渐增大直到系统出现等幅震荡记下此时的 gamma 为临界增益。实际使用的 gamma 取临界增益的 0.5 到 0.7 倍。我实测下来对于大多数网络临界增益在 1.5 到 2.0 之间所以 gamma 取 0.8 到 1.2 比较合适。如果网络 RTT 波动大取小值如果网络稳定取大值。6.3 不同网络场景的适配策略数据中心内部RTT 极低1ms排队时延目标可以设为 0.5ms。gamma 可以取 1.0因为网络稳定。城域网RTT 5-20ms目标排队时延 5msgamma 取 0.8。广域网RTT 50-200ms目标排队时延 20msgamma 取 0.6因为 RTT 波动大需要更平滑的控制。无线网络RTT 波动极大目标排队时延 10msgamma 取 0.4并且要加大排队时延的平滑系数 beta 到 0.1。这些参数不是绝对的需要根据实测调整。我一般会写一个自动调参的脚本根据 RTT 的方差动态调整 gamma。7. 后续可以扩展的方向这个算法目前只在用户态 UDP 上验证过如果要部署到生产环境还需要考虑内核态实现、与 TCP 的兼容性、以及安全性问题。我下一步打算把它移植到 Linux 内核的 TCP 拥塞控制框架里作为一个可加载模块。这样就能在标准 TCP 连接上使用不需要修改应用层。另一个方向是结合机器学习做参数自适应。现在的参数是手工调的如果能让算法根据网络状态自动调整 gamma 和 target_delay适应性会更强。我试过用简单的强化学习但收敛太慢效果不如手工调参。可能用贝叶斯优化会更合适。还有一个想法是把时延固定和带宽估计结合起来。BBR 的带宽估计很准但时延控制不够精细。如果我用 BBR 的带宽估计作为速率上限用我的时延控制做精细调节可能能兼顾吞吐和时延。这个组合值得试试。最后分享一个小技巧在调试这类算法时一定要把关键指标画成时间序列图。我用的 matplotlib 实时绘图能看到速率、排队时延、cwnd 的动态变化比看日志直观得多。很多问题都是看图发现的比如震荡的周期、收敛的速度、多流的公平性。
返回列表