机器通常过质保以后,就会由于各类各样的问题而宕机。而这一次的宕机,让笔者观察到了日常观察不到的tcp在对端宕机状况下的行为。通过详细跟踪分析缘由以后,发现能够经过调整内核tcp参数来减小宕机形成的影响。java
笔者所在的公司用某个中间件的古老版本作消息转发,此中间件在线上运行有些年头了,大约刚开始部署的时候机器仍是全新的,如今都已通过保了。机器的宕机致使了一些诡异的现象。以下图所示:linux
在中间件所在机器宕机以后,出现了调用中间件超时的现象。抛开各类业务细节,会发现出现了时间很长的超时。其中一波在821s以后报出了Connection reset异常,还有一波在940s以后报出了Connection timed out(Read failed)异常。网络
发现出bug的时间点很微妙,有将近10个请求是在22:32:22.300左右集中报错,而且这个时间点有Connection reset。
另外一波是在22:34.11.450左右集中报错,而且这个时间点由Connection timed out(Read failed)。
因而笔者看了下此中间件client的网络模型,以下图所示:
这就很容易理解,为什么请求为什么都是在同一时刻超时,由于是顺序请求,后面的几个请求还没发送出去,就因为第一个请求超时而致使后面的全部请求报错。以下图所示:
进一步推出,真正的socket超时时间是请求1(最长)的超时时间。
即对应oracle
Connection reset的821s Connection timed out(Read failed)的940s
这个中间件采用了bio模型,而且socket没有设置超时时间,其业务超时时间经过业务层的future来控制。可是这个超时时间只有在真正发送请求的时间起做用,每一个请求以前还会有其它的一段交互,以下图所示:
至此,问题缘由已经很明显了,在(do something)的那个过程因为socket设置soTimeOut为0,致使卡住了至关长的一段时间。代码以下图所示:运维
..... protected int soTimeout; ...... protected void initialiseSocket(Socket sock) throws SocketException, IllegalArgumentException { ...... // 默认是0 sock.setSoTimeout(soTimeout); ...... }
问题自己的查找是比较简单的,若是仅仅只有这些的话,笔者也不会将其写成一篇博客。
因为socket设置timeout(>0)是一种常识,不多遇到设置为0的状况。因而其引发的现象引发了笔者的兴趣。咱们看看socket设置timeout为0后jdk源码的描述:dom
/** * ...... * A timeout of zero is interpreted as an infinite timeout. * ...... */ public synchronized void setSoTimeout(int timeout) throws SocketException { if (isClosed()) throw new SocketException("Socket is closed"); if (timeout < 0) throw new IllegalArgumentException("timeout can't be negative"); getImpl().setOption(SocketOptions.SO_TIMEOUT, new Integer(timeout)); }
里面有这么一段话socket
A timeout of zero is interpreted as an infinite timeout
按上述字母解释为若是设置为0的话,应该是等待无限长的时间(直到进程重启)。
但是按照线上业务的表现,确是有超时时间的,只不过期间很长。最长的达到了940s,即15分钟多。
这就引发了笔者的兴趣,究竟是什么让这个无限的超时时间被打断呢?咱们继续分析。tcp
首先咱们聚焦于第一个异常报错Connection reset(22:32分), 笔者自己阅读过tcp协议栈源码,知道基本上全部Connection reset都由对端发出。因此笔者料定在22:32分的时候,机器确定又活过来了,可是对应的中间件进程确没有起来,因此没有对应的端口,进而当包过来的时候,发送tcp reset包回去(即便当前中间件起来了也会发送reset,由于tcp自己的seq序列号校验失败)。以下图所示:
而后了解到在22:32左右,为了拷贝宿主机内部的消息记录,运维确实将宕掉的机器从新给拉起来了,这进一步印证了个人想法。可是按照笔者的推论,在22:32分新发出重传的全部的请求都被Connection reset了,为什么在将近两分钟以后(准确的说是在1分49s以后由又报了一波错?)继续往下分析。
(注意22:32分和22:34分报错的是不一样的socket链接)ui
这个错误不多遇到。不知道是在哪一种状况下触发。具体的异常栈为:this
Caused by: java.net.SocketException: Connection timed out(Read failed) at java.net.SocketInputStream.socketRead0(Native Method) ~[?1.8.0_121] at java.net.SocketInputStream.socketRead(SocketInputStream.java:116) ~[?:1.8.0_121] ......
因而用sublime搜索Connection timed out,发现其只在Java_java_net_PlainSocketImpl_socketConnect出现,和上面的异常栈明显不符合。
那么就从socketRead0入手,咱们详细看看源代码:
JNIEXPORT jint JNICALL Java_java_net_SocketInputStream_socketRead0(JNIEnv *env, jobject this, jobject fdObj, jbyteArray data, jint off, jint len, jint timeout) { ...... nread = NET_Read(fd, bufP, len); if (nread <= 0) { if (nread < 0) { switch (errno) { case ECONNRESET: case EPIPE: JNU_ThrowByName(env, "sun/net/ConnectionResetException", "Connection reset"); break; case EBADF: JNU_ThrowByName(env, JNU_JAVANETPKG "SocketException", "Socket closed"); break; case EINTR: JNU_ThrowByName(env, JNU_JAVAIOPKG "InterruptedIOException", "Operation interrupted"); break; default: NET_ThrowByNameWithLastError(env, JNU_JAVANETPKG "SocketException", "Read failed"); } } } ...... }
答案就在NET_ThrowByNameWithLastError里面,其最后调用的是os::stderr来获取kernel返回的error字符串。
查了下linux stderr手册,发现是ETIMEDOUT对应了Connection timed out。
可是后面的Connection timed out(Read failed)中的(Read failed)不该该拼接在后面,由于其逻辑是kernel返回error就用kernel的error,不然用defaultDetail即(Read failed和errno的组合)。具体缘由,笔者并无在openJdk源码中找到,猜想多是版本的缘由或者oracleJdk和openJdk之间细微的差异。
既然是linux kernel返回的,笔者就立马翻了linux源码。
(这其中有个插曲,就是笔者一开始看的是2.6.24内核源码,发现怎么计算都对不上数据。后来看到线上用的是2.6.32内核版本,翻了对应版本的源码,才搞定)
既然是sockRead0返回的,那确定不是socket建立链接阶段(SYN),确定到了establish的send/rcv阶段。这个错误最有可能就是在重传失败的时候返回的错误。因而翻了下重传的源代码:
static void tcp_retransmit_timer(struct sock *sk) { ...... // 检查当前重传是否已经超过最大时间 if (tcp_write_timeout(sk)) goto out; ...... icsk->icsk_backoff++; icsk->icsk_retransmits++; out_reset_timer: // 从新重传定时器,rto最大为TCP_RTO_MAX即为120s icsk->icsk_rto = min(icsk->icsk_rto << 1, TCP_RTO_MAX); inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS, icsk->icsk_rto, TCP_RTO_MAX); if (retransmits_timed_out(sk, sysctl_tcp_retries1 + 1)) __sk_dst_reset(sk); }
上面逻辑是首先断定是否超时,若是未超时则设置下一个超时时间。逻辑以下图所示:
咱们再看下tcp_write_timeout:
static int tcp_write_timeout(struct sock *sk){ ... // 对SYN,即建立链接过程当中的处理 ... // retry即便kernel中的tcp_retries2 // 即cat /proc/sys/net/ipv4/tcp_retries2便是15 retry_until = sysctl_tcp_retries2; // 下面就是超时判断的过程 if (retransmits_timed_out(sk, retry_until)) { /* Has it gone just too far? */ // 若是超过最大时间,则调用tcp_write_err tcp_write_err(sk); return 1; } return 0; }
tcp_write_err确实返回了ETIMEDOUT,以下面源码所示:
static void tcp_write_err(struct sock *sk) { sk->sk_err = sk->sk_err_soft ? : ETIMEDOUT; // 返回ETIMEDOUT sk->sk_error_report(sk); tcp_done(sk); NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_TCPABORTONTIMEOUT); }
至此,基本能够断定就是tcp_write_timeout超时了,也即其中的
retransmits_timed_out断定超时。
很明显为何940s的时候没有Connection reset,就是因为先判断了tcp_write_timeout超时致使没有发送下一个重传包,而直接time_out,若是发了,那就是Connection reset。
这个计算过程直接上源码:
static inline bool retransmits_timed_out(struct sock *sk, unsigned int boundary) { unsigned int timeout, linear_backoff_thresh; unsigned int start_ts; if (!inet_csk(sk)->icsk_retransmits) return false; if (unlikely(!tcp_sk(sk)->retrans_stamp)) start_ts = TCP_SKB_CB(tcp_write_queue_head(sk))->when; else start_ts = tcp_sk(sk)->retrans_stamp; linear_backoff_thresh = (TCP_RTO_MAX/TCP_RTO_MIN); if (boundary <= linear_backoff_thresh) timeout = ((2 << boundary) - 1) * TCP_RTO_MIN; else timeout = ((2 << linear_backoff_thresh) - 1) * TCP_RTO_MIN + (boundary - linear_backoff_thresh) * TCP_RTO_MAX; return (tcp_time_stamp - start_ts) >= timeout; }
上述源码中,boundary = 15,那么
TCP_RTO_MAX=120s,TCP_RTO_MIN=200ms
linear_backoff_thresh = ilog2(120s/200ms)=ilog2(600)=ilog2(1001011000二进制),ilog的实现为:
#define ilog2(n) ( \ __builtin_constant_p(n) ? ( \ (n) < 1 ? ____ilog2_NaN() : \ (n) & (1ULL << 63) ? 63 : \ ...... (n) & (1ULL << 9) ? 9 : \ /* 即(1001011000 & 1000000000)=1=>返回9 */ ...... )
因为boundary=15 > linear_backoff_thresh(9)因此,计算超时时间为:
timeout = ((2 << linear_backoff_thresh) - 1) * TCP_RTO_MIN +(boundary - linear_backoff_thresh) * TCP_RTO_MAX;
即(TCP_RTO_MIN=200ms,TCP_RTO_MAX=120s)
timeout = ((2 << 9 - 1) * 0.2s + (15 - 9) * 120s=924.6s
值得注意的是,由上面的代码逻辑,咱们tcp_retries=15指的并非重传15次,而是在rto初始值为200ms的状况下计算一个最终超时时间,实际重传次数和15并无直接的关系。
由上面的计算可知,
即在重传后的tcp_time_stamp(当前时间戳)- start_ts(第一次重传时间戳)>=924.6s的时候,即抛出异常,那么重传最终超时的下界就是924.6s,以下图所示:
咱们假设在第N次的时候tcp_time_stamp - start_ts=924.5999s时候进行超时断定,那么势必会进行下一次重传,并在924.5999+120=1044.5999s后超时,以下图所示:
那么,重传最终超时的上界就是1044.6s
最终结论:
重传最终超时的上下界是: [924.6,1044.6]
由上面代码可知,重传rto是不停的*2,一直到TCP_RTO_MAX(120s)为止,阅读linux代码可知,在笔者的线上状况下,初始rto=srtt>>3 + rttvar(TCP_RTO_MIN)(固然了,实际比这个复杂的多,计算暂以TCP_RTO_MIN代替),即初始rto=200ms+(一个计算出来的值)
笔者写了个模拟程序:
public class RetransSimulate { public static void timeOutCaclulate(double rto) { double initialRto = rto; double sum = 0; while (true) { sum += rto; if (sum > 924600) { break; } rto = rto * 2; rto = rto < 120000 ? rto : 120000; } // 以50ms做为偏差 if(Math.abs(sum - 939997) < 50){ System.out.println("rto="+initialRto+",timeout=" + sum); System.out.println(); } } public static void main(String[] args) { // rtt > 3 + rttval(这个计算有点复杂,这边能够直接用TCP_RTO_MIN作计算) // 以0.01ms为精度 double rto = 0.01 + 200;// 0.01 for random rtt > 3(初始扰动),200 for TCP_RTO_MIN // 最多计算到300 for (int i = 0; i < 10000; i++) { timeOutCaclulate(rto); rto += 0.01 ; } } }
发现距离线上真实表现超时时间最近的是:
rto=215.00999999998635,timeout=939955.229999986 rto=215.01999999998634,timeout=939965.459999986 rto=215.02999999998633,timeout=939975.689999986 rto=215.03999999998632,timeout=939985.919999986 rto=215.0499999999863,timeout=939996.1499999859 rto=215.0599999999863,timeout=940006.3799999859 rto=215.0699999999863,timeout=940016.609999986 rto=215.07999999998628,timeout=940026.839999986
这样,基本就能基本肯定在宕机的时候,用的rto是215了
题外话:
以前博客里面笔者想固然的将rto认为成rtt,致使以前的模拟程序在rto的初始值没有加上200ms,咱们同事在复现现场的时候,发现第一次重传包确实是200ms左右,和笔者的推理并不同。
使得笔者从新阅读了rto源码,发现其rto初始就要加上TCP_RTO_MIN(实际上是rttvar,细节有点复杂,在此略过不表),感谢那位同事,也向以前阅读过笔者此篇博客的人道歉,笔者犯了想固然的毛病。
因为到了800s/900s的时候,确定已经到了TCP_RTO_MAX(120s),因此咱们能够根据两个socket的报错时间计算一下机器响应的时间窗口。在这里为了简便分析,咱们忽略包在网络中的最长存活时间,以下图所示:
即机器开始应答的时间应该在22:32:11至22:32:22之间。
固然了,很难获取到机器真正开始应答的精确时间来证明笔者的计算。可是这个计算的意义在于若是二者的应答窗口没有交叠,那么笔者的上述推论就是错的,须要推倒重来。存在这个时间窗口,可让笔者的推测在逻辑上自洽。
将tcp_retries2减小。soTimeOut在这个中间件client代码里面因为其它问题不建议设置。
机器宕机虽然不讨人喜欢,可是观察宕机后线上的种种表现但是一次可贵机会,可以发现平时注意不到的坑。另外,定量分析其实蛮有意思的,尤为是种种数据都对上的时刻,挺有成就感_。
关注笔者公众号,获取更多干货文章: