前言
InfluxDB是当今最为流行的开源时序数据库,广泛应用于监控场景,因为监控数据的来源多样,InfluxDB的数据写入链路也具有一定的复杂性。本文将分享一次由网络状况不佳而触发的写入抖动问题的排查过程,并且深入分析其背后所涉及到的技术原理。
问题的出现
某用户反馈其InfluxDB实例的写入性能出现抖动,大量写入请求失败,从监控数据看也证实了用户反馈的问题,之前一直平稳的写入性能曲线出现了十分明显的抖动,甚至跌零,如下图所示:
出现此类问题,首先需要确认最近是否有变更,因为大多数软件问题都是由变更触发的。然而用户确认其客户端没有任何变更,而服务端这边也没有任何运维操作,所以问题就变得棘手,到底是什么原因导致的呢?
InfluxDB的写入控制
在介绍排查历程之前,先简单描述一下InfluxDB的写入流控机制,以便于读者理解。
如上图所示,InfluxDB的写入通过HTTP/1.1协议实现,并发处理的请求数是有限制的,由一个长度可配置的定长队列来控制,每个请求处理完成之后会将结果(成功或失败)返回给客户端。如果处理队列满了,写入请求会进入一个等待队列,按照FIFO方式进行服务。请求在等待队列中的等待是有timeout机制的,超过30s就会返回超时错误给客户端。这种模式很容易理解,大家可以想象为商场买东西时结账,有固定数量的收银台,客户排成一个队,每个收银台服务完一个客户后会接待队列中最前面的一个客户。
抽丝剥茧
收到用户反馈后,我们立刻展开了排查。
首先自然是分析日志,InfluxDB的HTTP访问日志记录了每个写请求的处理耗时,也就是从收到请求到返回response给客户端的时间。不出意外,从日志中发现了大量HTTP请求处理超时,也就是请求在等待队列中超过30s后返回timeout错误给客户端;实例监控也显示写入量下跌甚至跌零。由此判断,等待队列的消费(出队)能力已经很低甚至丧失,而请求处理模块是负责消费等待队列中的请求的,很可能是写请求的处理逻辑出了问题。
进一步分析日志,发现了一个略感意外的有趣现象,即有少量请求的处理时间长达数小时甚至几天,这显然是不正常的,这些请求是在从等待队列进入处理队列之后运行了几天时间才结束;因为等待队列的处理逻辑十分简单,超过30s就会返回了,不会出现出现超长的等待。为什么服务端处理一个请求会耗时几天?要直接回答这个问题显然要通读整个处理流程的代码,这不是一件容易的事。但是这些超长的请求处理日志为问题分析提供了另一个切入点。
通过对服务端网络连接进行分析,发现存在大量处于established状态的TCP连接。同用户沟通发现,这些连接的远端,也就是客户端程序所在的主机上,并没有看到对应的tcp连接信息。基于对tcp协议的了解,可以知道这些TCP连接已经变成了半开(half-open)连接(详细解释在下文会给出)。出现这么多的半开连接显然是不正常的,所以马上与用户沟通,了解其使用场景,发现其客户端较多,而且分布在不同地域,包括海外,而几个海外地址的网络连通性较差,测试发现丢包率甚至超过60%,这些丢包率高的客户端恰恰就是前文提到的tcp半开连接所对应的远端地址。另外一个重要信息是,客户端的http超时设置很短,只有2秒钟,超时就会断开连接,所以客户端断开连接后服务端并没有关闭对应的连接。
这个发现成了问题的突破口。如果服务端在半开连接上进行数据读取,是会阻塞的,而influxdb的http连接没有设置读取超时,所以阻塞几天时间是很可能的。
真相大白
有了突破口,就可以进一步排查验证,最终破解真相,下面是梳理出的问题爆发流程:
- InfluxDB服务端使用Go net/http库实现,当客户端发送一个请求到服务端,服务端在读取HTTP header之后会交付给请求处理模块,而此时HTTP body可能还没有全部发送过来,因为内核缓冲区可能无法接受全部body数据。
- 当写请求进入了处理队列,会读取 HTTP body,获取需要写入的数据。这里的读取逻辑有一个缺陷,就是没有设置超时!
- 问题的起点:当大量写入请求涌入,部分请求会进入等待队列,系统负载过高时,某些请求无法在2秒内处理完,这时客户端就会直接断开连接。
- 因为网络丢包率很高,客户端关闭TCP连接的FIN或者RST数据包有很高的概率会丢失,而一旦丢失就导致了服务端遗留了半开连接,即客户端已经释放了连接,而服务端依然在维护这个tcp连接。
- 连接上的请求从等待队列进入处理队列后,会从连接上读取http body,因为没有超时,这个读操作会阻塞。
- 一旦阻塞,就意味着处理队列的一个slot被占用了!
- 随着问题的积累(大约两周的时间), 越来越多的slot被占用,InfluxDB的处理能力逐步下降,更多的请求等待和超时,如下图所示
- 最终,处理队列被打满,无法处理任何新请求,所有的请求在等待队列中等待30s后返回超时错误给客户端。
问题的来龙去脉搞清楚了,修复方案也很简单,可以通过设置服务端的读取超时来避免长时间阻塞。
最后,有一个问题读者有兴趣可以思考下:为什么半开连接上的read()调用在阻塞几小时或者几天这些不等的时间后返回了?
TCP 半开连接和keepalive那些事
半开连接问题是TCP协议中比较常见的异常情况,其描述可以参考rfc793
An established connection is said to be "half-open" if one of the TCPs has closed or aborted the connection at its end without the knowledge of the other, or if the two ends of the connection have become desynchronized owing to a crash that resulted in loss of memory. Such connections will automatically become reset if an attempt is made to send data in either direction. However, half-open connections are expected to be unusual, and the recovery procedure is mildly involved.
半开连接的原因可能是远端的意外崩溃,比如主机掉电,或者网络故障,也有可能是恶意程序有意为之;无论如何,对于服务器而言,半开连接意味着资源消耗,因为内核需要维护tcp连接信息,所以需要一种机制来探测tcp连接的对端是否还活着。一般来说,设计网络应用时,应用层都会使用心跳机制来检测远端的状态,以确保在没有数据传输的情况下也能及时发现远端异常。
而keep-alive是TCP提供的,通过发送空数据包来验证连接可用性的机制。严格来说,keep-alive不是TCP协议的,但是大多数TCP的实现都支持这种机制。
对于Linux系统来说,一般都会开启。具体的keepalive配置参数可以从proc 文件获取到:
# cat /proc/sys/net/ipv4/tcp_keepalive_time 7200 # cat /proc/sys/net/ipv4/tcp_keepalive_intvl 75 # cat /proc/sys/net/ipv4/tcp_keepalive_probes 9
参数的含义如下:
tcp_keepalive_intvl (integer; default: 75; since Linux 2.4) The number of seconds between TCP keep-alive probes. tcp_keepalive_probes (integer; default: 9; since Linux 2.2) The maximum number of TCP keep-alive probes to send before giving up and killing the connection if no response is obtained from the other end. tcp_keepalive_time (integer; default: 7200; since Linux 2.2) The number of seconds a connection needs to be idle before TCP begins sending out keep-alive probes. Keep-alives are sent only when the SO_KEEPALIVE socket option is enabled. The default value is 7200 seconds (2 hours). An idle connection is terminated after approximately an additional 11 minutes (9 probes an interval of 75 seconds apart) when keep-alive is enabled. Note that underlying connection tracking mechanisms and application timeouts may be much shorter.
也就是说,如果一个tcp连接上有7200秒(2小时)没有数据传输,keepalive探测就会开启,并且每75秒进行一次探测,如果连续9次探测失败,就会关闭这个连接。这个keepalive配置是全局的,不能针对每个socket独立设置,灵活性不足,而且两个小时的间隔对一般应用来说有点过长了,所以应用层的心跳机制还是需要的。
需要注意的是,即使系统开启了tcp keepalive,一个tcp连接也需要显式的设置SO_KEEPALIVE参数才能真正开启keepalive!因为RFC1122中有如下描述:
4.2.3.6 TCP Keep-Alives
Implementors MAY include "keep-alives" in their TCP
implementations, although this practice is not universally
accepted. If keep-alives are included, the application MUST
be able to turn them on or off for each TCP connection, and
they MUST default to off.
InfluxDB的服务端没有对每个连接开启keep-alive,所以才会出现半开连接维持了很多天的现象。
写在最后
本文分享了一次因TCP半开连接导致,网络丢包触发的服务器问题,深入剖析了TCP keep-alive机制。
下面是广告时间。阿里云InfluxDB作为开源托管服务,在性能和稳定性方面做了大量优化,提供7*24的技术支持,是各种监控场景的绝佳存储方案。目前推出了首购一元体验活动,欢迎大家试用。