离奇问题,网络故障恢复后,无法重连到数据库?
问题现象
周末生产环境出现了一个奇怪的问题,部署在 k8s 容器中的 SpringBoot 应用连接到数据库的交换机出现了短暂的故障,交换机故障恢复后,查询数据库的接口还是无法提供服务,多次尝试后,日志显示的异常仍然是"Connection is not available",难道是网络故障还是没恢复?
为了排除网络问题,我们使用了 telnet
,结果显示网络是通的。接着我们用 netstat
查看与数据库的连接状态,发现状态显示为 ESTABLISHED
。这种状态意味着连接已经成功建立,但奇怪的是,连接数量只有一个,而我们配置的最小活跃连接数应为 10 个。为了尽快恢复业务,我们只能重启应用服务。
事后,我们检查了所有通过该故障交换机连接数据库的应用程序,发现有些应用出现问题后虽然没有重启,但分别在 20 分钟和 120 分钟后自动恢复了。
为什么短暂的几秒钟网络故障会导致部分应用的数据库连接池长时间无法恢复?带着这个疑问,我们决定在测试环境中复现这个问题,并寻找解决方案。
问题复现
通过对生产环境自动恢复应用的梳理和分析,我们发现了一个规律,在 20 分钟后恢复的应用全部署在虚拟机上,而在 120 分钟后恢复的应用全都部署在容器中。通过这两个数字,于是我们猜想,这个恢复时间是否和操作系统的 TCP 连接最大空闲时间有关系,这个配置在 linux 系统中就是tcp_keepalive_time
这个系统参数,于是我们查询了虚拟机和容器的 tcp_keepalive_time
系统配置:
结果显示,虚拟机的 tcp_keepalive_time
设置为 1200 秒,而容器中则为 7200 秒。换算后,分别对应 20 分钟和 120 分钟,这与我们的猜想是一致的。于是我们进一步推测,尽管应用与数据库的连接显示为 ESTABLISHED
状态,但实际上在出现故障的服务器中,这些 TCP 连接已经不可用,无法向数据库发送数据。
为了验证这一点,我们在测试环境中模拟了故障,通过手动断开交换机与数据库的连接来模拟故障,希望能够复现问题。然而,每次故障恢复后,应用与数据库的 TCP 连接都迅速恢复到了 10 个,生产环境中的问题无法复现。
接下来,我们将注意力转向了 JDBC 建立数据库连接的过程。Spring Boot 的数据库连接池通常使用 JDBC 连接数据库,建立连接需要调用 getConnection()
方法。该方法首先建立 TCP 连接,连接成功后状态显示为 ESTABLISHED
,然后进行认证。问题可能出现在认证过程中。为了模拟这种情况,我们使用了 iptables
工具。
iptables
是 Linux 操作系统中的一个用户空间工具,用于配置 Linux 内核中的 iptables 防火墙。它允许系统管理员定义规则来控制网络流量的进出、转发以及网络地址转换 (NAT)。iptables 通常用于安全性设置、防止未经授权的访问、管理网络流量等。我们想利用这个工具,去控制 TCP 连接的数据包的进出。具体来说,我们要做的就是去控制 TCP 协议三次握手以外的数据包。于是我们回顾了一下 TCP 三次握手的流程:
依据这个流程,我们就能利用 iptables
屏蔽了 TCP 三次握手以外的数据包:
这四个命令的大致意思是:允许客户端和服务器(端口为 2883)之间的 SYN 和 SYN-ACK 数据包通过,这分别对应 TCP 握手的前两次握手。允许客户端发送 ACK 数据包给服务器,完成 TCP 的第三次握手。同时丢弃所有来自端口 2883 的 ACK 数据包。通过这种方式,我们成功模拟了 TCP 握手成功但认证报文无法收到响应的情况。
通过这个方法注入故障,生产环境中的问题终于复现了!注入故障后,应用与数据库的 TCP 连接数量逐渐减少到一个,且状态显示为 ESTABLISHED
,与生产环境现象符合。随后我们移除了故障,但应用依然没有恢复,直到经过 tcp_keepalive_time
后,数据库连接数才恢复到最小活跃连接数。问题成功复现。
最后一个问题
为什么故障恢复后,连接池没有自动恢复,而且 TCP 连接数会减少到 1 个?我们查阅了 HikariCP 连接池的源码,发现填充连接的线程是一个单线程:
在复现过程中,我们通过 jstack
查看应用的堆栈信息,发现该线程阻塞在 getConnection()
方法,处于 socketRead
状态,addConnectionExecutor
线程池无法接受新的数据库连接任务,导致再也不会有新的连接被建立,已经建立的连接因空闲时间超时被关闭,最后仅剩下这个“问题”连接。直到经历了tcp_keepalive_time
时间后,线程被释放,连接池才能重新开始建立连接的任务,这也就解释了为什么网络故障时间很短暂,但是应用连接池却等了很长时间才能恢复 。
应用无法恢复的问题找到了!我们还测试了 Druid 连接池,发现它也存在同样的问题,但由于我们使用的是 HikariCP,所以没有进一步研究 Druid 的源码。
解决方案
首先,我们要改 HikariCP 的源码的,简单的把 addConnectionExecutor 的线程数调大?当然不行,故障时间长了,问题还是会出现。
我们决定将真正建立连接的代码交由一个单独的线程去处理,并配置了超时时间。一旦超时,强制销毁这个线程,以避免 addConnectionExecutor
被阻塞。在 PoolBase
类中的 newConnection
方法里进行了如下修改:
虽然这可能导致频繁重建线程,似乎会影响性能,但实际上只要不出现故障,性能不会受到影响,因为线程数仍然是 1。如果出现故障,这种性能损耗也是可以接受的。通过这种改动,addConnectionExecutor
就不会被阻塞了。
这种方案存在一个风险:如果 tcp_keepalive_time
过长且网络故障持续存在,虽然建立连接的任务被取消了,但是操作系统仍然可能会成功建立无用的 TCP 连接,数据库连接池在不断尝试填充连接的过程中,会重复这个过程,最终导致无用 TCP 连接的数量不断增加,直到占满服务器的端口。然而,如果tcp_keepalive_time
设置合适,这些无用连接会在经历tcp_keepalive_time
的时间后被操作系统强制关闭,如果网络故障长时间不恢复,最终无用连接数也会在tcp_keepalive_time
窗口期内维持一个定值,因此问题不会太大。
最后一点是关于 Kubernetes 的配置调整,在 Kubernetes 的 pod 中,容器内的 tcp_keepalive_time
配置不会继承宿主机的默认设置,默认是 7200 秒。如果没有高权限(如我们的生产环境),无法在 Dockerfile 中修改配置。这时可以通过 initContainers
在 deployment 的 yaml 文件中添加如下配置:
需要注意的是,initContainers
必须与 containers
配置在同一级别。在 deployment 的 yaml 文件中,initContainers
用于在主容器启动之前运行初始化任务。
确认了解决方案后,我们在虚拟机和容器上都做了多次验证。在注入故障后,操作系统和数据库的连接数的确会持续增加,但是经过tcp_keepalive_time
后,这些无用且状态为
ESTABLISHED
的 TCP 连接确实是会被关闭,然后会有新的连接会被建立,最终在tcp_keepalive_time
窗口期内维持一个定值,与我们预期一致。随后我们移除了故障,应用与数据库的正常连接会立刻被建立,应用接口也能正常提供服务,最终可用的数据库连接恢复到配置的最小活跃连接数,无用连接也被操作系统关闭。至此,这个问题基本上解决了!
版权声明: 本文为 InfoQ 作者【中原银行】的原创文章。
原文链接:【http://xie.infoq.cn/article/eab9ab9a20f4a3a33f866d684】。文章转载请联系作者。
评论