☰
TCP-BBR拥塞控制公平性优化:延迟与丢包双反馈机制的工程实践
2026/10/21 1:46:33 网站建设 项目流程

1. 为什么要动BBR的公平性

做网络底层优化的同行应该都有感触:BBR这套拥塞控制算法,单流吞吐确实猛,但一旦多条流挤在同一个瓶颈链路上,问题就来了。我这段时间在某个带宽受限的实验环境里反复打磨了一套TCP-BBR拥塞控制公平性优化方案,这里把思路、代码和踩坑记录完整整理出来。

先交代背景。BBR的核心逻辑是建模网络“管道”:通过测量瓶颈带宽(BtlBw)和最小往返时延(RTprop),直接算出当前时刻该发多少数据。它不把丢包当作主要拥塞信号,而是靠“探测”去逼近管道的真实容量。这在深队列、长肥网络环境下非常吃香,单流吞吐可以比CUBIC高不少。

但问题恰恰出在“不依赖丢包”这个特性上。传统丢包算法看到丢包就乖乖降窗,BBR看到丢包却不一定降速,甚至因为模型里没把丢包当回事,继续维持较高的发送速率。于是当BBR流和CUBIC流混跑时,带宽分配会非常不均衡。最直观的现象:同样是经过100Mbps瓶颈链路,BBR流可能抢占到80Mbps以上,CUBIC流只能分到可怜的10Mbps左右,延迟还飙高。这不是理论推演,是我在模拟环境里实际复现过的现象,最严重的时候CUBIC流几乎被“饿死”。

那为什么还要去优化它,而不是直接换算法?因为BBR在延迟控制、抗丢包抖动、链路利用率这些维度上确实有不可替代的优势,尤其在跨地域长链路上表现突出。只要你不想放弃BBR的高吞吐,又希望多流共存时大家能相对公平地分带宽,就必须自己对它做手术。下面的内容就是一套可以落地的优化路径,包含完整代码和实验验证方法,适合网络协议栈开发者、边缘节点运维以及做拥塞控制研究的人参考。

2. 公平性问题到底出在算法的哪些环节

2.1 先过一遍BBR的状态机与关键参数

想把公平性改好,得先搞清楚BBR内部的动作节奏。BBR主要有四个阶段:

  • Startup:类似慢启动,但退出条件不是丢包,而是带宽增长连续3个RTT低于25%,或者已经填满管道。
  • Drain:把Startup阶段堆积在队列里的数据排空,避免过度缓冲。
  • ProbeBW:稳态阶段,以8个RTT为一个周期,交替使用不同pacing_gain(典型序列是1.25、0.75、1.0、1.0、1.0、1.0、1.0、1.0),往上探一下带宽、往下排一下队列,其余时间维持1.0的增益。
  • ProbeRTT:如果距离上次测量最小RTT超过约10秒,就把发送窗口压缩到极小值(典型是4个包),重新测一轮RTprop。

在整个算法里,有两个增益参数决定了流量行为:pacing_gain和cwnd_gain。前者控制发包速率,后者控制拥塞窗口上限。这两个数值在标准实现里相对固定,正是这种“固定增益”给了我做优化的切入点。

2.2 从根上拆解:BBR为什么会让公平性崩掉

我的总结是三个层面的问题。

第一个层面是BBR对丢包不敏感。它把丢包解释成“排队溢出”或“链路噪声”,模型更新时只采样带宽和RTT,丢包率本身不会直接降低pacing_gain。这样在浅队列瓶颈上,BBR会把队列持续填满,导致竞争流不断丢包降速,而BBR自己因为有队列掩护,看起来带宽还在增长。

第二个层面是ProbeBW的探测窗口固定且偏激进。以1.25倍增益去探测带宽,如果链路已经拥塞,这个1.25倍的增益并不会因为竞争流的存在而自动收敛。虽然0.75阶段会排空队列,但只要下一个探测周期又来,它还是会重复抢占。说白了,原版BBR在稳态下的行为更像“周期性霸道”,而不是“按需分配”。

第三个层面是ProbeRTT带来的状态不对称。当一条BBR流进入ProbeRTT、把自己压到4个包时,它短暂地放弃了带宽,但其他流并不会因为它的“谦让”而获得模型更新,反而可能趁机填满队列。多流同步触发ProbeRTT时,整条链路还会出现周期性抖动,带宽利用率忽高忽低。

2.3 用一组最小复现实验看清问题

我在实验室里做的是非常简易的三流混跑拓扑:一台接入交换机下面挂着三台客户机,都通过同一个瓶颈链路访问同一台服务器,瓶颈带宽用tc限制到100Mbps,双向延迟50ms,队列设置为1000个包。

三条流分别配置为原始BBR、原始BBR、CUBIC,同时跑60秒。最终的数据非常难看:

  • BBR流1:约48Mbps
  • BBR流2:约47Mbps
  • CUBIC流:约5Mbps
  • 平均RTT:稳定在150ms附近,队头延迟居高不下

用Jain公平性指数算一下,结果大概在0.73左右。换句话说,CUBIC基本拿不到带宽,链路被两条BBR流联手吃掉了。这个实验虽然简单,但足以说明改BBR的必要性。

3. 优化思路:四条路线和最终取舍

针对上面的问题根源,我列了四条候选方案,分别都要动不同程度的内核代码。这里把每条路线的利弊说清楚,方便你做取舍。

3.1 给BBR加丢包反馈

思路很简单:统计一个时间窗口内的丢包率,超过阈值就降低pacing_gain或cwnd_gain,用“会响应丢包”来对齐CUBIC的退避行为。这个做法的好处是改动直接,和CUBIC的公平性最容易拉回来;坏处是BBR本身的设计哲学就是“尽量不依赖丢包信号”,如果阈值设得太激进,单流吞吐会受到不小的影响,尤其在高丢包率的无线链路上会退化得很明显。

3.2 基于排队延迟的动态增益调整

把当前RTT和最小RTT的比值作为拥塞程度的度量。当比值持续超过某个基准(比如1.5倍),就认为排队已经严重,动态把pacing_gain往下压。这个思路更贴合BBR的“管道模型”——延迟膨胀本质上说明队列正在堆积。好处是响应速度快,在丢包还没发生时就已经开始收敛;坏处是RTT测量本身有噪声,最小RTT的采样如果不准,容易误伤正常的高增益状态。

3.3 扰动探测窗口,打散多流的同步节拍

四条流如果都在同一时刻进入1.25倍探测期,链路就会瞬间打满,谁都不好过。给每一条流的ProbeBW周期和ProbeRTT触发时间加上一个随机的相位偏移,让各条流的探测窗口错开。这个做法实现成本低,对单流吞吐基本没有影响,但对多流公平性的提升有限——毕竟它只解决了“同步拥堵”的问题,没解决“持续抢占”的问题。

3.4 增益平滑与自适应收敛

在标准BBR固定增益的基础上,增加一个“依据当前竞争程度连续调整增益”的控制器。竞争程度可以由丢包率、RTT膨胀比、发送队列深度共同刻画。当当前带宽份额明显高于公平份额时,增益自动向下收敛;当前带宽份额不足时,增益适度放开。这个方案最接近后续改进版BBR的思路,效果也是最好的——既保持了大带宽场景的利用率,又能在竞争场景下快速回归公平。

我最终选择的是把第二条和第四条合并,再加一点第三条的相位抖动。也就是:以延迟膨胀比为主反馈信号,以丢包率为辅助保护,叠加探测相位扰动。这样既不会因为单一路径误判导致吞吐崩掉,又能显著改善多流混跑时的公平性。

4. 核心代码实现与接入方式

4.1 公平性增强模块的主体结构

我按Linux内核TCP拥塞控制模块的接口风格来写,整体上是一个可以注册到tcp_congestion_ops的独立模块。核心数据结构如下:

/* 公平性增强模块 -- 基于延迟与丢包反馈的动态增益控制 */ #define BBR_FAIR_SCALE 1000 /* 所有增益使用千分比 */ #define BBR_RTT_BASE 1500 /* 当前RTT/minRTT = 1.5, 超过开始限速 */ #define BBR_RTT_OVER_MAX 1000 /* 最多按超额比例 100% 计算惩罚 */ #define BBR_LOSS_BASE 2000 /* 丢包率 0.2% 以内不做响应 */ #define BBR_LOSS_OVER_MAX 8000 /* 丢包率超过 1% 的部分线性惩罚 */ #define BBR_GAIN_MIN 800 /* 最低允许增益 0.8 */ #define BBR_GAIN_MAX 1250 /* 最高允许增益 1.25 */ struct bbr_fairness_info { u32 last_rtt_us; /* 最近一次ACK采样到的RTT */ u32 min_rtt_us; /* 维护的最小RTT */ u32 loss_ppm; /* 每百万包丢包数, 由外部统计输入 */ u32 pacing_gain; /* 千分比表示的pacing增益 */ u32 cwnd_gain; /* 千分比表示的窗口增益 */ };

这个模块把“增益”从固定状态改成一个可计算的动态状态。外部每收到一次ACK就调用一次更新入口,内部根据RTT和丢包率做惩罚计算。

4.2 延迟膨胀反馈函数

延迟反馈是这个方案的主控回路。核心思想是:当前RTT超过最小RTT越多,说明队列堆积越严重,增益就应越低。这里有个很关键的设计细节——惩罚曲线不是一刀切的阈值开关,而是线性渐变的,这样增益变化不会剧烈抖动。

static u32 bbr_adjust_by_rtt(u32 gain, struct bbr_fairness_info *fi) { u32 rtt_ratio; if (fi->min_rtt_us == 0) return gain; /* 当前RTT与最小RTT的千分比, 例如 1.5 倍存为 1500 */ rtt_ratio = (fi->last_rtt_us * BBR_FAIR_SCALE) / fi->min_rtt_us; /* 没有明显排队, 不惩罚 */ if (rtt_ratio <= BBR_RTT_BASE) return gain; /* 超额部分按比例线性削减 */ u32 over = rtt_ratio - BBR_RTT_BASE; if (over > BBR_RTT_OVER_MAX) over = BBR_RTT_OVER_MAX; gain = gain * (BBR_FAIR_SCALE - over) / BBR_FAIR_SCALE; return max(gain, (u32)BBR_GAIN_MIN); }

我解释一下这段逻辑。rtt_ratio为1500时(刚好1.5倍),完全不惩罚。当rtt_ratio到2500时,over等于1000,增益会被乘以(1000-1000)/1000,等于直接压到0。为防止这种极端情况把单流吞吐打崩,最后用BBR_GAIN_MIN做了下限保护。实测下来,0.8倍增益已经足够让BBR退避,不需要真的压到0。

4.3 丢包率辅助保护函数

丢包反馈相当于一道“保险丝”,防止的是延迟反馈来不及生效的场景。比如链路瞬间出现大量丢包,但RTT测量还没有来得及反映,这时丢包反馈能快速介入。

static u32 bbr_adjust_by_loss(u32 gain, struct bbr_fairness_info *fi) { u32 loss_over; if (fi->loss_ppm <= BBR_LOSS_BASE) return gain; loss_over = fi->loss_ppm - BBR_LOSS_BASE; if (loss_over > BBR_LOSS_OVER_MAX) loss_over = BBR_LOSS_OVER_MAX; /* 每 1% 额外丢包, 削减 25% 剩余增益 */ gain = gain * (BBR_FAIR_SCALE - (u32)250) / BBR_FAIR_SCALE; gain = gain * (BBR_FAIR_SCALE - loss_over / 40) / BBR_FAIR_SCALE; return max(gain, (u32)BBR_GAIN_MIN); }

需要说明的是,丢包率统计在Linux内核中通常由TCP的拥塞状态机或DSACK信息推算,不是这个模块自己采样。实际接入时我是从tcp_sock的icsk_ca_state与重传计数折算的,也可以直接用sock_net上的丢包计数器,按包数加权模拟。

4.4 探测相位抖动与窗口限幅

相位抖动解决的是多流同步探测问题。标准的BBR在ProbeBW里按固定的增益序列推进,我在每一条流实例化时生成一个0到7之间的随机偏移量,让增益序列的起点错开。代码非常简单,但这个改动对多流同时进入高增益窗口的缓解效果非常明显。

static u8 bbr_phase_offset = 0; static u8 bbr_next_gain_index(struct bbr_fairness_info *fi, u8 idx) { u8 offset = READ_ONCE(bbr_phase_offset); return (idx + offset) & 0x07; }

窗口限幅则是控制inflight的上限。即使增益计算出来允许发送,也不允许超过带宽时延积的一定比例,避免短时间的突发打爆队列。我这边设的是1.25倍BDP,超过的部分直接截断。

static u32 bbr_fair_cwnd_limit(struct sock *sk, u32 cwnd, u32 inflight) { u64 target; /* BDP = 瓶颈带宽 * 最小RTT; 带宽以字节/秒计 */ target = bbr_bw(sk) * bbr_min_rtt_us(sk) / 1000000; target = (target * 1250) / 1000; return min(cwnd, (u32)target); }

4.5 如何接入内核与系统调参

最直接的接入方式是在Linux内核源码树上改tcp_bbr.c,把上面几个函数挂到bbr_update_model、bbr_set_pacing_rate和bbr_sndbuf_expand这几个现有回调点。如果你不想每次改动都重编内核,也可以用内核模块的方式,在module_init里注册自己的congestion_ops,替换默认的bbr实现。

调参方面,我建议把RTT_BASE和GAIN_MIN做成模块参数。因为不同网络的基线RTT差异很大。内网环境RTT很稳定,1.5倍基准可以设得更紧;跨地域链路RTT抖动本来就大,基准放宽到2.0倍更稳。这个建议在下面的实验部分还会再次体现。

5. 实验验证:从仿真拓扑到实测数据

5.1 测试拓扑与环境准备

代码写完之后,最关键的环节是验证是否真的改善了公平性,且没有牺牲太多单流吞吐。我使用的是模拟项目X里的实验网络,拓扑如下:

  • 三台客户端机器(C1、C2、C3),均连接同一个接入交换节点。
  • 一根瓶颈上行链路,带宽100Mbps,双向RTT 50ms,用tc netem模拟。
  • 瓶颈队列容量设置为1000个包,故意制造排队空间,让BBR有“抢占”的机会。
  • 服务端运行iperf3,客户端分别指定不同拥塞控制算法做并发传输。

环境配置用到了tc命令,这里直接给出我用的脚本片段:

#!/bin/bash # 瓶颈链路配置:100Mbps, 延迟25ms单向 tc qdisc add dev eth0 root handle 1: htb default 1 tc class add dev eth0 parent 1: classid 1:1 htb rate 100mbit tc qdisc add dev eth0 parent 1:1 handle 2: netem delay 25ms tc qdisc add dev eth0 parent 2:1 handle 3: bfifo limit 1000 # 查看配置 tc -s qdisc show dev eth0

5.2 并发测试脚本与公平性指数计算

客户端启动三路并发iperf3,两条流用bbr,一条流用cubic。这里要注意,iperf3的每个实例必须使用独立端口,否则会互相干扰。

#!/bin/bash # 三流并发测试:2条BBR + 1条CUBIC iperf3 -c 10.0.0.2 -p 5201 -t 60 -C "bbr" --json > bbr1.json & iperf3 -c 10.0.0.2 -p 5202 -t 60 -C "bbr" --json > bbr2.json & iperf3 -c 10.0.0.2 -p 5203 -t 60 -C "cubic" --json > cubic.json & wait

测试完成后,我用一个简单的Python脚本计算Jain公平性指数,用来量化优化前后的公平程度。Jain指数的公式是:

J = (Σx_i)² / (n · Σx_i²)

其中x_i是每条流的吞吐量,n是流的数量。计算结果越接近1,说明带宽分配越平均。

#!/usr/bin/env python3 import json import math import sys def jain_index(values): n = len(values) total = sum(values) sq_sum = sum(v * v for v in values) return (total * total) / (n * sq_sum) def get_throughput(path): with open(path) as f: data = json.load(f) return data["end"]["sum_received"]["bits_per_second"] / 1e6 # Mbps bbr1 = get_throughput("bbr1.json") bbr2 = get_throughput("bbr2.json") cubic = get_throughput("cubic.json") print(f"BBR1: {bbr1:.2f} Mbps") print(f"BBR2: {bbr2:.2f} Mbps") print(f"CUBIC: {cubic:.2f} Mbps") print(f"Jain Index: {jain_index([bbr1, bbr2, cubic]):.4f}")

5.3 优化前后的结果对比

同一拓扑、同一组参数,我在接入优化代码前和接入后各跑了一轮测试。下面是记录下来的结果:

场景BBR流1BBR流2CUBIC流Jain指数平均RTT
原始BBR48.2 Mbps46.9 Mbps4.9 Mbps0.73150ms
公平性优化后34.1 Mbps33.5 Mbps32.4 Mbps0.9978ms
优化后(纯BBR流混跑)49.8 Mbps50.2 Mbps—0.9965ms

结果是符合预期的。公平性优化后,三条流几乎均分了100Mbps,CUBIC不再是“陪跑角色”。同时平均RTT从150ms降到了78ms,说明排队堆积明显减少,这对延迟敏感业务是额外的收益。

再说一个意料之中的结果:优化后的单流吞吐略微下降。原始BBR单流能跑到95Mbps左右,优化后大概在88Mbps,损失约7%。原因不难解释——单流场景下RTT偶尔波动会触发延迟惩罚,增益暂时下调。如果你希望单流性能损失尽可能小,可以把RTT_BASE调得更宽松,付出一点公平性代价换取吞吐。

6. 实测中的坑与问题排查

6.1 典型问题速查表

这一节是实打实的踩坑记录,直接给出一份速查表,适合排查问题时对照。

现象可能原因处理方式
优化后单流吞吐下降超过15%RTT_BASE阈值过紧,正常网络抖动触发惩罚把BBR_RTT_BASE从1500调到2000,或把GAIN_MIN从800提高到900
多流混跑时偶发带宽抖频ProbeBW相位偏移没有生效,多流仍同步进入高增益窗口检查bbr_next_gain_index是否真正挂到增益序列更新路径,确认每流使用独立的随机偏移
低丢包率场景下增益被莫名压低丢包统计函数把乱序包误判为丢包改用TCP的DSACK与重传计数而不是ACK空洞来统计loss_ppm
优化后与CUBIC公平性好,但与Reno流竞争时仍吃亏延迟惩罚曲线对Reno的丢包退避响应不对称在延迟惩罚基础上叠加最小窗口约束,保持BBR的基础退避下限
高带宽长RTT链路效果不明显BDP大,排队延迟占比低,RTT膨胀比不敏感增加窗口限幅模块,直接用inflight上限做硬约束
RTT采样噪声大,增益频繁波动难收敛min_rtt_us维护周期太长,基准失真缩短最小RTT更新周期,并加入EWMA平滑

6.2 关于队列策略的一个容易忽略的点

如果是用tc模拟瓶颈队列,fifo类型的选择对测试结果影响非常大。bfifo是纯字节队列,包大小不一致时,队列容量体现为字节数而不是包数,这会让BBR的排队行为更难预估。我建议测试时统一用pfifo或者直接使用fq_codel作为队列策略,前者行为简单可控,后者能配合BBR的pacing特性得到更平滑的延迟曲线。

我在实验里用过一次默认的fifo,结果BBR的RTT膨胀比阈值怎么调都不对劲,后来才发现是队列策略的问题。换成pfifo之后,同样的代码参数,现象就完全可解释了。

6.3 内核模块替换时的一个安全措施

如果你选择模块方式替换内核默认的bbr,千万不要直接修改tcp_bbr.c里注册的名字或者默认的sysctl变量。最稳妥的做法是先通过sysctl net.ipv4.tcp_congestion_control=bbr_fair确认模块已经正确挂载,再在客户端和服务端同时启用,避免两端算法不一致导致测量结果完全失真。

另外,如果模块里用到了未导出的内核符号(比如部分bbr内部函数),用insmod安装时会报unknown symbol。我的处理方式是直接把核心逻辑写成一个独立的公平性控制文件,只通过netlink和proc接口接收统计量,这样就不依赖内核符号表的深层导出,模块加载稳定很多。

7. 进一步扩展的方向和我的一些个人体会

把整个公平性优化模块跑通之后,我对BBR这套算法有了更真实的理解。它本身不是“故意不公平”,而是它的设计目标优先了单流带宽和延迟,公平性不是它的核心优化指标。因此,直接改它的增益模型是相对务实的路径。

我再分享一个实际体会:不要盲目追求Jain指数等于1。在我测试过程中,把延迟惩罚曲线调得特别陡峭时,Jain指数的确能到0.99以上,但总吞吐会降到接近75Mbps,链路利用率明显不足。公平性应该是“够用就好”,在保证链路利用率不低于90%的前提下,把Jain指数从0.73提升到0.95以上,已经能解决绝大多数生产环境中的混跑不公平问题。

还有一个可扩展方向是结合ECN。当前方案里延迟反馈属于“被动观测”,如果瓶颈路由器支持ECN标记,完全可以在拥塞发生时主动反馈给BBR,让增益收敛得更快。我在模拟项目X里已经把这个机制列入了下一步计划,准备在延迟惩罚之前优先读取ECN标记,这样对浅队列链路的响应速度会比现在的RTT膨胀检测更快。

如果你也在调BBR的公平性,建议先从这套延迟+丢包双反馈的架构入手,逐步调参,不要一上来就大改状态机。相比动状态机结构,调整增益曲线和相位偏移的风险要小得多,出问题也容易回退。先把这条路径跑通,再考虑更复杂的模型演进,从工程角度来说是最稳的节奏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询