序章
TCP 拥塞控制算法在网络中占据重要地位,在 BBR 算法出来之前,大部分现代操作系统的拥塞控制算法经过好几代的更新,最后大多都是采用 Cubic;而在 BBR 出现之后,由于它在长肥网络中优异的带宽的利用率,加上 Google 在 Youtube 的推广,大有替换 cubic 等传统 TCP 拥塞算法的趋势。在 Aliyun Linux2 上我们也把默认的拥塞控制算法从 cubic 改成了 bbr。
然而 RDS 的 Redis 遇到一个问题,他们将他们的 ECS 从 Aliyun Linux 升级到 Aliyun Linux2 上之后,发现性能反而变差了,而且差了近一倍。他们给出的测试场景很简单:
在两个 VM 中,分别跑 redis-server 和 memtier_benchmark,具体的命令如下
VM#1: redis-server --protected-mode no
VM#2: memtier_benchmark -s 192.168.124.100 -p 6379 -d 3 -n 10000000 -c 100 -t 4 --ratio=100:0
然后,观察benchmark 输出结果里的 ops/sec。结果如下:
Aliyun Linux 1 (kernel 4.4.95-3) : 14W+
Aliyun Linux 2 (kernel 4.19.48-14): 8W+
复现环境:
于是我们在阿里云官网买了两台 ECS,server端 24个 core, client 端4个 core,自己搭建了一个测试环境,发现可以复现该问题。
server 端跑 redis-server,实际运行跑起来,发现 server 端其实只用到了一个core。
client 端跑 4个线程,100个连接。实际测试发现 client端跑4个线程,16个连接即可达到 OPS 的极限,瓶颈应该在 server端。
内网 IP 在同一个网段中,
server: 47.104.214.74
cmd: redis-server --protected-mode no
client: 118.190.53.2
cmd: memtier_benchmark -s 172.31.210.8 -p 6379 -d 3 -n 10000000 -c 100 -t 4 --ratio=100:0
进一步对比,发现问题出在拥塞控制算法上面, Aliyun Linux 1 使用的是 cubic 算法,而 Aliyun Linux 2 使用的是 BBR.
调整 memtier_benchmark 的连接数和线程数,测试 BBR 和 CUBIC 的情况:
从上面的测试可以看出:
- 在连接数不多的情况下,该测试 CUBIC 和 BBR 差别不大,但当连接数到8个之后,BBR 明显不如 CUIBC;
- 单个线程和多个线程情况基本上差不多;
- 实际发现原因是 cubic 在跑相同的 QPS 的情况下,达到 CPU 瓶颈的时连接数明显比 BBR 要高,相同的连接数的情况下,BBR 的性能显然不如 CUBIC 高。
进一步测试单线程情况下两者的 CPU 利用率。
对比上面的图可以看出,单个线程的情况下,相同的连接数:
- BBR 的 sys 占比明显比 CUBIC 要高,说明 BBR 需要处理的内核逻辑比 CUBIC 明显要多。而 sys 越高,同样的 CPU 利用率的情况下,应用真正能干的活就越少;
- 随着连接数的增加,SYS 占比越来越高;这个是符合预期的,连接数越多,内核需要处理的逻辑越多,cache miss也会越高;
- 在该场景下,cubic 的 user/sys 大概是 bbr 的 1.55~1.86 倍之间。
说明,BBR 在这种场景下相比 CUBIC 会占用更多的 CPU。 难道 BBR 被夸大了?
甜点
抓包发现,这个场景下,memtier_benchmark/redis 的流量模型就是一个 request depth > 1 的 request-response,request的大小大概是 44 字节,response 大概是5 字节。
既然是个网络问题,我们还是用标准网络工具来验证。我们用 netperf 测试一下,看看是否有相同的情况:
测试命令为:
taskset -c 3 netperf -t TCP_RR -l 50 -H 172.31.210.8 -- -r $req_size,$rsp_size
测试结果如下:
TCP_RR (单连接时延测试)
这里测试的 request size 和 response 保持一致,相当于相同 size 的 ping-pong。测试结果如下图:
TCP_STREAM (单连接吞吐测试)
这里的 send size 是 netperf 的 TCP_STREAM 模式下的 -m 参数,也就是指定 netperf 调用的 sendto() 里面buf 的 len,len越小,一次系统调用下去给内核的数据越少。
测试命令为:
taskset -c 3 netperf -t TCP_STREAM -l 500 -H 172.31.210.8 -- -m $send_size
对比 netperf 的测试结果,可以明显看出:
- 无论是 TCP_RR 还是 TCP_STREAM,结果与前面的 redis 的 benchmark 类似。相同吞吐的情况下,BBR 的 sys 态 CPU 利用率高于 CUBIC,在某些场景先,差别非常明显(send_size==1024, CUBIC vs. BBR: 51.69 vs. 98.20)。
正餐
既然 BBR 相比 CUBIC 有这么大的区别,那我们就应该要搞清楚为啥会差这么大,这中间是不是有什么可以优化的地方?
既然 netperf 可以轻易复现,想要找出来原因应该也不是一件困难的事情。我们先 perf 抓一把 netperf 在 cubic 和 bbr 的对比情况,如下:
一对比才发现 CUBIC 跟 BBR 的 CPU 占用率的区别根本就没有在 BBR 的逻辑上啊!
但是,perf 应该不会骗人,先重点先看看 BBR 中几个标红而 CUBIC 上不明显的函数:
ipt_do_table();
_raw_spin_unlock_irqrestore();
smp_apic_timer_interrupt();
我们再 perf script 找了下这三个函数调用栈都是谁,并对比 cubic 下的情况。发现一些问题:
-
ipt_do_table() 这种函数在 cubic 和 bbr 下执行的次数都很多;不同点在于, bbr 的 netperf 的调用栈中,多了很多如下的调用栈,而 cubic 下却没有。
netperf 13335 620971.431131: 250000 cpu-clock: ffffffff847d2c2d _raw_spin_unlock_irqrestore+0xd ([kernel.kallsyms]) ffffffff840f3a4e __hrtimer_run_queues+0xde ([kernel.kallsyms]) ffffffff840f3c3c hrtimer_run_softirq+0x7c ([kernel.kallsyms]) ffffffff84a000d1 __softirqentry_text_start+0xd1 ([kernel.kallsyms]) ffffffff84800d3a do_softirq_own_stack+0x2a ([kernel.kallsyms]) ffffffff84084688 do_softirq+0x58 ([kernel.kallsyms]) ffffffff840846f7 __local_bh_enable_ip+0x57 ([kernel.kallsyms]) ffffffff8471fbbb ipt_do_table+0x33b ([kernel.kallsyms]) ffffffff846bde8d nf_hook_slow+0x3d ([kernel.kallsyms]) ffffffff846d21bd __ip_local_out+0xcd ([kernel.kallsyms]) ffffffff846d2237 ip_local_out+0x17 ([kernel.kallsyms]) ffffffff846ec103 __tcp_transmit_skb+0x583 ([kernel.kallsyms]) ffffffff846ec7f3 tcp_write_xmit+0x243 ([kernel.kallsyms]) ffffffff846ed4f1 __tcp_push_pending_frames+0x31 ([kernel.kallsyms]) ffffffff846df0ae tcp_sendmsg_locked+0x9be ([kernel.kallsyms]) ffffffff846df467 tcp_sendmsg+0x27 ([kernel.kallsyms]) ffffffff8464dab6 sock_sendmsg+0x36 ([kernel.kallsyms]) ffffffff8464f1bc __sys_sendto+0xdc ([kernel.kallsyms]) ffffffff8464f264 __x64_sys_sendto+0x24 ([kernel.kallsyms]) ffffffff8400201b do_syscall_64+0x5b ([kernel.kallsyms]) ffffffff84800088 entry_SYSCALL_64_after_hwframe+0x44 ([kernel.kallsyms]) 7f84df63ae6d __libc_send+0x1d (/usr/lib64/libc-2.17.so)
-
_raw_spin_unlock_irqrestore()
的调用者基本上都跟 hrtimer 有关; - 而 cubic 中,就没有出现过 hrtimer;
以上几点都指向了 hrtimer。于是去找 BBR 的代码,发现原来人家代码一开始的注释中就写了个大写的 NOTE ?:
*
* NOTE: BBR might be used with the fq qdisc ("man tc-fq") with pacing enabled,
* otherwise TCP stack falls back to an internal pacing using one high
* resolution timer per TCP socket and may use more resources.
*/
再盯着这个 pacing 看,发现:
BBR 由于依赖于 pacing,所以 bbr 在 bbr_init() 里面,就把 sk->sk_pacing_status 初始化成了 SK_PACING_NEEDED;而一旦 sk->sk_pacing_status == SK_PACING_NEEDED,tcp 会尝试通过一个 hrtimer 来实现该功能,从而引入了大量的 hrtimer 的逻辑,占用了 CPU。
在 __tcp_transmit_skb()
(发包的关键路径) ,有这么个判断:
static int __tcp_transmit_skb(struct sock *sk, struct sk_buff *skb,
int clone_it, gfp_t gfp_mask, u32 rcv_nxt)
{
...// 省略若干行
if (skb->len != tcp_header_size) {
tcp_event_data_sent(tp, sk);
tp->data_segs_out += tcp_skb_pcount(skb);
tp->bytes_sent += skb->len - tcp_header_size;
tcp_internal_pacing(sk, skb); // tcp 层的 pacing
}
... // 省略若干行
}
可以看出,当当前的 skb 带有数据(不是一个纯 ack 包)时,就会调用 tcp_internal_pacing()
:
/* BBR congestion control needs pacing.
* Same remark for SO_MAX_PACING_RATE.
* sch_fq packet scheduler is efficiently handling pacing,
* but is not always installed/used.
* Return true if TCP stack should pace packets itself.
*/
static inline bool tcp_needs_internal_pacing(const struct sock *sk)
{
return smp_load_acquire(&sk->sk_pacing_status) == SK_PACING_NEEDED;
}
static void tcp_internal_pacing(struct sock *sk, const struct sk_buff *skb)
{
u64 len_ns;
u32 rate;
if (!tcp_needs_internal_pacing(sk))
return;
rate = sk->sk_pacing_rate;
if (!rate || rate == ~0U)
return;
len_ns = (u64)skb->len * NSEC_PER_SEC;
do_div(len_ns, rate);
hrtimer_start(&tcp_sk(sk)->pacing_timer,
ktime_add_ns(ktime_get(), len_ns),
HRTIMER_MODE_ABS_PINNED_SOFT);
sock_hold(sk);
}
其中 tcp_needs_internal_pacing() 就是判断 sk->sk_pacing_status 是否等于 SK_PACING_NEEDED。
如果相等,则就要走下面的 hrtimer_start() 的逻辑,起这个 pacing 的 hrtimer。这样就能解释,为什么 bbr 的perf 中,抓到那么多的 hrtimer 相关的函数,而 cubic 里面压根就没有了。
因为 cubic 里面这个 sk->sk_pacing_status == SK_PACING_NONE,而 bbr 中 sk->sk_pacing_status == SK_PACING_NEEDED。
TCP pacing
再接着看,BBR 依赖于 pacing,原来原始版本的 BBR 就是直接依赖于 tc-fq 的,之后,Eric D 老哥认为 BBR 这个拥塞控制算法不能跟流量调度算法绑定在一起,所以他搞了个 patch,在 TCP 内部自己实现了一个 pacing。
见:218af599 tcp: internal implementation for pacing
而正是这个 patch 引起了我们这问题。
夜宵
既然知道了 bbr 多出来的那么多的 CPU 是由于 sk->sk_pacing_status 引发的。而这个 sk_pacing_status 还可以复用 tc-fq 中的 pacing (SK_PACING_FQ),那我们打开 tc-fq 的 pacing 应该就可以避免掉这个 hrtimer 的时钟中断的开销了。毕竟人家 BBR 的注释 NOTE 中也是这么说的嘛。
测试一把:
# tc qdisc add dev eth0 root fq
打开 tc-fq 后,重新跑一把 netperf 的 TCP_STREAM 测试,结果如下:
send_size == 512bytes 的时候, BBR 仍然比 CUBIC 要高一些 (69% vs 61%),但总算差别没那么明显了。
在抓一把 perf 对比:
嗯... 前面的现在看起来总算差不多了。
我们再来对比下业务反馈 redis 性能差的问题:
BBR+FQ 还是比 CUBIC 稍微差一点点,但差别很小了。
洗洗睡
所以,总结下来,这个问题可以这样描述:
在内网低时延、高吞吐的环境下:
- 默认不使用 tc-fq,BBR 由于需要 pacing,而在种情况下 pacing 依赖于高精度timer,导致需要消耗大量额外 CPU,在高 PPS 的场景下,性能会变差;
- 流量调度换成 tc-fq 之后,BBR 不再使用额外的高精度时钟,CPU 消耗与 cubic 差不太多,性能也与 cubic 相当 (差5%以内)
最后关于 BBR 建议:
- 若仅仅在内网使用,内网环境带宽高,时延低,低丢包率的情况下,建议继续使用 cubic;
- 若对外提供服务,建议使用 BBR,并使相应的网卡使用 tc-fq 调度,否则可能占用额外的 CPU 资源,影响性能;
- 在 Aliyun Linux2 上,不同的连接拥塞算法是可以不一样的,并且 tcp 拥塞控制算法可以分 net_namespace 来控制;所以,如果有一台机器上有多个容器,每个容器又分属不同的 net_namespace,而有些容器只对外提供服务,有些容器只对内提供服务,可以对这些容器分别设置不同的拥塞控制算法,并将跑 BBR 的容器的网卡配置成 tc-fq 调度算法;