序章

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 的情况:

undefined undefined undefined

从上面的测试可以看出:

  1. 在连接数不多的情况下,该测试 CUBIC 和 BBR 差别不大,但当连接数到8个之后,BBR 明显不如 CUIBC;
  2. 单个线程和多个线程情况基本上差不多;
  3. 实际发现原因是 cubic 在跑相同的 QPS 的情况下,达到 CPU 瓶颈的时连接数明显比 BBR 要高,相同的连接数的情况下,BBR 的性能显然不如 CUBIC 高。

进一步测试单线程情况下两者的 CPU 利用率。 undefined

对比上面的图可以看出,单个线程的情况下,相同的连接数:

  1. BBR 的 sys 占比明显比 CUBIC 要高,说明 BBR 需要处理的内核逻辑比 CUBIC 明显要多。而 sys 越高,同样的 CPU 利用率的情况下,应用真正能干的活就越少;
  2. 随着连接数的增加,SYS 占比越来越高;这个是符合预期的,连接数越多,内核需要处理的逻辑越多,cache miss也会越高;
  3. 在该场景下,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。测试结果如下图: netperf TCP_RR CUBIC vs. BBR

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 CUBIC vs. BBR

对比 netperf 的测试结果,可以明显看出:

  1. 无论是 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 的对比情况,如下: perf cubic vs. bbr

一对比才发现 CUBIC 跟 BBR 的 CPU 占用率的区别根本就没有在 BBR 的逻辑上啊! 但是,perf 应该不会骗人,先重点先看看 BBR 中几个标红而 CUBIC 上不明显的函数:

ipt_do_table();
_raw_spin_unlock_irqrestore();
smp_apic_timer_interrupt();

我们再 perf script 找了下这三个函数调用栈都是谁,并对比 cubic 下的情况。发现一些问题:

  1. 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)
    
  2. _raw_spin_unlock_irqrestore() 的调用者基本上都跟 hrtimer 有关;
  3. 而 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 测试,结果如下:

undefined

send_size == 512bytes 的时候, BBR 仍然比 CUBIC 要高一些 (69% vs 61%),但总算差别没那么明显了。

在抓一把 perf 对比:

undefined

嗯… 前面的现在看起来总算差不多了。

我们再来对比下业务反馈 redis 性能差的问题: undefined 2thread_memtier_bench 4thread_memtier_bench

BBR+FQ 还是比 CUBIC 稍微差一点点,但差别很小了。

洗洗睡

所以,总结下来,这个问题可以这样描述: 在内网低时延、高吞吐的环境下:

  1. 默认不使用 tc-fq,BBR 由于需要 pacing,而在种情况下 pacing 依赖于高精度timer,导致需要消耗大量额外 CPU,在高 PPS 的场景下,性能会变差;
  2. 流量调度换成 tc-fq 之后,BBR 不再使用额外的高精度时钟,CPU 消耗与 cubic 差不太多,性能也与 cubic 相当 (差5%以内)

最后关于 BBR 建议:

  1. 若仅仅在内网使用,内网环境带宽高,时延低,低丢包率的情况下,建议继续使用 cubic;
  2. 若对外提供服务,建议使用 BBR,并使相应的网卡使用 tc-fq 调度,否则可能占用额外的 CPU 资源,影响性能;
  3. 在 Aliyun Linux2 上,不同的连接拥塞算法是可以不一样的,并且 tcp 拥塞控制算法可以分 net_namespace 来控制;所以,如果有一台机器上有多个容器,每个容器又分属不同的 net_namespace,而有些容器只对外提供服务,有些容器只对内提供服务,可以对这些容器分别设置不同的拥塞控制算法,并将跑 BBR 的容器的网卡配置成 tc-fq 调度算法;