离奇问题,网络故障恢复后,无法重连到数据库?

问题现象

周末生产环境出现了一个奇怪的问题,部署在 k8s 容器中的 SpringBoot 应用连接到数据库的交换机出现了故障,交换机的故障恢复后,查询数据库的接口还是无法提供服务,试了很多次,日志中报出的异常依然是 Connection is not available,难道是网络还是没回复?

网络可能不通?那我们就 telnet 一下,网络是没问题的。最后我们使用 netstat 看一下和数据库的连接状态,状态是 ‌ESTABLISHED,这个状态表示已经成功建立连接,但是连接只有一个,与我们配置的最小活跃连接数 10 的数量不相符,此时为了业务能尽快恢复,只好重启了应用服务。

事后,我们又查看了所有通过这个故障交换机连接数据库的应用,有的没有发生问题,有的没有经过重启,分别在 20 分钟和 120 分钟之后自动恢复了,于是我们开始在测试环境想办法复现这个问题并寻找解决方案。

问题复现

经过梳理,我们发现 20 分钟恢复的应用都部署在虚拟机上,120 分钟恢复的应用都部署在容器上,根据这两个数字,于是很快联想到 tcp_keepalive_time 这个系统配置,我们查看了虚机和容器的这个参数配置:

1
sysctl net.ipv4.tcp_keepalive_time

结果是,虚机是 1200,容器是 7200,换算一下,刚好是 20 分钟和 120 分钟,于是我们猜测,这个状态是 ESTABLISHED 的连接其实就是不可用的,即使连上了,我们也无法向数据库发送报文。

随后我们着手复现这个现象,并在测试环境的交换机中通过手动断开与数据库的连接来模拟故障,但是每次的试验结果都是,在故障恢复以后,应用和数据库的 TCP 连接立马恢复到 10 个,也就是最小活跃连接数,生产问题根本无法复现。

接下来,我们把目光投向 JDBC 建立数据库连接的过程,SpringBoot 的数据库连接池,毫无例外的都是使用 JDBC 连接数据库的,建立连接就要调用 getConnection()方法。这个方法,首先会建立 TCP 连接,TCP 建立连接后,连接就会保持在 ESTABLISHED 状态,然后发送认证信息,有没有可能问题就出在认证过程?如何模拟这种情况,我们用到了 iptables 这个工具。

iptables 是 Linux 操作系统中的一个用户空间工具,用于配置 Linux 内核中的 iptables 防火墙。它允许系统管理员定义规则来控制网络流量的进出、转发以及网络地址转换 (NAT)。iptables 通常用于安全性设置、防止未经授权的访问、管理网络流量等。我们回顾一下 TCP 三次握手的流程:

我们利用 iptables 屏蔽除 TCP 以外的三次握手,就能够达到目的了:

1
2
3
4
iptables -A OUTPUT -p tcp --dport 2883 --tcp-flags SYN SYN -j ACCEPT
iptables -A INPUT -p tcp --sport 2883 --tcp-flags SYN,ACK SYN,ACK -j ACCEPT
iptables -A OUTPUT -p tcp --dport 2883 --tcp-flags ACK ACK -j ACCEPT
iptables -A INPUT -p tcp --sport 2883 --tcp-flags ACK ACK -j DROP

这四个命令,大致意思就是允许客户端和服务端(端口为 2883)标志位为(SYN)、(SYN,ACK)的通讯包,分别对应 TCP 握手的前两次握手。允许客户端发送向服务端发送标志位(ACK)的通讯包,对应 TCP 握手的第三次握手。丢弃所有来自端口 2883 的标志位为( ACK)的通讯包,这样我们就达到了允许 TCP 成功握手,但是认证报文无法收到响应的问题。

通过这个方式注入故障,生产的问题终于复现了!注入这个故障后,和数据库的 TCP 连接逐渐降低到 1 个,且状态是 ESTABLISHED,随后我们移除故障,应用仍然没有恢复,经过了 tcp_keepalive_time 后,和数据库的连接数恢复到最小活跃连接数,问题就此复现。

最后一个问题

为什么故障恢复后,连接池不会恢复,我们翻看了 HikariCP 连接池的源码后发现,填充连接的线程,是一个单线程:

1
2
3
4
5
6
7
8
9
private synchronized void fillPool() {
final int connectionsToAdd = Math.min(config.getMaximumPoolSize() - getTotalConnections(), config.getMinimumIdle() - getIdleConnections()) -
addConnectionQueueReadOnlyView.size();
if (connectionsToAdd <= 0) logger.debug("{} - Fill pool skipped, pool is at sufficient level.", poolName);
// addConnectionExecutor 线程池里核心线程数和最大线程数都是 1
for (int i = 0; i < connectionsToAdd; i++) {
addConnectionExecutor.submit((i < connectionsToAdd - 1) ? poolEntryCreator : postFillPoolEntryCreator);
}
}

复现过程中,我们通过 jstack 查看应用堆栈信息,该线程卡在 getConnection(),处于 socketRead 状态,这样就再也不会有新的连接建立了,应用不会恢复的问题也找到了!后来我们测试了 Druid 连接池,也存在这个问题,由于我们用的是 HikariCP,Druid 的源码我们就没有研究了。

解决方案

首先,我们要改 HikariCP 的源码的,简单的把 addConnectionExecutor 的线程数调大?当然不行,故障时间长了,问题还是会出现。

我们找到了这个任务里真正建立连接的代码,把他交给一个线程去处理,并配置了超时时间,超过这个时间,就强制销毁这个线程,这样 addConnectionExecutor 就不会阻塞了。在 PoolBase 里的 newConnection 方法里

1
2
3
4
5
6
7
8
9
10
11
// doConnectionExecutor 是新定义的线程池,核心线程数和最大线程数都是 1
Future<Connection> connectionFuture = this.doConnectionExecutor.submit(this.newConnectionTask);
try {
// get 设置超时时间
connection = connectionFuture.get(this.config.getConnectionTimeout(), MILLISECONDS);
} catch (TimeoutException e) {
// 取消任务
connectionFuture.cancel(true);
// 销毁并重置线程,其实就是调用 shutdownNow() 然后重建线程池
this.handleRecoveryThread();
}

这样看似会不断重建线程,影响性能,实际上,只要不出现故障,就没有影响,线程数还是 1,出现故障,这点性能也就无所谓了。这样改,addConnectionExecutor 就不会阻塞了。

这样有一个风险,如果 tcp_keepalive_time 过长且故障一直不恢复,会建立很多无用的连接,连接数会一直膨胀,把服务器端口占满。如果 tcp_keepalive_time 合适的话,这些无用连接都会被关闭,最终在 tcp_keepalive_time 窗口期内维持一个定值,问题也不会太大。

最后,k8s 的 tcp_keepalive_time 不会继承宿主机的这个配置,默认是 7200 秒,如果没有高权限,比如我们的生产环境,是没办法在 Dockerfile 里改的,这个时候需要用到 initContainers,在 deployment 的 yaml 文件中添加如下配置:

1
2
3
4
5
6
7
8
9
initContainers:
- name: sysctl-set
image: centos:7
command:
- sh
- "-c"
- sysctl -w net.ipv4.tcp_keepalive_time=1200
securityContext:
privileged: true

至此,这个问题基本上解决了!

本文作者: twl
由 iMaeGoo 代为发表

离奇问题,网络故障恢复后,无法重连到数据库?

https://www.imaegoo.com/2024/conn-pool-pro-issue/

作者

iMaeGoo

发布于

2024-08-21

更新于

2024-08-21

许可协议

CC BY 4.0

评论

微信二维码