详解MySQL连接挂死的原因
- 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
- 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
- 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。
详解MySQL连接挂死的原因
⽬录
⼀、背景
架构
问题现象
⼆、分析过程
连接池
陷⼊焦灼
拨开云雾见光明
三、解决⽅案
四、⼩结
⼀、背景
近期由测试反馈的问题有点多,其中关于系统可靠性测试提出的问题令⼈感到头疼,⼀来这类问题有时候属于“偶发”现象,难以在环境上快速复现;⼆来则是可靠性问题的定位链条有时候变得很长,极端情况下可能要从 A 服务追踪到 Z 服务,或者是从应⽤代码追溯到硬件层⾯。
本次分享的是⼀次关于 MySQL ⾼可⽤问题的定位过程,其中曲折颇多但问题本⾝却⽐较有些代表性,遂将其记录以供参考。
架构
⾸先,本系统以 MySQL 作为主要的数据存储部件。
整⼀个是典型的微服务架构(SpringBoot + SpringCloud),持久层则采⽤了如下⼏个组件:
mybatis,实现 SQL <-> Method 的映射
hikaricp,实现数据库连接池
mariadb-java-client,实现 JDBC 驱动
在 MySQL 服务端部分,后端采⽤了双主架构,前端以 keepalived 结合浮动IP(VIP)做⼀层⾼可⽤。
如下:
说明
MySQL 部署两台实例,设定为互为主备的关系。
为每台 MySQL 实例部署⼀个 keepalived 进程,由 keepalived 提供 VIP ⾼可⽤的故障切换。
实际上,keepalived 和 MySQL 都实现了容器化,⽽ VIP 端⼝则映射到 VM 上的nodePort 服务端⼝上。
业务服务⼀律使⽤ VIP 进⾏数据库访问。
Keepalived 是基于 VRRP 协议实现了路由层转换的,在同⼀时刻,VIP 只会指向其中的⼀个虚拟机(master)。
当主节点发⽣故障时,其他的 keepalived 会检测到问题并重新选举出新的 master,此后 VIP 将切换到另⼀个可⽤的 MySQL 实例节点上。
这样⼀来,MySQL 数据库就拥有了基础的⾼可⽤能⼒。
另外⼀点,Keepalived 还会对 MySQL 实例进⾏定时的健康检查,⼀旦发现 MySQL 实例不可⽤会将⾃⾝进程杀死,进⽽再触发 VIP 的切换动作。
问题现象
本次的测试⽤例也是基于虚拟机故障的场景来设计的:
持续以较⼩的压⼒向业务服务发起访问,随后将其中⼀台 MySQL 的容器实例(master)重启。
按照原有的评估,业务可能会产⽣很⼩的抖动,但其中断时间应该保持在秒级。
然⽽经过多次的测试后发现,在重启 MySQL 主节点容器之后,有⼀定的概率会出现业务却再也⽆法访问的情况!
⼆、分析过程
在发⽣问题之后,开发同学的第⼀反应是 MySQL 的⾼可⽤机制出了问题。
由于此前曾经出现过由于 keepalived 配置不当导致 VIP 未能及时切换的问题,因此对其已经有所戒备。
先是经过⼀通的排查,然后并没有找到 keepalived 任何配置上的⽑病。
然后在没有办法的情况下,重新测试了⼏次,问题⼜复现了。
紧接着,我们提出了⼏个疑点:
1.Keepalived 会根据 MySQL 实例的可达性进⾏判断,会不会是健康检查出了问题?
但在本次测试场景中,MySQL 容器销毁会导致 keepalived 的端⼝探测产⽣失败,这同样会导致 keepalived 失效。
如果 keepalived 也发⽣了中⽌,那么 VIP 应该能⾃动发⽣抢占。
⽽通过对⽐两台虚拟机节点的信息后,发现 VIP 的确发⽣了切换。
2. 业务进程所在的容器是否发⽣了⽹络不可达的问题?
尝试进⼊容器,对当前发⽣切换后的浮动IP、端⼝执⾏ telnet 测试,发现仍然能访问成功。
连接池
在排查前⾯两个疑点之后,我们只能将⽬光转向了业务服务的DB客户端上。
从⽇志上看,在产⽣故障的时刻,业务侧的确出现了⼀些异常,如下:
Unable to acquire JDBC Connection [n/a]
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:669) ~[HikariCP-2.7.9.jar!/:?]
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:183) ~[HikariCP-2.7.9.jar!/:?]
...
这⾥提⽰的是业务操作获取连接超时了(超过了30秒)。
那么,会不会是连接数不够⽤呢?
业务接⼊采⽤的是 hikariCP 连接池,这也是市⾯上流⾏度很⾼的⼀款组件了。
我们随即检查了当前的连接池配置,如下:
//最⼩空闲连接数
spring.datasource.hikari.minimum-idle=10
//连接池最⼤⼤⼩
spring.datasource.hikari.maximum-pool-size=50
//连接最⼤空闲时长
spring.datasource.hikari.idle-timeout=60000
//连接⽣命时长
spring.datasource.hikari.max-lifetime=1800000
//获取连接的超时时长
spring.datasource.hikari.connection-timeout=30000
其中注意到 hikari 连接池配置了 minimum-idle = 10,也就是说,就算在没有任何业务的情况下,连接池应该保证有 10 个连接。
更何况当前的业务访问量极低,不应该存在连接数不够使⽤的情况。
除此之外,另外⼀种可能性则可能是出现了“僵⼫连接”,也就是说在重启的过程中,连接池⼀直没有释放这些不可⽤的连接,最终造成没有可⽤连接的结果。
开发同学对"僵⼫链接"的说法深信不疑,倾向性的认为这很可能是来⾃于 HikariCP 组件的某个 BUG…
于是开始⾛读 HikariCP 的源码,发现应⽤层向连接池请求连接的⼀处代码如下:
public class HikariPool{
//获取连接对象⼊⼝
public Connection getConnection(final long hardTimeout) throws SQLException
{
suspendResumeLock.acquire();
final long startTime = currentTime();
try {
//使⽤预设的30s 超时时间
long timeout = hardTimeout;
do {
//进⼊循环,在指定时间内获取可⽤连接
//从 connectionBag 中获取连接
PoolEntry poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
if (poolEntry == null) {
break; // We timed out... break and throw exception
}
final long now = currentTime();
//连接对象被标记清除或不满⾜存活条件时,关闭该连接
if (poolEntry.isMarkedEvicted() || (elapsedMillis(stAccessed, now) > aliveBypassWindowMs && !isConnectionAlive(poolEntry.connection))) {
closeConnection(poolEntry, poolEntry.isMarkedEvicted() ? EVICTED_CONNECTION_MESSAGE : DEAD_CONNECTION_MESSAGE);
timeout = hardTimeout - elapsedMillis(startTime);
}
//成功获得连接对象
else {
metricsTracker.recordBorrowStats(poolEntry, startTime);
return poolEntry.createProxyConnection(leakTaskFactory.schedule(poolEntry), now);
}
} while (timeout > 0L);
//超时了,抛出异常
metricsTracker.recordBorrowTimeoutStats(startTime);
throw createTimeoutException(startTime);
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new SQLException(poolName + " - Interrupted during connection acquisition", e);
}
finally {
suspendResumeLock.release();
}
}
}
getConnection() ⽅法展⽰了获取连接的整个流程,其中 connectionBag 是⽤于存放连接对象的容器对象。
如果从 connectionBag 获得的连接不再满⾜存活条件,那么会将其⼿动关闭,代码如下:
void closeConnection(final PoolEntry poolEntry, final String closureReason)
{
//移除连接对象
if (connectionBag.remove(poolEntry)) {
final Connection connection = poolEntry.close();
//异步关闭连接
closeConnectionExecutor.execute(() -> {
quietlyCloseConnection(connection, closureReason);
//由于可⽤连接变少,将触发填充连接池的任务
if (poolState == POOL_NORMAL) {
fillPool();
}
});
}
}
注意到,只有当连接满⾜下⾯条件中的其中⼀个时,会被执⾏ close。
isMarkedEvicted() 的返回结果是 true,即标记为清除,如果连接存活时间超出最⼤⽣存时间(maxLifeTime),或者距离上⼀次使⽤超过了idleTimeout,会被定时任务标记为清除状态,清除状态的连接在获取的时候才真正 close。
500ms 内没有被使⽤,且连接已经不再存活,即 isConnectionAlive() 返回 false
由于我们把 idleTimeout 和 maxLifeTime 都设置得⾮常⼤,因此需重点检查 isConnectionAlive ⽅法中的判断,如下:
public class PoolBase{
//判断连接是否存活
boolean isConnectionAlive(final Connection connection)
{
try {
try {
//设置 JDBC 连接的执⾏超时
setNetworkTimeout(connection, validationTimeout);
final int validationSeconds = (int) Math.max(1000L, validationTimeout) / 1000;
//如果没有设置 TestQuery,使⽤ JDBC4 的校验接⼝
if (isUseJdbc4Validation) {
return connection.isValid(validationSeconds);
}
//使⽤ TestQuery(如 select 1)语句对连接进⾏探测
try (Statement statement = connection.createStatement()) {
if (isNetworkTimeoutSupported != TRUE) {
setQueryTimeout(statement, validationSeconds);
}
statement.execute(config.getConnectionTestQuery());
}
}
finally {
setNetworkTimeout(connection, networkTimeout);
if (isIsolateInternalQueries && !isAutoCommit) {
connection.rollback();
}
}
return true;
}
catch (Exception e) {
//发⽣异常时,将失败信息记录到上下⽂
lastConnectionFailure.set(e);
logger.warn("{} - Failed to validate connection {} ({}). Possibly consider using a shorter maxLifetime value.",
poolName, connection, e.getMessage());
return false;
}
}
}
我们看到,在PoolBase.isConnectionAlive ⽅法中对连接执⾏了⼀系列的探测,如果发⽣异常还会将异常信息记录到当前的线程上下⽂中。
随后,在 HikariPool 抛出异常时会将最后⼀次检测失败的异常也⼀同收集,如下:
private SQLException createTimeoutException(long startTime)
{
logPoolState("Timeout failure ");
metricsTracker.recordConnectionTimeout();
String sqlState = null;
//获取最后⼀次连接失败的异常
final Throwable originalException = getLastConnectionFailure();
if (originalException instanceof SQLException) {
sqlState = ((SQLException) originalException).getSQLState();
}
//抛出异常
final SQLException connectionException = new SQLTransientConnectionException(poolName + " - Connection is not available, request timed out after " + elapsedMillis(startTime) + "ms.", sqlState, originalException); if (originalException instanceof SQLException) {
connectionException.setNextException((SQLException) originalException);
}
return connectionException;
}
这⾥的异常消息和我们在业务服务中看到的异常⽇志基本上是吻合的,即除了超时产⽣的 “Connection is not available, request timed out after xxxms” 消息之外,⽇志中还伴随输出了校验失败的信息:
Caused by: java.sql.SQLException: Connection.setNetworkTimeout cannot be called on a closed connection
at org.mariadb.jdbc.internal.util.exceptions.ExceptionMapper.getSqlException(ExceptionMapper.java:211) ~[mariadb-java-client-2.2.6.jar!/:?]
at org.mariadb.jdbc.MariaDbConnection.setNetworkTimeout(MariaDbConnection.java:1632) ~[mariadb-java-client-2.2.6.jar!/:?]
at com.zaxxer.hikari.pool.PoolBase.setNetworkTimeout(PoolBase.java:541) ~[HikariCP-2.7.9.jar!/:?]
at com.zaxxer.hikari.pool.PoolBase.isConnectionAlive(PoolBase.java:162) ~[HikariCP-2.7.9.jar!/:?]
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:172) ~[HikariCP-2.7.9.jar!/:?]
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:148) ~[HikariCP-2.7.9.jar!/:?]
at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128) ~[HikariCP-2.7.9.jar!/:?]
到这⾥,我们已经将应⽤获得连接的代码⼤致梳理了⼀遍,整个过程如下图所⽰:
从执⾏逻辑上看,连接池的处理并没有问题,相反其在许多细节上都考虑到位了。
在对⾮存活连接执⾏ close 时,同样调⽤了 removeFromBag 动作将其从连接池中移除,因此也
不应该存在僵⼫连接对象的问题。
那么,我们之前的推测应该就是错误的!
陷⼊焦灼
在代码分析之余,开发同学也注意到当前使⽤的 hikariCP 版本为 3.4.5,⽽环境上出问题的业务服务却是 2.7.9 版本,这仿佛预⽰着什么… 让我们再次假设 hikariCP 2.7.9 版本存在某种未知的 BUG,导致了问题的产⽣。
为了进⼀步分析连接池对于服务端故障的⾏为处理,我们尝试在本地机器上进⾏模拟,这⼀次使⽤了 hikariCP 2.7.9 版本进⾏测试,并同时将 hikariCP 的⽇志级别设置为DEBUG。
模拟场景中,会由由本地应⽤程序连接本机的 MySQL 数据库进⾏操作,步骤如下:
1. 初始化数据源,此时连接池 min-idle 设置为 10;
2. 每隔50ms 执⾏⼀次SQL操作,查询当前的元数据表;
3. 将 MySQL 服务停⽌⼀段时间,观察业务表现;
4. 将 MySQL 服务重新启动,观察业务表现。
最终产⽣的⽇志如下:
//初始化过程,建⽴10个连接
DEBUG -HikariPool.logPoolState - Pool stats (total=1, active=1, idle=0, waiting=0)
DEBUG -HikariPool$PoolEntryCreator.call- Added connection MariaDbConnection@71ab7c09
DEBUG -HikariPool$PoolEntryCreator.call- Added connection MariaDbConnection@7f6c9c4c
DEBUG -HikariPool$PoolEntryCreator.call- Added connection MariaDbConnection@7b531779
...
DEBUG -HikariPool.logPoolState- After adding stats (total=10, active=1, idle=9, waiting=0)
//执⾏业务操作,成功
execute statement: true
test time -------1
execute statement: true
test time -------2
...
//停⽌MySQL
...
//检测到⽆效连接
WARN -PoolBase.isConnectionAlive - Failed to validate connection MariaDbConnection@9225652 ((conn=38652)
Connection.setNetworkTimeout cannot be called on a closed connection). Possibly consider using a shorter maxLifetime value.
WARN -PoolBase.isConnectionAlive - Failed to validate connection MariaDbConnection@71ab7c09 ((conn=38653)
Connection.setNetworkTimeout cannot be called on a closed connection). Possibly consider using a shorter maxLifetime value.
//释放连接
DEBUG -PoolBase.quietlyCloseConnection(PoolBase.java:134) - Closing connection MariaDbConnection@9225652: (connection is dead)
DEBUG -PoolBase.quietlyCloseConnection(PoolBase.java:134) - Closing connection MariaDbConnection@71ab7c09: (connection is dead)
//尝试创建连接失败
DEBUG -HikariPool.createPoolEntry - Cannot acquire connection from data source
java.sql.SQLNonTransientConnectionException: Could not connect to address=(host=localhost)(port=3306)(type=master) :
Socket fail to connect to host:localhost, port:3306. Connection refused: connect
Caused by: java.sql.SQLNonTransientConnectionException: Socket fail to connect to host:localhost, port:3306. Connection refused: connect
at internal.util.exceptions.ExceptionFactory.createException(ExceptionFactory.java:73) ~[mariadb-java-client-2.6.0.jar:?]
...
//持续失败.. 直到MySQL重启
//重启后,⾃动创建连接成功
DEBUG -HikariPool$PoolEntryCreator.call -Added connection MariaDbConnection@42c5503e
DEBUG -HikariPool$PoolEntryCreator.call -Added connection MariaDbConnection@695a7435
//连接池状态,重新建⽴10个连接
DEBUG -HikariPool.logPoolState(HikariPool.java:421) -After adding stats (total=10, active=1, idle=9, waiting=0)
//执⾏业务操作,成功(已经⾃愈)
execute statement: true
从⽇志上看,hikariCP 还是能成功检测到坏死的连接并将其踢出连接池,⼀旦 MySQL 重新启动,业务操作⼜能⾃动恢复成功了。
根据这个结果,基于 hikariCP 版本问题的设想也再次落空,研发同学再次陷⼊焦灼。
拨开云雾见光明
多⽅⾯求证⽆果之后,我们最终尝试在业务服务所在的容器内进⾏抓包,看是否能发现⼀些蛛丝马迹。
进⼊故障容器,执⾏tcpdump -i eth0 tcp port 30052进⾏抓包,然后对业务接⼝发起访问。
此时令⼈诡异的事情发⽣了,没有任何⽹络包产⽣!⽽业务⽇志在 30s 之后也出现了获取连接失败的异常。
我们通过 netstat 命令检查⽹络连接,发现只有⼀个 ESTABLISHED 状态的 TCP 连接。
也就是说,当前业务实例和 MySQL 服务端是存在⼀个建好的连接的,但为什么业务还是报出可⽤连接呢?
推测可能原因有⼆:
该连接被某个业务(如定时器)⼀直占⽤。
该连接实际上还没有办法使⽤,可能处于某种僵死的状态。
对于原因⼀,很快就可以被推翻,⼀来当前服务并没有什么定时器任务,⼆来就算该连接被占⽤,按照连接池的原理,只要没有达到上限,新的业务请求应该会促使连接池进⾏新连接的建⽴,那么⽆论是从 netstat 命令检查还是 tcpdump 的结果来看,不应该⼀直是只有⼀个连接的状况。
那么,情况⼆的可能性就很⼤了。
带着这个思路,继续分析 Java 进程的线程栈。
执⾏ kill -3 pid 将线程栈输出后分析,果不其然,在当前 thread stack 中发现了如下的条⽬:
"HikariPool-1 connection adder" #121 daemon prio=5 os_prio=0 tid=0x00007f1300021800 nid=0xad runnable [0x00007f12d82e5000]
ng.Thread.State: RUNNABLE
at .SocketInputStream.socketRead0(Native Method)
at .SocketInputStream.socketRead(SocketInputStream.java:116)
at .SocketInputStream.read(SocketInputStream.java:171)
at .SocketInputStream.read(SocketInputStream.java:141)
at java.io.FilterInputStream.read(FilterInputStream.java:133)
at org.mariadb.jdbc.internal.io.input.ReadAheadBufferedStream.fillBuffer(ReadAheadBufferedStream.java:129)
at org.mariadb.jdbc.internal.io.input.ReadAheadBufferedStream.read(ReadAheadBufferedStream.java:102)
- locked <0x00000000d7f5b480> (a org.mariadb.jdbc.internal.io.input.ReadAheadBufferedStream)
at org.mariadb.jdbc.internal.io.input.StandardPacketInputStream.getPacketArray(StandardPacketInputStream.java:241)
at org.mariadb.jdbc.internal.io.input.StandardPacketInputStream.getPacket(StandardPacketInputStream.java:212)
at .read.ReadInitialHandShakePacket.<init>(ReadInitialHandShakePacket.java:90)
at org.mariadb.jdbc.internal.protocol.AbstractConnectProtocol.createConnection(AbstractConnectProtocol.java:480)
at org.mariadb.jdbc.internal.protocol.AbstractConnectProtocol.connectWithoutProxy(AbstractConnectProtocol.java:1236)
at org.mariadb.jdbc.internal.util.Utils.retrieveProxy(Utils.java:610)
at org.mariadb.jdbc.MariaDbConnection.newConnection(MariaDbConnection.java:142)
at org.mariadb.jdbc.Driver.connect(Driver.java:86)
at com.zaxxer.hikari.util.DriverDataSource.getConnection(DriverDataSource.java:138)
at com.zaxxer.hikari.pool.PoolBase.newConnection(PoolBase.java:358)
at com.zaxxer.hikari.pool.PoolBase.newPoolEntry(PoolBase.java:206)
at com.zaxxer.hikari.pool.HikariPool.createPoolEntry(HikariPool.java:477)
这⾥显⽰HikariPool-1 connection adder这个线程⼀直处于 socketRead 的可执⾏状态。
从命名上看该线程应该是 HikariCP 连接池⽤于建⽴连接的任务线程,socket 读操作则来⾃于 MariaDbConnection.newConnection() 这个⽅法,即 mariadb-java-client 驱动层建⽴ MySQL 连接的⼀个操作,其中 ReadInitialHandShakePacket 初始化则属于 MySQL 建链协议中的⼀个环节。
简⽽⾔之,上⾯的线程刚好处于建链的⼀个过程态,关于 mariadb 驱动和 MySQL 建链的过程⼤致如下:
MySQL 建链⾸先是建⽴ TCP 连接(三次握⼿),客户端会读取 MySQL 协议的⼀个初始化握⼿消息包,内部包含 MySQL 版本号,鉴权算法等等信息,之后再进⼊⾝份鉴权的环节。
这⾥的问题就在于 ReadInitialHandShakePacket 初始化(读取握⼿消息包)⼀直处于 socket read 的⼀个状态。
如果此时 MySQL 远端主机故障了,那么该操作就会⼀直卡住。
⽽此时的连接虽然已经建⽴(处于 ESTABLISHED 状态),但却⼀直没能完成协议握⼿和后⾯的⾝份鉴权流程,即该连接只能算⼀个半成品(⽆法进⼊ hikariCP 连接池的列表中)。
从故障服务的 DEBUG ⽇志也可以看到,连接池持续是没有可⽤连接的,如下:
DEBUG HikariPool.logPoolState --> Before cleanup stats (total=0, active=0, idle=0, waiting=3)
另⼀个需要解释的问题则是,这样⼀个 socket read 操作的阻塞是否就造成了整个连接池的阻塞呢?
经过代码⾛读,我们再次梳理了 hikariCP 建⽴连接的⼀个流程,其中涉及到⼏个模块:
HikariPool,连接池实例,由该对象连接的获取、释放以及连接的维护。
ConnectionBag,连接对象容器,存放当前的连接对象列表,⽤于提供可⽤连接。
AddConnectionExecutor,添加连接的执⾏器,命名如 “HikariPool-1 connection adder”,是⼀个单线程的线程池。
PoolEntryCreator,添加连接的任务,实现创建连接的具体逻辑。
HouseKeeper,内部定时器,⽤于实现连接的超时淘汰、连接池的补充等⼯作。
HouseKeeper 在连接池初始化后的 100ms 触发执⾏,其调⽤ fillPool() ⽅法完成连接池的填充,例如 min-idle 是10,那么初始化就会创建10个连接。
ConnectionBag 维护了当前连接对象的列表,该模块还维护了请求连接者(waiters)的⼀个计数器,⽤于评估当前连接数的需求。
其中,borrow ⽅法的逻辑如下:
public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
{
// 尝试从 thread-local 中获取
final List<Object> list = threadList.get();
for (int i = list.size() - 1; i >= 0; i--) {
...
}
// 计算当前等待请求的任务
final int waiting = waiters.incrementAndGet();
try {
for (T bagEntry : sharedList) {
if (pareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
//如果获得了可⽤连接,会触发填充任务
if (waiting > 1) {
listener.addBagItem(waiting - 1);
}
return bagEntry;
}
}
//没有可⽤连接,先触发填充任务
listener.addBagItem(waiting);
//在指定时间内等待可⽤连接进⼊
timeout = timeUnit.toNanos(timeout);
do {
final long start = currentTime();
final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
if (bagEntry == null || pareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
timeout -= elapsedNanos(start);
} while (timeout > 10_000);
return null;
}
finally {
waiters.decrementAndGet();
}
}
注意到,⽆论是有没有可⽤连接,该⽅法都会触发⼀个 listener.addBagItem() ⽅法,HikariPool 对该接⼝的实现如下:
public void addBagItem(final int waiting)
{
final boolean shouldAdd = waiting - addConnectionQueueReadOnlyView.size() >= 0; // Yes, >= is intentional.
if (shouldAdd) {
//调⽤ AddConnectionExecutor 提交创建连接的任务
addConnectionExecutor.submit(poolEntryCreator);
}
else {
logger.debug("{} - Add connection elided, waiting {}, queue {}", poolName, waiting, addConnectionQueueReadOnlyView.size());
}
}
PoolEntryCreator 则实现了创建连接的具体逻辑,如下:
public class PoolEntryCreator{
@Override
public Boolean call()
{
long sleepBackoff = 250L;
//判断是否需要建⽴连接
while (poolState == POOL_NORMAL && shouldCreateAnotherConnection()) {
//创建 MySQL 连接
final PoolEntry poolEntry = createPoolEntry();
if (poolEntry != null) {
//建⽴连接成功,直接返回。
connectionBag.add(poolEntry);
logger.debug("{} - Added connection {}", poolName, poolEntry.connection);
if (loggingPrefix != null) {
logPoolState(loggingPrefix);
}
return Boolean.TRUE;
}
...
}
// Pool is suspended or shutdown or at max size
return Boolean.FALSE;
}
}
由此可见,AddConnectionExecutor 采⽤了单线程的设计,当产⽣新连接需求时,会异步触发 PoolEntryCreator 任务进⾏补充。
其中 PoolEntryCreator. createPoolEntry() 会完成MySQL 驱动连接建⽴的所有事情,⽽我们的情况则恰恰是MySQL 建链过程产⽣了永久性阻塞。
因此⽆论后⾯怎么获取连接,新来的建链任务都会⼀直排队等待,这便导致了业务上⼀直没有连接可⽤。
下⾯这个图说明了 hikariCP 的建链过程:
好了,让我们在回顾⼀下前⾯关于可靠性测试的场景:
⾸先,MySQL 主实例发⽣故障,⽽紧接着 hikariCP 则检测到了坏的连接(connection is dead)并将其释放,在释放关闭连接的同时⼜发现连接数需要补充,进⽽⽴即触发了新的建链请求。
⽽问题就刚好出在这⼀次建链请求上,TCP 握⼿的部分是成功了(客户端和 MySQL VM 上 nodePort 完成连接),但在接下来由于当前的 MySQL 容器已经停⽌(此时 VIP 也切换到了另⼀台 MySQL 实例上),因此客户端再也⽆法获得原 MySQL 实例的握⼿包响应(该握⼿属于MySQL应⽤层的协议),此时便陷⼊了长时间的阻塞式 socketRead 操作。
⽽建链请求任务恰恰好采⽤了单线程运作,进⼀步则导致了所有业务的阻塞。
三、解决⽅案
在了解了事情的来龙去脉之后,我们主要考虑从两⽅⾯进⾏优化:
优化⼀,增加 HirakiPool 中 AddConnectionExecutor 线程的数量,这样即使第⼀个线程出现挂死,还有其他的线程能参与建链任务的分配。
优化⼆,出问题的 socketRead 是⼀种同步阻塞式的调⽤,可通过 SO_TIMEOUT 来避免长时间挂死。
对于优化点⼀,我们⼀致认为⽤处并不⼤,如果连接出现了挂死那么相当于线程资源已经泄露,对服务后续的稳定运⾏⼗分不利,⽽且 hikariCP 在这⾥也已经将其写死了。
因此关键的⽅案还是避免阻塞式的调⽤。
查阅了 mariadb-java-client 官⽅⽂档后,发现可以在 JDBC URL 中指定⽹络IO 的超时参数,如下:
如描述所说的,socketTimeout 可以设置 socket 的 SO_TIMEOUT 属性,从⽽达到控制超时时间的⽬的。
默认是 0,即不超时。
我们在 MySQL JDBC URL 中加⼊了相关的参数,如下:
spring.datasource.url=jdbc:mysql://10.0.71.13:33052/appdb?socketTimeout=60000&connectTimeout=30000&serverTimezone=UTC
此后对 MySQL 可靠性场景进⾏多次验证,发现连接挂死的现象已经不再出现,此时问题得到解决。
四、⼩结
本次分享了⼀次关于 MySQL 连接挂死问题排查的⼼路历程,由于环境搭建的⼯作量巨⼤,⽽且该问题复现存在偶然性,整个分析过程还是有些坎坷的(其中也踩了坑)。
的确,我们很容易被⼀些表⾯的现象所迷惑,⽽觉得问题很难解决时,更容易带着偏向性思维去处理问题。
例如本例中曾⼀致认为连接池出现了问题,但实际上却是由于 MySQL JDBC 驱动(mariadb driver)的⼀个不严谨的配置所导致。
从原则上讲,应该避免⼀切可能导致资源挂死的⾏为。
如果我们能在前期对代码及相关配置做好充分的排查⼯作,相信 996 就会离我们越来越远。
以上就是详解MySQL连接挂死的原因的详细内容,更多关于MySQL连接挂死的原因的资料请关注其它相关⽂章!。