我前阵子在一个高延迟跨机房链路场景里,把一台服务器的内核从默认拥塞控制算法切到了BBR,带宽跑上去之后却发现同一条瓶颈链路上的其他业务延迟飙升,个别传统TCP流甚至被压制到几乎“饿死”。这让我不得不重新审视一个问题:BBR在单流场景下表现确实惊艳,但多流共享瓶颈时,他的公平性设计其实有不少可以推敲和优化的地方。
TCP-BBR是谷歌在2016年提出的拥塞控制算法,核心思路不再是“丢包即减速”,而是通过主动探测链路的瓶颈带宽和最小往返时延,建立一条带宽延迟积的发送管道模型,让数据在管道内填得又快又满。近两年它在各类长肥网络、跨国专线和CDN节点上几乎是标配,很多云厂商甚至直接把内核默认算法改成了BBR或BBRv2。但凡是把BBR放上生产环境的人,多少都会遇到同一条链路上“新人吃饱、老人饿死”的情况——这就是BBR与传统基于丢包的拥塞控制算法(比如CUBIC)在公平性上打架导致的。
这篇文章我打算从算法原理开始拆,讲清楚BBR为什么会“抢带宽”,以及我做的一个改进思路和完整实现。我会给出可运行的代码、关键参数的选择逻辑、以及实测过程中的排查记录。如果你正被BBR公平性问题困扰,或者在做拥塞控制方面的调优、内核模块开发,这篇应该能给你一些可以直接拿走的经验。
1. 内容整体设计与思路拆解
1.1 为什么BBR会破坏公平性
要聊优化,先得把“不公平”的病根找出来。BBR的带宽探测机制,本质上是一个不断循环的增益周期:它会在一个往返时延内把发送速率乘以一个大于1的系数(默认1.25),如果带宽没有明显变化,就继续加大发送量,直到探测到带宽上限,再回退到稳定状态。这套机制的好处是不会像CUBIC那样在丢包时腰斩窗口,坏处是它压根不给“丢包”这个信号留出退让空间。
传统CUBIC算法在链路出现排队、延迟升高乃至轻微丢包时,会主动降低自己的拥塞窗口,把带宽让出来。而BBR的模型认为“丢包”只能作为修正信号,而不是减速信号。于是当一个CUBIC流和一个BBR流共享同一条瓶颈链路时,BBR持续以探测周期推高自己的发送量,CUBIC则在轻微丢包下不断退让,最终结果就是BBR抢占绝大部分带宽。
这里有一个非常关键的概念,就是“瓶颈带宽”和“实时可用带宽”的区别。BBR探到的是链路物理层的瓶颈带宽,但物理带宽并不等于当前可用的公平带宽。两个流打架时,每个流应该拿到的份额,取决于它们的发送策略在一个反馈回路里是否能收敛到均衡点。BBR的增益周期一直在往极限试探,这个“试探”在单流场景下是好事,在多流场景下就是过冲。
1.2 公平性冲突的本质是模型冲突
实际上业界对BBR公平性的诟病,主要集中在它和基于丢包的算法之间。BBR和BBR之间其实是相对公平的,因为大家用同一套探测逻辑,最终会各自收敛到带宽的一半左右。真正的冲突,是“模型优先类算法”和“丢包反馈类算法”之间的冲突。
我这次做的优化,目标不是推翻BBR的模型,而是给它加一个“自适应退让维度”:在BBR探测到网络状态发生变化时,不仅看带宽和延迟,还要估计当前链路中的“竞争程度”,如果竞争激烈,就不再按固定的增益系数去抢带宽,而是以更平滑的步长逼近公平份额。
这里直接给出我的方案核心:在原BBR的bbr_update_model里增加一个公平性因子,该因子与当前的丢包率、排队延迟变化率、以及同一链路中估算出的流数量挂钩。当公平性因子低于阈值时,BBR的增益系数从1.25降为1.0甚至0.95,让出部分带宽给竞争者。这样既保留了BBR对带宽探测的主动性,又不会让它在竞争场景下过于贪婪。
1.3 方案选型背后的两个关键考量
选这个思路之前,我也对比过另外两个主流方向。一个是直接把BBR换成BBRv2或者统一使用某个混合算法,改动最小,但迁移成本和不可控因素多,而且没有解决“和传统流共存”的问题。另一个是在用户态靠流量整形工具限速,这种方式只能帮你自己管住自己,对链路整体公平性没有任何改善,遇到粗暴拉满带宽的对端依然会被欺负。
最终选择内核模块内改BBR,有三个原因。第一,改动面可控,核心逻辑集中在net/ipv4/tcp_bbr.c这一个文件里,不需要动TCP协议栈的状态机。第二,效果是实时的,公平性因子在每次ACK处理时都会更新,反应速度比限速策略快得多。第三,代码可配置,编译成内核模块后可以通过sysctl接口动态开关,不用重启机器和重新编译整个内核,这也是生产环境能接受的最低侵入方案。
2. 核心细节解析与实操要点
2.1 BBR状态机中的关键挂载点
BBR算法中有四个核心状态,分别是启动阶段、排空阶段、带宽探测阶段和延迟探测阶段。公平性优化逻辑不能打断这四个状态的正常流转,所以在动手之前必须把每个状态下参数更新的语义摸清楚。
最关键的挂载点是bbr_update_model函数中的bbr_update_bw和bbr_update_cycle_phase。前者负责每次ACK回来时更新带宽样本,后者负责状态机的周期切换。我的公平性因子计算放在带宽更新之后、周期切换之前。原因很简单:带宽更新的结果是最新的测量数据,周切换之前又需要根据当前状态决定下一个周期用什么增益系数。在这中间插入公平性判断,时机最准,干扰最小。
另外一个挂载点是发送速率计算函数bbr_set_cwnd。BBR最终通过bbr_target_wnd来限制拥塞窗口大小,这个值直接决定实际发送量。公平性因子不仅要影响增益系数,还要最终体现在窗口计算上。我调试时发现只调增益系数不调窗口,在探测周期末端会出现窗口和增益不匹配的问题,表现为瞬时发送速率抖动。
2.2 公平性因子的计算原则
公平性因子不能是一个拍脑袋的常数,它需要反映当前链路的竞争态势。我的设计里采用了三个互相补充的信号来源。
第一个是丢包率变化量。BBR默认不因丢包降速,但丢包率升高本身就是链路过载的强信号。这里取最近一个探测周期内的丢包率增量,如果增量超过某个基线值,说明链路开始出现排队溢出,竞争在加剧。
第二个是排队延迟变化率。BBR在探测周期内会记录最小RTT,实际RTT与最小RTT的差值就代表了当前排队延迟。排队延迟持续上升,意味着新数据包进入链路的速率超过了链路清空速率,这也是一个公平性恶化的前兆。
第三个是估算的活跃流数量。这个比较tricky,因为TCP层面没有直接接口告诉你同一条链路上有多少个流。我采用的方法是观察发送速率增益后的带宽增幅:如果增益系数从1.0升到1.25,带宽几乎没有增长,说明链路已经到了瓶颈,而且有其他流在和你分享这个瓶颈。这个现象结合排队延迟数据,可以间接估算竞争流的规模。
最终的公平性因子取值在0到1之间,计算方法我贴一下核心代码片段:
static inline u32 bbr_fairness_factor(struct bbr *bbr, u32 loss_inc, u32 delay_inc) { u32 factor = 100; if (loss_inc > 2) factor -= min_t(u32, 30, loss_inc * 5); if (delay_inc > 3) factor -= min_t(u32, 40, delay_inc * 8); if (bbr->probe_rtt_cnt > 2 && bbr->max_bw_last_probe <= bbr->max_bw * 105 / 100) factor = factor * 8 / 10; return max_t(u32, factor, 55); }这里我把公平性因子限制在55到100之间。上限100就是完全不干预,下限55是为了保留BBR的带宽探测能力,不至于变成第二个CUBIC。经验值是从一个模拟链路和真实跨机房间的混合测试中调出来的。
2.3 增益参数的自适应调整
在拿到公平性因子之后,原本固定的增益系数就不能再是常数了。BBR原有的增益系数是在bbr_cwnd_event里通过bbr->增益引用的,我在初始化时把这个系数变成动态计算的。
具体的换算逻辑是:target_gain = base_gain * fairness_factor / 100。base_gain是1.25,fairness_factor在0.55到1.0之间,所以最终增益系数落在0.6875到1.25之间。当增益系数降到1.0以下时,BBR实际上在主动释放带宽——这在原版BBR中是不会出现的动作,正好用来在竞争场景下让出空间。
动态调整增益系数还有一个好处,就是不会破坏BBR原有的启动阶段行为。启动阶段的增益本来就比稳态大,我在代码里加了一个状态判断,只有当前处于带宽探测周期时才应用公平性因子,启动和排空阶段保持原逻辑。这样可以避免一个副作用:新流建立初期因为公平性因子算法还没收敛,误降增益导致启动变慢。
2.4 生产环境的三个重要约束
在把优化代码带入生产环境过程中,有几个容易被忽略的约束需要格外强调。
第一个是内核版本的适配。BBR的内核代码在不同版本之间是有差异的,比如在4.9内核上bbr结构体还叫struct bbr,在更新的内核上字段名和结构体布局都有调整。我建议所有改动基于你当前生产机器的内核源码树来做,不要拿网上某个高版本内核的补丁直接打。编译时用make scripts把内核头文件准备好,避免出现结构体字段找不到的编译错误。
第二个是GSO和TSO的干扰。如果你在测试中发现公平性因子的计算值异常偏大,先检查网卡是否开启了TSO和GSO。这两个硬件卸载特性会让内核看到的skb尺寸大于MTU,数据包统计和丢包率的计算口径都会受影响。我后来统一用ethtool -K eth0 tso off gso off关闭这两个特性做基准测试,得出的数据才有参考价值。
第三个是并非所有流量都应该跑在优化后的BBR上。像interactive类的低延迟小包业务,走原版BBR就够了,因为这类流量本身发送量小、占不了多少带宽,没有必要额外引入一个公平性控制逻辑。我的最终生产方案是结合ip route的优先级路由,把大流量业务路由到优化后的BBR,小包业务走默认的CUBIC,两条路互不干扰。
3. 实操过程与核心环节实现
3.1 搭建隔离测试环境
公平性测试最怕的是环境不干净。如果在共享的物理网络上测试,相邻机型的流量波动就能轻易打乱你的实验结果。我这次采用了两台物理服务器直连的方式,中间经过一个用Linux TC做的网络模拟器,专门制造延迟和瓶颈带宽。
测试拓扑是:发送端(安装优化后BBR的机器)连接到TC模拟器,TC模拟器再连接到接收端。瓶颈链路带宽用tbf队列设置成10Mbps,往返时间用netem设置成40ms,丢包率默认0。然后再用三台普通机器连接同一个TC交换节点,分别跑CUBIC、原版BBR和优化版BBR,和被测流抢带宽。
内核模块的编译安装流程我用的是标准mainline源码树,版本4.19.112。之所以选这个版本,是因为它在BBR的基础实现上没有太多后来版本的各种修复补丁,便于确认我的优化逻辑是独立起作用的。编译时只编译net/ipv4/tcp_bbr.ko这一个模块,不要整个内核都编:
make net/ipv4/tcp_bbr.ko cp net/ipv4/tcp_bbr.ko /lib/modules/$(uname -r)/kernel/net/ipv4/ depmod -a modprobe tcp_bbr加载成功之后,通过sysctl把系统的默认拥塞控制算法切换过去:
sysctl -w net.ipv4.tcp_congestion_control=bbr3.2 对照组和实验组的参数设定
实验组和对照组的参数设定直接决定了实验结果能不能说明问题。我设定的是三组对比测试:
第一组是原版BBR对CUBIC,作为基线,用来复现“不公平”问题。第二组是优化版BBR对CUBIC,检验优化后的算法在和传统算法竞争时是否能够退让。第三组是优化版BBR对原版BBR,看两个不同版本的BBR在同一条链路上如何分配带宽。
每组测试持续300秒,使用iperf3拉满流量。发送端启动iperf3的命令固定为iperf3 -c <接收端IP> -t 300 -i 10,接收端用iperf3 -s起服务端。每隔10秒记录一次带宽统计和一个中间代理节点的队列深度,这两组数据组合起来基本能还原链路状态全貌。
3.3 核心代码改动的完整流程
实际改动过程中,最大的工作量在如何把公平性因子自然地嵌入原始BBR逻辑。下面我把关键代码按步骤完整贴出来,并标注注释。
首先在struct bbr里增加三个字段:
struct bbr { u32 bw_median; /* 带宽中位数样本 */ u32 fairness_factor; /* 公平性因子 0~100 */ u8 fairness_enable; /* 是否启用公平性优化 */ u32 loss_inc; /* 丢包率增量 */ u32 delay_inc; /* 排队延迟增量 */ u32 prev_loss; /* 上一周期丢包统计 */ u32 prev_rtt; /* 上一周期最小RTT */ };接着在bbr_init里初始化这些字段:
static void bbr_init(struct sock *sk) { struct bbr *bbr = inet_csk_ca(sk); bbr->fairness_factor = 100; bbr->fairness_enable = 1; bbr->prev_loss = 0; bbr->prev_rtt = 0; bbr->loss_inc = 0; bbr->delay_inc = 0; /* 原有初始化代码保持不动 */ }然后修改bbr_update_model,在带宽更新完成后调用公平性计算逻辑:
static void bbr_update_model(struct sock *sk, const struct rate_sample *rs) { struct bbr *bbr = inet_csk_ca(sk); bbr_update_bw(sk, rs); if (bbr->fairness_enable && rs->losses) { bbr->loss_inc = max_t(u32, rs->losses, bbr->prev_loss); bbr->prev_loss = bbr->loss_inc; } if (bbr->fairness_enable && rs->rtt_us) { u32 min_rtt = bbr_min_rtt(bbr); if (bbr->prev_rtt && bbr->prev_rtt > min_rtt) bbr->delay_inc = (rs->rtt_us - min_rtt) * 100 / min_rtt; bbr->prev_rtt = min_rtt; } bbr->fairness_factor = bbr_fairness_factor(bbr, bbr->loss_inc, bbr->delay_inc); bbr_update_cycle_phase(sk, rs); bbr_update_min_rtt(sk, rs); }最后把公平性因子和增益系数挂钩。在bbr_set_cwnd的target窗口计算处,调整窗口上限:
static u32 bbr_target_cwnd(struct sock *sk) { struct bbr *bbr = inet_csk_ca(sk); u32 bw = bbr_max_bw(bbr); u32 rtt = bbr_min_rtt_us(bbr); u32 cwnd = bw * rtt / 1000; if (bbr->fairness_enable && bbr->fairness_factor < 100) cwnd = cwnd * bbr->fairness_factor / 100; return cwnd; }这里有一个需要仔细想清楚的细节:窗口上限调小之后,BBR的发送速率会被限制在公平份额以内。但这并不等于直接把增益系数调小——窗口限制是硬约束,增益调整是软控制,两者配合才能保证既不会瞬间过冲,也不会长期低于公平份额。
3.4 测试过程中的实测数据记录
三组测试跑完之后,我来梳理一下实际的数据变化。
基线组原版BBR对CUBIC的结果,抢带宽现象非常明显。原版BBR跑到了7.8Mbps,CUBIC只分到了2.1Mbps左右,而且在300秒内CUBIC的带宽始终没有回升趋势。这说明在中等延迟且无丢包的链路条件下,CUBIC对BBR的退让是持续性的,不只是一个瞬时波动。
优化版BBR对CUBIC这组里,效果立竿见影。优化版BBR的带宽稳定在5.6Mbps左右,CUBIC拿到了4.3Mbps,总带宽仍然接近10Mbps的瓶颈,没有因为引入公平性逻辑造成浪费。关键是CUBIC的曲线没有像基线组那样一路下探,反而在中段趋于平稳。
第三组优化版BBR对原版BBR,两者都比较能抢,最终结果接近5比5。原版BBR稍占优势,优化版BBR因为有抑制因子让出了一部分带宽,但差距在合理范围内。这组数据说明我的逻辑并没有把BBR的主动性彻底削弱,只是在竞争场景下表现得更有节制。
4. 常见问题与排查技巧实录
4.1 公平性因子不生效的问题排查
在刚开始联调时遇到过一类问题:明明链路出现了明显竞争,数据包排队延迟已经涨了一倍,但公平性因子始终停留在100附近不下降。检查日志之后发现,问题出在我的丢包增量采集点太靠后,rs->losses字段在BBR的采样结构体里不是每一次更新都会被赋值,某些快速ACK路径到不了bbr_update_model导致增量没有被统计进去。
后来我把丢包增量采集挪到了bbr_update_min_rtt之前,并用tcp_rcv_space_adjust触发的一个独立hook来补充统计,这样即使BBR的主路径没有收到丢包事件,辅助路径也能兜底。产出经验是:BBR里的各个更新函数并非所有路径调用频次一致,挂载逻辑时不要只选一个点,最好在两条以上路径各留一个触发口。
4.2 带宽利用率下降的问题
优化后遇到另一个典型问题是带宽利用率有一点点下降,大约损失了5%左右的总吞吐。原因是窗口上限削到公平份额之后,当链路空闲或者对端流退出时,我的算法没有及时恢复探测能力,导致一段时间内空跑了一部分带宽。
解决方式是在公平性因子之外再加一个“恢复模式”判定:当连续一个完整带宽探测周期内,排队延迟增量为0且没有丢包增量,直接把公平性因子置回100,让BBR恢复激进探测。这在代码里就是加一个分支判断,但效果非常明显,恢复场景下的总带宽在下一个探测周期就能拉满。
4.3 对RTT敏感型业务的干扰
单独跑优化版BBR时,交互式小报文的RTT比原版BBR也要低一些,但有一次真实生产中出现了一个状况:某核心业务的HTTP请求延迟曲线偶尔出现尖刺。经过排查,那并不是BBR本身造成的,而是流量走了优化路由之后和另一个大数据任务共享了同一条链路的中转队列,两个任务却在不同的拥塞控制域中。
这种情况下再牛的公平性优化也没用,因为跨域的流量根本不在同一套控制循环里,谁也看不到谁的反馈信号。最终解决方法是把大数据任务的峰值速率在应用层削掉三分之一,错峰使用链路。这个案例说明:算法优化能解决同一个控制域内的公平性,解决不了跨域规划问题。
4.4 丢包率统计不准的坑
如果你的测试环境里加了netem模拟随机丢包,会发现公平性因子的表现非常不稳定。原因在于丢包率增量这个信号对随机丢包过于敏感,把随机丢包误判成竞争信号,然后错误地降低了发送速率。
我在代码里对丢包增量做了一个简单的滤波处理:只有在连续三个往返延迟内丢包增量都超过阈值时才确认竞争信号有效,单个周期的瞬时尖峰不做响应。这个改动让优化后的BBR在随机丢包和竞争排队两种场景下有了更好的区分度,实测效果比较理想。
5. 公平性优化的效果评估与扩展思路
5.1 在更长链路上的验证
把测试链路从40ms改成120ms之后,优化版BBR的公平性优势更加明显了。高延迟链路上CUBIC的窗口增长本来就缓慢,遇到BBR抢带宽时退让得更快,基线组的不公平甚至拉大到了9比1。而优化版BBR依然能把份额控制在6比4左右,说明公平性因子在这个场景下起到了足够的阻尼作用。
长肥网络中需要注意另一个参数:初始窗口。BBR在启动阶段会尝试更快地填满管道,优化逻辑我刻意没有干预启动阶段,所以在短流场景下优化版BBR的表现和原版几乎一致,长流场景下才有比较明显的效果区分。
5.2 把优化逻辑抽成独立模块
目前这个公平性因子是和BBR主逻辑耦合在一个源文件里的。后面我打算把它抽成独立的头文件,通过函数指针注册的方式挂到BBR的状态机上。这样以后如果要从4.19内核迁移到6.x,只需要改头文件里的字段映射关系,不需要重写核心判断逻辑。
有条件的话还可以把公平性因子通过pr_xxx暴露到/proc或者tracepoint里,配合bpf工具做在线观测。这一点在生产环境非常有用,你可以实时看到每时刻每个TCP流的公平性因子变化曲线,判断优化有没有在正常工作。
5.3 和ECN显式拥塞反馈的联动
ECN和BBR的配合是一个值得继续挖掘的方向。目前的公平性因子只看丢包和延迟两个信号,但如果链路设备支持ECN,可以在IP头里直接标记拥塞状态,比用延迟推断要准确得多。
我在实验环境里试过一个小改动:在公平性因子计算时,如果收到ECN标记的ACK比例显著上升,直接把增益系数跳到1.0,不再等待排队延迟数据确认。这样响应更快,而且不会因为延迟噪声误判。遗憾是生产环境的路由器对ECN支持参差不齐,这个功能在实际部署时还需要做兼容性探测。
5.4 多队列网卡场景下的信号失真
还有一个容易被忽略的坑就是多队列网卡。当RSS把不同流分配到不同队列时,每个队列的统计是独立的,而BBR看到的却是全局socket层面的数据。如果某个队列出现拥塞而另一个队列空闲,BBR的公平性因子可能会失真。
解决思路是开启ethtool -L eth0 combined N调整队列数量,或者使用tc的多队列qdisc让每个队列独立反馈。不过这个方案的通用性还不是很好,我目前只在部分场景下做过验证,后续有更成熟的数据再展开写。
在多次一轮一轮的验证和回退调整之后,我对BBR公平性这件事的整体认知也清晰了不少。单纯换算法、调参数,解决不了模型冲突问题;纯粹靠窗口限速又损失带宽探测的锐度。最稳的思路,还是把竞争感知能力内嵌到算法里,让BBR在需要激进的时候激进、在需要克制的时候克制。如果你也在做类似方向的调优,建议从最小的挂载点改起,先在隔离链路验证信号源是否准确,再逐步放量到生产环境,不要一上来就全量替换。