乐趣区

关于java:druid连接池引起的线程blocked

一、前言

  最近在捣鼓演示利用的时候发现一个 druid 连接池引起的线程 blocked 问题。先申明下,这个问题是 druid 1.1.23 版本之前的一个 bug,并不是说 druid 存在显著的性能问题。

  github 上其实曾经有相干的 issue:https://github.com/alibaba/dr…;也能够关注下 druid 各版本的 release 信息:https://github.com/alibaba/dr…。

  而我在发现这个问题的时候并不知道这其实是个曾经修复了的 bug,因而记录了一些剖析过程的线索,明天次要分享下问题排查的思路。

二、Let‘s Go

(一)重大的 Thread Blocked

  事件起源于筹备客户演示环境时的一轮压测(验证环境)。在压测过程中,发现有一个拜访数据库的接口响应工夫异样的高,尽管成心做了循环查库【一次 http 申请,10 次 MySQL 查问】的操作,然而对于只是进行简略查问的 SQL 来说,接口的响应工夫显得很不失常。

  1. 首先查看了下应用服务的资源应用状况,一切正常;
     
  2. 查看 jvm 运行状况。在热点办法【办法耗费 CPU 资源的状况】中,看到 zip 压缩相干的办法始终在耗费 CPU 资源时排在首位:把办法调用链开展,发现是类加载引起的;【能够必定,利用哪里必定有问题】

     
  3. 线程 dump,查看线程都在干什么,并看看类加载相干残缺的线程栈。在 thread dump 中发现了大问题,97% 的业务线程都处于 blocked 状态:

     
  4. 剖析线程 dump,寻找 blocked 的次要起因。发现就是类加载操作导致的大量线程 blocked。而类加载操作居然是 druid 相干的办法导致的(前面会提到,其实连贯检测导致的);

  我的项目中应用的 druid 连接池局部配置参数:

    initialSize: 5
    minIdle: 5
    maxIdle: 5
    maxActive: 5
    maxWait: 60000
    timeBetweenEvictionRunsMillis: 60000
    minEvictableIdleTimeMillis: 300000
    validationQuery: SELECT 1 FROM DUAL
    poolPreparedStatements: true
    testWhileIdle: true
    // 获取连贯时检测
    testOnBorrow: true
    // 偿还连贯时检测
    testOnReturn: true
    #   配置监控统计拦挡的 filters,去掉后监控界面 sql 无奈统计,'wall' 用于防火墙
    filters: stat,wall,slf4j
    maxPoolPreparedStatementPerConnectionSize: 20
    useGlobalDataSourceStat: true
    connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=1000

  能够看到,这边把连贯检测的配置都加上,也就是在获取连贯,偿还连贯,以及连贯闲暇阶段都会进行连贯检测。【思考到性能影响:个别不开启 testOnReturntestOnReturn,而应用 testWhileIdle 配合 timeBetweenEvictionRunsMillis 进行闲暇连贯检测】

  接下来,简略剖析下 druid 的局部源码,以及 druid 1.1.23 版本之前的 bug 的相干源码。

或者能够间接跳转至上面 getLastPacketReceivedTimeMs 章节,间接切入正题;

(二)连贯检测局部源码

1. getConnectionDirect – 连贯检测入口

  调用 getConnection() 获取连贯后,最终会调用 com.alibaba.druid.pool.DruidDataSource#getConnectionDirect 办法。在 getConnectionDirect 办法中,就会判断是否走连贯检测的逻辑,上面是次要源码(篇幅起因其余分支逻辑细节去掉了,可自行浏览):

public DruidPooledConnection getConnectionDirect(long maxWaitMillis) throws SQLException {
        int notFullTimeoutRetryCnt = 0;
        for (;;) {
            // DruidPooledConnection 封装了物理连贯对象
            DruidPooledConnection poolableConnection;
            try {
                // 这是连贯检测的外围代码所在
                poolableConnection = getConnectionInternal(maxWaitMillis);
            } catch (GetConnectionTimeoutException ex) {if (notFullTimeoutRetryCnt <= this.notFullTimeoutRetryCount && !isFull()) {
                    notFullTimeoutRetryCnt++;
                    if (LOG.isWarnEnabled()) {LOG.warn("get connection timeout retry :" + notFullTimeoutRetryCnt);
                    }
                    continue;
                }
                throw ex;
            }
            // 是否开启了获取连贯时检测(依照下面的配置,首先会进入这段代码)if (testOnBorrow) {boolean validate = testConnectionInternal(poolableConnection.holder, poolableConnection.conn);
                if (!validate) {if (LOG.isDebugEnabled()) {LOG.debug("skip not validate connection.");
                    }

                    discardConnection(poolableConnection.holder);
                    continue;
                }
            } else {if (poolableConnection.conn.isClosed()) {discardConnection(poolableConnection.holder); // 传入 null,防止反复敞开
                    continue;
                }
                // 是否开启了闲暇检测,会依据闲暇工夫,判断是否进行连贯检测
                if (testWhileIdle) {// ......}
            }
            // 是否开启了 removeAbandoned 参数(不举荐开启,会获取线程栈信息)if (removeAbandoned) {// ......}

            if (!this.defaultAutoCommit) {poolableConnection.setAutoCommit(false);
            }

            return poolableConnection;
        }
    }

2. testConnectionInternal – 连贯检测外围代码

com.alibaba.druid.pool.DruidAbstractDataSource#testConnectionInternal是连贯检测的外围代码。开启了 testOnReturntestOnReturn后,外围的连贯测试逻辑都在这。

两个要害办法

  1. validConnectionChecker.isValidConnection(),实际上调用的是com.alibaba.druid.pool.vendor.MySqlValidConnectionChecker#isValidConnection()。次要做两件事:1、先发送 ping 信息给 mysql 服务器,检测 tcp 连贯可用性;2、执行 validationQuery 配置的 SQL 查问语句;
     
  2. MySqlUtils.getLastPacketReceivedTimeMs(),用于获取连贯的闲暇工夫。这个办法也是明天 thread blocked 的首恶;
protected boolean testConnectionInternal(DruidConnectionHolder holder, Connection conn) {
    // ...... 其余
    try {if (validConnectionChecker != null) {
            // isValidConnection 真正做检测的办法,会做两件事
            // 1. 先发送 ping 信息给 mysql 服务器,检测 tcp 连贯可用性;
            // 2. 执行 validationQuery 配置的 SQL 查问语句;
            boolean valid = validConnectionChecker.isValidConnection(conn, validationQuery, validationQueryTimeout);
            long currentTimeMillis = System.currentTimeMillis();
            if (holder != null) {
                holder.lastValidTimeMillis = currentTimeMillis;
                holder.lastExecTimeMillis = currentTimeMillis;
            }
            // 如果是 mysql 数据库,且通过了连贯测试,就进入上面的代码
            if (valid && isMySql) { // unexcepted branch
                // 1.1.23 之前的 bug 所在
                long lastPacketReceivedTimeMs = MySqlUtils.getLastPacketReceivedTimeMs(conn);
                if (lastPacketReceivedTimeMs > 0) {
                    long mysqlIdleMillis = currentTimeMillis - lastPacketReceivedTimeMs;
                    if (lastPacketReceivedTimeMs > 0 //
                            && mysqlIdleMillis >= timeBetweenEvictionRunsMillis) {discardConnection(holder);
                        String errorMsg = "discard long time none received connection."
                                + ", jdbcUrl :" + jdbcUrl
                                + ", version :" + VERSION.getVersionNumber()
                                + ", lastPacketReceivedIdleMillis :" + mysqlIdleMillis;
                        LOG.warn(errorMsg);
                        return false;
                    }
                }
            }
            if (valid && onFatalError) {lock.lock();
                try {if (onFatalError) {onFatalError = false;}
                } finally {lock.unlock();
                }
            }
            return valid;
        }
        // ...... 其余
    }
}

3. getLastPacketReceivedTimeMs – 线程阻塞的首恶,bug 所在

  com.alibaba.druid.util.MySqlUtils#getLastPacketReceivedTimeMs

  • 1.1.23 版本之前的实现
/**
 * druid 1.1.23 版本之前的源码实现
 */
public static long getLastPacketReceivedTimeMs(Connection conn) throws SQLException {
        // 第一次执行这段代码,class_connectionImpl 是初始值 null,因而会进入 if 逻辑
        if (class_connectionImpl == null && !class_connectionImpl_Error) {
        try {
            // 写死 mysql-connector-java 5 的类
            // 因而应用 6 + 版本的驱动,会存在 ClassNotFound 的问题。class_connectionImpl = Utils.loadClass("com.mysql.jdbc.MySQLConnection");
        } catch (Throwable error){class_connectionImpl_Error = true;}
        }
}
  • 修复后的代码实现
     
    新增了对 mysql-connector-java 6 的反对;
/**
 * druid 1.1.23 版本及之后的源码实现
 * @param conn:如果配置了自定义 Filter,传入的 conn 就是 ConnectionProxyImpl 类型,否则就是 ConnectionImpl 类型
 */
public static long getLastPacketReceivedTimeMs(Connection conn) throws SQLException {
        // 如果配置了自定义 Filter,这边 class_connectionImpl 就是 null
        if (class_connectionImpl == null && !class_connectionImpl_Error) {
            try {
                // 加载 mysql 连贯类
                class_connectionImpl = Utils.loadClass("com.mysql.jdbc.MySQLConnection");
                if (class_connectionImpl == null) {class_connectionImpl = Utils.loadClass("com.mysql.cj.MysqlConnection");
                    if (class_connectionImpl != null) {mysqlJdbcVersion6 = true;}
                }
            } catch (Throwable error) {class_connectionImpl_Error = true;}
        }
        .....
      }

  发现没有,其实次要问题是 MySQL 驱动包版本的问题,druid 1.1.23 之前的版本是写死的 mysql-connector-java 5 的类名,如果工程中配置了 com.mysql.cj.* 相干的包,就会导致一系列的问题:

  1. loadClass是同步办法,多线程下导致线程阻塞;
     
  2. Spring Boot 动静类加载的时候最终会调用其自定义类加载器LaunchedURLClassLoader,在加载过程中会遍历 BOOT-INF/lib/ 下所有的 jar 包 以及 BOOT-INF/classes/ 下的字节码文件;【jar 形式部署】
     
  3. 解析外部 jar 时,会进行解压缩操作;
     
  4. com.mysql.cj.*相干的包不存在,因而每次连贯测试都会反复以上操作;

三、结尾

  码完了,也没什么别的想说的了。哈🤠,能够看看本人的 druid 版本,版本 <1.1.23 的话连忙降级吧。有问题欢送留言探讨。

退出移动版