
前言事实证明读过Linux内核源码确实有很大的好处尤其在处理问题的时刻。当你看到报错的那一瞬间就能把现象/原因/以及解决方案一股脑的在脑中闪现。甚至一些边边角角的现象都能很快的反应过来是为何。笔者读过一些Linux TCP协议栈的源码就在解决下面这个问题的时候有一种非常流畅的感觉。Bug现场首先这个问题其实并不难解决但是这个问题引发的现象倒是挺有意思。先描述一下现象吧笔者要对自研的dubbo协议隧道网关进行压测(这个网关的设计也挺有意思准备放到后面的博客里面)。先看下压测的拓扑吧:为了压测笔者gateway的单机性能两端仅仅各保留一台网关即gateway1和gateway2。压到一定程度就开始报错导致压测停止。很自然的就想到网关扛不住了。网关的情况去Gateway2的机器上看了一下没有任何报错。而Gateway1则有大量的502报错。502是Bad GatewayNginx的经典报错首先想到的就是Gateway2不堪重负被Nginx在Upstream中踢掉。那么就先看看Gateway2的负载情况把查了下监控发现Gateway2在4核8G的机器上只用了一个核完全看不出来有瓶颈的样子难道是IO有问题看了下小的可怜的网卡流量打消了这个猜想。Nginx所在机器CPU利用率接近100%这时候发现一个有意思的现象,Nginx确用满了CPU!再次压测去Nginx所在机器上top了一下发现Nginx的4个Worker分别占了一个核把CPU吃满-_-!什么号称性能强悍的Nginx竟然这么弱说好的事件驱动\epoll边沿触发\纯C打造的呢一定是用的姿势不对去掉Nginx直接通信毫无压力既然猜测是Nginx的瓶颈,就把Nginx去掉吧。Gateway1和Gateway2直连压测TPS里面就飙升了而且Gateway2的CPU最多也就吃了2个核毫无压力。去Nginx上看下日志由于Nginx机器权限并不在笔者手上所以一开始没有关注其日志现在就联系一下对应的运维去看一下吧。在accesslog里面发现了大量的502报错确实是Nginx的。又看了下错误日志发现有大量的Cannot assign requested address由于笔者读过TCP源码一瞬间就反应过来是端口号耗尽了由于Nginx upstream和后端Backend默认是短连接所以在大量请求流量进来的时候回产生大量TIME_WAIT的连接。而这些TIME_WAIT是占据端口号的而且基本要1分钟左右才能被Kernel回收。cat /proc/sys/net/ipv4/ip_local_port_range 32768 61000也就是说只要一分钟之内产生28232(61000-32768)个TIME_WAIT的socket就会造成端口号耗尽也即470.5TPS(28232/60),只是一个很容易达到的压测值。事实上这个限制是Client端的,Server端没有这样的限制因为Server端口号只有一个8080这样的有名端口号。而在upstream中Nginx扮演的就是Client,而Gateway2就扮演的是Nginx为什么Nginx的CPU是100%而笔者也很快想明白了Nginx为什么吃满了机器的CPU,问题就出来端口号的搜索过程。让我们看下最耗性能的一段函数:int __inet_hash_connect(...) { // 注意这边是static变量 static u32 hint; // hint有助于不从0开始搜索而是从下一个待分配的端口号搜索 u32 offset hint port_offset; ..... inet_get_local_port_range(low, high); // 这边remaining就是61000 - 32768 remaining (high - low) 1 ...... for (i 1; i remaining; i) { port low (i offset) % remaining; /* port是否占用check */ .... goto ok; } ....... ok: hint i; ...... }看上面那段代码如果一直没有端口号可用的话则需要循环remaining次才能宣告端口号耗尽也就是28232次。而如果按照正常的情况因为有hint的存在所以每次搜索从下一个待分配的端口号开始计算以个位数的搜索就能找到端口号。如下图所示:所以当端口号耗尽后Nginx的Worker进程就沉浸在上述for循环中不可自拔把CPU吃满。为什么Gateway1调用Nginx没有问题很简单因为笔者在Gateway1调用Nginx的时候设置了Keepalived所以采用的是长连接就没有这个端口号耗尽的限制。Nginx 后面有多台机器的话由于是因为端口号搜索导致CPU 100%,而且但凡有可用端口号因为hint的原因搜索次数可能就是1和28232的区别。因为端口号限制是针对某个特定的远端server:port的。所以只要Nginx的Backend有多台机器甚至同一个机器上的多个不同端口号只要不超过临界点Nginx就不会有任何压力。把端口号范围调大比较无脑的方案当然是把端口号范围调大这样就能抗更多的TIME_WAIT。同时将tcp_max_tw_bucket调小tcp_max_tw_bucket是kernel中最多存在的TIME_WAIT数量只要port范围 - tcp_max_tw_bucket大于一定的值那么就始终有port端口可用这样就可以避免再次到调大临界值得时候继续击穿临界点。cat /proc/sys/net/ipv4/ip_local_port_range 22768 61000 cat /proc/sys/net/ipv4/tcp_max_tw_buckets 20000开启tcp_tw_reuse这个问题Linux其实早就有了解决方案那就是tcp_tw_reuse这个参数。echo 1 /proc/sys/net/ipv4/tcp_tw_reuse事实上TIME_WAIT过多的原因是其回收时间竟然需要1min这个1min其实是TCP协议中规定的2MSL时间而Linux中就固定为1min。#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT * state, about 60 seconds */2MSL的原因就是排除网络上还残留的包对新的同样的五元组的Socket产生影响也就是说在2MSL(1min)之内重用这个五元组会有风险。为了解决这个问题Linux就采取了一些列措施防止这样的情况使得在大部分情况下1s之内的TIME_WAIT就可以重用。下面这段代码就是检测此TIME_WAIT是否重用。__inet_hash_connect |-__inet_check_established static int __inet_check_established(......) { ...... /* Check TIME-WAIT sockets first. */ sk_nulls_for_each(sk2, node, head-twchain) { tw inet_twsk(sk2); // 如果在time_wait中找到一个match的port,就判断是否可重用 if (INET_TW_MATCH(sk2, net, hash, acookie, saddr, daddr, ports, dif)) { if (twsk_unique(sk, sk2, twp)) goto unique; else goto not_unique; } } ...... }而其中的核心函数就是twsk_unique它的判断逻辑如下:int tcp_twsk_unique(......) { ...... if (tcptw-tw_ts_recent_stamp (twp NULL || (sysctl_tcp_tw_reuse get_seconds() - tcptw-tw_ts_recent_stamp 1))) { // 对write_seq设置为snd_nxt655362 // 这样能够确保在数据传输速率80Mbit/s的情况下不会被回绕 tp-write_seq tcptw-tw_snd_nxt 65535 2 ...... return 1; } return 0; }上面这段代码逻辑如下所示:在开启了tcp_timestamp以及tcp_tw_reuse的情况下,在Connect搜索port时只要比之前用这个port的TIME_WAIT状态的Socket记录的最近时间戳1s,就可以重用此port,即将之前的1分钟缩短到1s。同时为了防止潜在的序列号冲突直接将write_seq加上在65537,这样在单Socket传输速率小于80Mbit/s的情况下不会造成序列号重叠(冲突)。同时这个tw_ts_recent_stamp设置的时机如下图所示: