Druid连接池深度剖析:当MySQL悄悄关闭连接时,你的应用为何浑然不知?

深夜,监控系统突然告警,某个核心服务接口的响应时间从几十毫秒飙升到数秒,紧接着出现大量Communications link failure异常。你迅速登录服务器查看日志,发现错误信息中赫然写着“The last packet successfully received from the server was 658,107 milliseconds ago”——这意味着数据库连接已经空闲了超过10分钟,而MySQL服务器早已将其关闭。但奇怪的是,连接池却认为这个连接仍然有效,继续将其分配给业务线程使用。

这不是什么罕见的场景,而是许多使用Druid连接池的团队都曾踩过的坑。表面上看,这是一个简单的“连接被MySQL回收”问题,但深入Druid源码后你会发现,事情远比想象中复杂。连接池的回收逻辑、MySQL的超时机制、应用配置的微妙平衡,这三者之间的博弈决定了你的系统能否稳定运行。

1. 连接池的双重身份:资源管理者与风险承担者

连接池在现代Java应用中扮演着双重角色。一方面,它是资源管理者,负责高效地复用数据库连接,避免频繁创建和销毁带来的性能损耗。另一方面,它又是风险承担者,必须时刻警惕数据库连接的状态变化,防止将无效连接交给业务代码使用。

Druid作为阿里巴巴开源的数据库连接池,以其强大的监控功能和灵活的配置选项而闻名。但正是这种灵活性,让许多开发者在配置时感到困惑。特别是当涉及到连接回收时,Druid提供了多个看似相似但作用完全不同的参数,稍有不慎就会埋下隐患。

1.1 连接池的基本工作原理

要理解连接回收问题,首先需要了解连接池是如何管理连接生命周期的。Druid内部维护着一个连接数组,这个数组中的每个元素都代表一个物理数据库连接。连接池通过几个关键线程来管理这些连接:

  • 创建线程:当连接数量不足时,负责创建新的数据库连接
  • 销毁线程:定期检查连接状态,回收空闲时间过长的连接
  • 保活线程:对空闲连接进行健康检查,确保其仍然有效

这些线程协同工作,确保连接池中既有足够的连接处理业务请求,又不会维持过多无用连接浪费资源。但问题在于,连接池对“连接是否有效”的判断,与数据库服务器(如MySQL)的判断标准可能存在差异。

1.2 MySQL的隐式杀手:wait_timeout与interactive_timeout

MySQL服务器有两个关键参数控制着连接的生命周期:

参数名默认值作用
wait_timeout28800秒(8小时)非交互式连接的空闲超时时间
interactive_timeout28800秒(8小时)交互式连接的空闲超时时间

这两个参数的含义经常被混淆。简单来说:

  • 交互式连接:通过MySQL命令行客户端等工具建立的连接
  • 非交互式连接:通过JDBC等程序接口建立的连接

对于大多数Java应用,我们使用的是非交互式连接,因此wait_timeout参数更为关键。当连接空闲时间超过这个阈值,MySQL服务器会主动关闭连接,释放资源。但关键在于:MySQL关闭连接时不会通知客户端。

这就导致了一个尴尬的局面:连接池认为连接还活着(因为上次使用它时一切正常),但MySQL已经单方面终止了它。当下一次业务线程尝试使用这个连接时,就会遇到“Communications link failure”异常。

2. Druid的回收机制:源码视角下的真相

要理解为什么Druid没有感知到MySQL已经关闭了连接,我们需要深入DruidDataSource类的shrink()方法。这是Druid连接回收的核心逻辑所在。

2.1 shrink()方法的核心逻辑

shrink()方法在DestroyTask线程中定期执行,它的主要职责是清理连接池中的空闲连接。让我们通过简化后的代码逻辑来理解其工作原理:

public void shrink(boolean checkTime, boolean keepAlive) {
    // 获取锁,确保线程安全
    lock.lockInterruptibly();
    
    try {
        if (!inited) {
            return;
        }
        
        final int checkCount = poolingCount - minIdle;
        final long currentTimeMillis = System.currentTimeMillis();
        
        // 遍历连接池中的所有连接
        for (int i = 0; i < poolingCount; ++i) {
            DruidConnectionHolder connection = connections[i];
            
            if (checkTime) {
                // 计算连接的空闲时间
                long idleMillis = currentTimeMillis - connection.lastActiveTimeMillis;
                
                // 关键判断1:如果空闲时间小于最小可驱逐时间,直接跳出循环
                if (idleMillis < minEvictableIdleTimeMillis) {
                    break;
                }
                
                // 关键判断2:如果当前索引小于需要检查的数量,或者空闲时间超过最大可驱逐时间
                if (checkTime && i < checkCount) {
                    evictConnections[evictCount++] = connection;
                } else if (idleMillis > maxEvictableIdleTimeMillis) {
                    evictConnections[evictCount++] = connection;
                } else if (keepAlive) {
                    keepAliveConnections[keepAliveCount++] = connection;
                }
            }
        }
        
        // 后续处理:实际销毁或保活连接
        // ...
    } finally {
        lock.unlock();
    }
}

这段代码中有几个关键点需要特别注意:

  1. 排序的重要性:Druid的连接数组是按照lastActiveTime(最后活跃时间)排序的,空闲时间最长的连接排在前面。这就是为什么当遇到第一个空闲时间小于minEvictableIdleTimeMillis的连接时,可以直接break跳出循环——后面的连接空闲时间更短,肯定也不满足条件。

  2. minEvictableIdleTimeMillis的真正作用:很多人误以为这个参数是“连接空闲多久后会被回收”,但实际上它是循环终止条件。当连接的空闲时间小于这个值时,Druid就停止检查后续连接。

  3. maxEvictableIdleTimeMillis的兜底作用:无论连接池中有多少空闲连接,只要某个连接的空闲时间超过这个值,就会被强制回收。

2.2 默认配置的陷阱

Druid的默认配置如下:

  • minEvictableIdleTimeMillis: 30分钟(1,800,000毫秒)
  • maxEvictableIdleTimeMillis: 7小时(25,200,000毫秒)
  • timeBetweenEvictionRunsMillis: 60秒(60,000毫秒)

而MySQL的默认wait_timeout是8小时(28,800,000毫秒)。看起来似乎没有问题,因为Druid的maxEvictableIdleTimeMillis(7小时)小于MySQL的wait_timeout(8小时)。

但实际情况要复杂得多。考虑以下场景:

  1. 应用在凌晨2点创建了一个连接,使用后归还到连接池
  2. 这个连接在连接池中一直处于空闲状态
  3. 早上8点,业务高峰开始,连接池需要分配连接
  4. 此时连接已经空闲了6小时,但Druid的maxEvictableIdleTimeMillis是7小时,所以不会回收
  5. MySQL的wait_timeout是8小时,也不会关闭连接
  6. 看起来一切正常,对吗?

问题在于,MySQL的wait_timeout是从最后一次交互开始计算的,而Druid的lastActiveTime是从应用层最后一次使用连接开始计算的。这两个时间点可能存在差异:

  • 应用在2:00使用连接执行查询,2:00:05收到结果,将连接归还
  • MySQL服务器可能在2:00:10才完全处理完这个查询
  • 对于MySQL来说,连接的空闲时间从2:00:10开始计算
  • 对于Druid来说,连接的空闲时间从2:00:05开始计算

这5秒的差异在大多数情况下无关紧要,但在边界条件下可能成为致命问题。

3. 健康检查:连接有效性的最后防线

既然Druid无法主动感知MySQL是否关闭了连接,那么就需要通过主动检查来验证连接的有效性。Druid提供了多种健康检查机制,但每种机制都有其适用场景和性能代价。

3.1 三种健康检查模式

Druid支持三种连接有效性检查方式,通过不同的配置参数控制:

检查时机配置参数作用性能影响
借出时检查testOnBorrow每次从连接池获取连接时执行验证查询高,每次获取连接都有额外开销
归还时检查testOnReturn连接归还到连接池时执行验证查询中,影响归还操作的性能
空闲时检查testWhileIdle连接在池中空闲时定期执行验证查询低,在后台线程中执行

大多数生产环境推荐使用testWhileIdle=true,因为它能在保证连接有效性的同时,对性能影响最小。但这里有一个关键细节:testWhileIdle只在timeBetweenEvictionRunsMillis指定的时间间隔内对空闲连接进行检查。

3.2 验证查询的优化

默认情况下,Druid使用SELECT 1作为验证查询。对于MySQL,这通常是最佳选择,但需要注意一个细节:不同数据库驱动对SELECT 1的实现方式不同。

在Druid 1.2.5及以上版本中,引入了一个与MySQL驱动相关的优化:usePingMethod。当这个参数为true时(默认值),Druid会使用MySQL驱动的pingInternal()方法来检查连接,而不是执行SELECT 1。

// MySqlValidConnectionChecker中的关键代码
if (usePingMethod) {
    if (conn instanceof DruidPooledConnection) {
        conn = ((DruidPooledConnection) conn).getConnection();
    }
    if (conn instanceof ConnectionProxy) {
        conn = ((ConnectionProxy) conn).getRawObject();
    }
    if (clazz.isAssignableFrom(conn.getClass())) {
        if (validationQueryTimeout <= 0) {
            validationQueryTimeout = DEFAULT_VALIDATION_QUERY_TIMEOUT;
        }
        try {
            // 使用ping方法而不是执行SQL
            ping.invoke(conn, true, validationQueryTimeout * 1000);
            return true;
        } catch (InvocationTargetException e) {
            Throwable cause = e.getCause();
            if (cause instanceof SQLException) {
                throw (SQLException) cause;
            }
            throw e;
        }
    }
}

pingInternal()方法比执行SELECT 1更高效,因为它直接在网络层检查连接是否存活,不需要经过SQL解析和执行过程。但在某些MySQL驱动版本中,这个方法可能存在bug,导致误判连接状态。

如果你遇到“discard long time none received connection”警告,可以尝试将druid.mysql.usePingMethod设置为false:

# 在JVM启动参数中添加
-Ddruid.mysql.usePingMethod=false

# 或者在Spring Boot的application.properties中添加
spring.datasource.druid.connection-properties=druid.mysql.usePingMethod=false

3.3 健康检查的时间窗口问题

即使配置了testWhileIdle=true,仍然可能出现连接失效的问题。原因在于检查的时间窗口:

假设配置如下:

  • timeBetweenEvictionRunsMillis: 60000(60秒)
  • minEvictableIdleTimeMillis: 300000(5分钟)
  • MySQL wait_timeout: 300(5分钟)

时间线如下:

  1. 00:00:00 - 连接被创建并开始使用
  2. 00:00:10 - 连接归还到连接池,开始空闲
  3. 00:01:00 - 销毁线程第一次运行,连接空闲50秒,小于5分钟,不检查
  4. 00:02:00 - 销毁线程第二次运行,连接空闲1分50秒,小于5分钟,不检查
  5. 00:03:00 - 销毁线程第三次运行,连接空闲2分50秒,小于5分钟,不检查
  6. 00:04:00 - 销毁线程第四次运行,连接空闲3分50秒,小于5分钟,不检查
  7. 00:05:00 - 销毁线程第五次运行,连接空闲4分50秒,小于5分钟,不检查
  8. 00:05:10 - MySQL服务器检测到连接已空闲5分钟,关闭连接
  9. 00:06:00 - 销毁线程第六次运行,此时连接已被MySQL关闭,但Druid直到现在才检查

可以看到,在00:05:10到00:06:00这50秒内,连接实际上已经失效,但Druid仍然可能将其分配给业务线程使用。

注意:这里有一个常见的误解。很多人认为testWhileIdle会定期检查所有空闲连接,但实际上Druid只检查那些空闲时间超过minEvictableIdleTimeMillis的连接。如果连接的空闲时间没有达到这个阈值,即使配置了testWhileIdle=true,也不会进行检查。

4. 实战配置:构建稳健的连接池

理解了Druid连接回收的原理后,我们可以针对性地调整配置,避免连接失效问题。以下是一套经过生产验证的配置方案,适用于大多数场景。

4.1 基础配置模板

spring:
  datasource:
    druid:
      # 连接池基本配置
      initial-size: 5
      min-idle: 5
      max-active: 20
      
      # 连接有效性检查
      test-while-idle: true
      test-on-borrow: false  # 生产环境不建议开启,性能影响大
      test-on-return: false  # 生产环境不建议开启
      validation-query: SELECT 1
      validation-query-timeout: 3
      
      # 连接回收配置
      time-between-eviction-runs-millis: 30000  # 30秒检查一次
      min-evictable-idle-time-millis: 180000    # 3分钟
      max-evictable-idle-time-millis: 300000    # 5分钟
      
      # 保活机制
      keep-alive: true
      keep-alive-between-time-millis: 60000     # 1分钟
      
      # 连接泄露检测(仅用于调试)
      remove-abandoned: false  # 生产环境不建议开启
      log-abandoned: false
      
      # MySQL特定优化
      connection-properties: druid.mysql.usePingMethod=false
      
      # 监控配置
      filters: stat,wall,slf4j
      filter:
        stat:
          log-slow-sql: true
          slow-sql-millis: 1000

4.2 配置参数详解与调优建议

让我们详细分析每个关键参数的作用和调优思路:

连接池大小配置

  • initial-size:连接池初始化时创建的连接数。建议设置为min-idle的值,避免应用启动后第一次请求需要等待连接创建。
  • min-idle:连接池中最少保持的空闲连接数。这个值不宜过大,否则会浪费数据库资源;也不宜过小,否则在流量突增时可能来不及创建新连接。
  • max-active:连接池中允许的最大连接数。这个值需要根据数据库的最大连接数、应用实例数、以及每个实例的并发需求来综合确定。

健康检查配置

  • test-while-idle:必须设置为true,这是保证连接有效性的核心机制。
  • validation-query:使用SELECT 1即可,对于MySQL这是最轻量的检查方式。
  • validation-query-timeout:设置一个合理的超时时间,建议3-5秒。太短可能导致误判,太长则影响连接获取速度。

连接回收配置(核心) 这里需要根据MySQL的wait_timeout来精心计算:

// 计算Druid配置与MySQL超时的关系
int mysqlWaitTimeout = 300; // MySQL wait_timeout,单位秒
int safetyMargin = 30; // 安全边际,单位秒

// Druid的最大空闲时间应该小于MySQL的超时时间减去安全边际
int maxEvictableIdleTimeMillis = (mysqlWaitTimeout - safetyMargin) * 1000;

// 最小空闲时间应该远小于最大空闲时间,确保有足够的时间窗口进行检查
int minEvictableIdleTimeMillis = maxEvictableIdleTimeMillis / 2;

// 检查频率应该足够高,确保在连接失效前能发现问题
int timeBetweenEvictionRunsMillis = Math.min(
    minEvictableIdleTimeMillis / 6, // 至少检查6次
    60000 // 最多1分钟检查一次
);

基于这个计算逻辑,如果MySQL的wait_timeout是300秒(5分钟),那么Druid的配置应该是:

  • max-evictable-idle-time-millis: 270000(4.5分钟)
  • min-evictable-idle-time-millis: 135000(2.25分钟)
  • time-between-eviction-runs-millis: 30000(30秒)

这样配置可以确保:

  1. 连接在MySQL关闭之前就会被Druid回收(4.5分钟 < 5分钟)
  2. 连接空闲2.25分钟后开始接受健康检查
  3. 每30秒检查一次,在连接失效前至少有4次检查机会

保活机制

  • keep-alive: 设置为true,启用保活机制
  • keep-alive-between-time-millis: 保活检查间隔,建议设置为time-between-eviction-runs-millis的一半

保活机制是Druid 1.1.22版本引入的重要特性。当连接空闲时间超过minEvictableIdleTimeMillis但小于maxEvictableIdleTimeMillis时,Druid会对这些连接执行保活操作(执行验证查询),而不是直接关闭。这可以避免频繁创建新连接的开销。

4.3 监控与告警配置

除了正确的参数配置,完善的监控也是必不可少的。Druid提供了丰富的监控指标,可以通过JMX或内置的StatViewServlet获取。

启用监控配置:

@Configuration
public class DruidConfig {
    
    @Bean
    public ServletRegistrationBean<StatViewServlet> druidStatViewServlet() {
        ServletRegistrationBean<StatViewServlet> registrationBean = 
            new ServletRegistrationBean<>(new StatViewServlet(), "/druid/*");
        
        // 白名单(为空表示允许所有访问)
        registrationBean.addInitParameter("allow", "");
        
        // 黑名单
        registrationBean.addInitParameter("deny", "192.168.1.100");
        
        // 登录查看信息的账号密码
        registrationBean.addInitParameter("loginUsername", "admin");
        registrationBean.addInitParameter("loginPassword", "admin");
        
        // 是否能够重置数据
        registrationBean.addInitParameter("resetEnable", "false");
        
        return registrationBean;
    }
    
    @Bean
    public FilterRegistrationBean<WebStatFilter> druidWebStatFilter() {
        FilterRegistrationBean<WebStatFilter> registrationBean = 
            new FilterRegistrationBean<>(new WebStatFilter());
        
        // 添加过滤规则
        registrationBean.addUrlPatterns("/*");
        
        // 忽略过滤的格式
        registrationBean.addInitParameter("exclusions", 
            "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*");
        
        return registrationBean;
    }
}

关键监控指标:

需要特别关注的几个指标:

指标正常范围异常处理
ActiveCount0 ~ maxActive的70%如果持续接近maxActive,考虑增加连接数或优化SQL
PoolingCount接近minIdle如果远大于minIdle,可能连接泄露
WaitThreadCount0或接近0如果持续大于0,说明连接不足
NotEmptyWaitCount应该很小如果很大,说明获取连接经常需要等待
NotEmptyWaitMillis平均小于100ms如果很大,需要优化连接获取逻辑

告警规则建议:

  1. 连接泄露检测:如果RemoveAbandonedCount在短时间内快速增长,可能代码中存在未关闭连接的情况。
  2. 连接池耗尽预警:当ActiveCount持续超过maxActive的80%时发出警告。
  3. 连接获取超时:当WaitThreadCount持续大于0且NotEmptyWaitMillis超过1秒时发出警告。
  4. 无效连接比例:通过ConnectErrorCount和ConnectCount计算错误率,超过1%时发出警告。

5. 高级场景与疑难问题处理

即使按照最佳实践配置了Druid,在某些特殊场景下仍然可能遇到问题。这里分享几个实际案例和解决方案。

5.1 案例一:突发流量下的连接风暴

现象:某电商系统在大促期间,监控显示数据库连接数瞬间打满,大量请求超时。

分析:查看Druid监控发现,ActiveCount瞬间达到maxActive,WaitThreadCount急剧上升。进一步分析业务日志,发现某个批量处理任务没有使用连接池,而是每次操作都创建新连接。

解决方案:

  1. 修复代码,确保所有数据库操作都使用连接池
  2. 为批量任务配置独立的连接池,避免影响在线业务
  3. 启用连接泄露检测,临时配置remove-abandoned: true和remove-abandoned-timeout: 120,找出未关闭连接的代码

配置调整:

# 为批量任务配置专用数据源
batch:
  datasource:
    druid:
      initial-size: 3
      min-idle: 3
      max-active: 10
      max-wait: 5000  # 批量任务可以等待更长时间
      validation-query: SELECT 1
      test-while-idle: true
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 300000
      max-evictable-idle-time-millis: 600000

5.2 案例二:长事务导致的连接不可用

现象:系统偶尔出现连接获取超时,但连接池显示有空闲连接。

分析:通过Druid的SQL监控功能,发现某些事务执行时间长达数分钟。这些长事务占用了连接,但Druid无法回收正在使用的连接。

解决方案:

  1. 优化SQL,减少事务执行时间
  2. 设置事务超时时间,避免事务无限期执行
  3. 对于查询操作,使用只读事务或非事务模式

代码示例:

@Service
public class OrderService {
    
    @Autowired
    private DataSource dataSource;
    
    // 为长事务设置超时
    @Transactional(timeout = 30)  // 30秒超时
    public void processBatchOrders(List<Order> orders) {
        // 分批处理,避免单次事务过大
        for (int i = 0; i < orders.size(); i += 100) {
            List<Order> batch = orders.subList(i, Math.min(i + 100, orders.size()));
            processOrderBatch(batch);
        }
    }
    
    // 只读查询使用只读事务
    @Transactional(readOnly = true)
    public List<Order> findRecentOrders(Date fromDate) {
        // 查询逻辑
    }
    
    // 简单查询可以不使用事务
    public Order findById(Long id) {
        // 直接使用JdbcTemplate,不开启事务
        return jdbcTemplate.queryForObject(
            "SELECT * FROM orders WHERE id = ?", 
            new OrderRowMapper(), 
            id
        );
    }
}

5.3 案例三:网络抖动导致的连接假死

现象:应用与数据库之间的网络偶尔抖动后,部分连接变得不可用,但Druid的健康检查没有发现问题。

分析:网络抖动可能导致TCP连接处于半开状态(half-open)。在这种状态下,连接在操作系统层面仍然是ESTABLISHED状态,但实际已经无法通信。Druid的SELECT 1检查可能因为TCP重传机制而成功,但实际业务SQL会失败。

解决方案:

  1. 在连接验证查询中使用更复杂的SQL,触发真正的网络通信
  2. 启用TCP KeepAlive机制
  3. 使用连接初始化SQL设置MySQL会话变量

配置示例:

spring:
  datasource:
    druid:
      # 使用更复杂的验证查询
      validation-query: SELECT 1 FROM DUAL WHERE 1=1
      
      # 连接初始化时执行的SQL
      connection-init-sqls:
        - SET SESSION wait_timeout=300
        - SET NAMES utf8mb4
      
      # 连接属性,启用TCP KeepAlive
      connection-properties: |
        socketTimeout=30000
        connectTimeout=5000
        tcpKeepAlive=true
        druid.mysql.usePingMethod=false

5.4 多数据源场景下的特殊考虑

在微服务架构中,一个应用可能连接多个数据库。每个数据源都应该有独立的连接池配置,因为不同数据库的负载特性和超时设置可能不同。

多数据源配置示例:

@Configuration
public class MultiDataSourceConfig {
    
    @Primary
    @Bean(name = "primaryDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.primary")
    public DataSource primaryDataSource() {
        return DruidDataSourceBuilder.create().build();
    }
    
    @Bean(name = "secondaryDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.secondary")
    public DataSource secondaryDataSource() {
        return DruidDataSourceBuilder.create().build();
    }
    
    @Bean(name = "primaryJdbcTemplate")
    public JdbcTemplate primaryJdbcTemplate(
            @Qualifier("primaryDataSource") DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }
    
    @Bean(name = "secondaryJdbcTemplate")
    public JdbcTemplate secondaryJdbcTemplate(
            @Qualifier("secondaryDataSource") DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }
}

对应的配置文件:

spring:
  datasource:
    primary:
      url: jdbc:mysql://primary-db:3306/app_db
      username: app_user
      password: ${PRIMARY_DB_PASSWORD}
      druid:
        initial-size: 5
        min-idle: 5
        max-active: 20
        test-while-idle: true
        validation-query: SELECT 1
        time-between-eviction-runs-millis: 30000
        min-evictable-idle-time-millis: 180000
        max-evictable-idle-time-millis: 240000  # 主库wait_timeout=300秒
    
    secondary:
      url: jdbc:mysql://secondary-db:3306/report_db
      username: report_user
      password: ${SECONDARY_DB_PASSWORD}
      druid:
        initial-size: 3
        min-idle: 3
        max-active: 10
        test-while-idle: true
        validation-query: SELECT 1
        time-between-eviction-runs-millis: 60000  # 报表库检查频率可以低一些
        min-evictable-idle-time-millis: 300000
        max-evictable-idle-time-millis: 420000  # 报表库wait_timeout=480秒

6. 性能测试与调优验证

配置完成后,必须通过压力测试验证连接池的稳定性和性能。以下是一个简单的测试方案:

6.1 连接泄漏测试

模拟连接未正确关闭的场景,验证Druid的removeAbandoned机制是否生效:

@SpringBootTest
class ConnectionLeakTest {
    
    @Autowired
    private DataSource dataSource;
    
    @Test
    void testConnectionLeakDetection() throws Exception {
        // 临时启用连接泄露检测
        DruidDataSource druidDataSource = (DruidDataSource) dataSource;
        druidDataSource.setRemoveAbandoned(true);
        druidDataSource.setRemoveAbandonedTimeout(10); // 10秒
        druidDataSource.setLogAbandoned(true);
        
        // 模拟连接泄露:获取连接但不关闭
        List<Connection> leakedConnections = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            Connection conn = dataSource.getConnection();
            leakedConnections.add(conn);
            // 不执行conn.close()
        }
        
        // 等待超时
        Thread.sleep(15000);
        
        // 验证连接池状态
        System.out.println("Active connections: " + druidDataSource.getActiveCount());
        System.out.println("Pooling connections: " + druidDataSource.getPoolingCount());
        
        // 清理
        for (Connection conn : leakedConnections) {
            try {
                if (!conn.isClosed()) {
                    conn.close();
                }
            } catch (SQLException e) {
                // 忽略已关闭的连接
            }
        }
        
        // 恢复配置
        druidDataSource.setRemoveAbandoned(false);
        druidDataSource.setLogAbandoned(false);
    }
}

6.2 并发性能测试

模拟高并发场景,验证连接池的吞吐量和响应时间:

@SpringBootTest
class ConcurrentAccessTest {
    
    @Autowired
    private DataSource dataSource;
    
    @Test
    void testConcurrentAccess() throws InterruptedException {
        int threadCount = 50;
        int iterations = 100;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);
        
        AtomicInteger successCount = new AtomicInteger();
        AtomicInteger errorCount = new AtomicInteger();
        List<Long> responseTimes = Collections.synchronizedList(new ArrayList<>());
        
        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    for (int j = 0; j < iterations; j++) {
                        long startTime = System.currentTimeMillis();
                        
                        try (Connection conn = dataSource.getConnection();
                             Statement stmt = conn.createStatement();
                             ResultSet rs = stmt.executeQuery("SELECT 1")) {
                            
                            long endTime = System.currentTimeMillis();
                            responseTimes.add(endTime - startTime);
                            
                            if (rs.next()) {
                                successCount.incrementAndGet();
                            }
                        } catch (SQLException e) {
                            errorCount.incrementAndGet();
                            System.err.println("SQL error: " + e.getMessage());
                        }
                        
                        // 随机等待,模拟真实场景
                        Thread.sleep(ThreadLocalRandom.current().nextInt(10, 100));
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    latch.countDown();
                }
            });
        }
        
        latch.await(2, TimeUnit.MINUTES);
        executor.shutdown();
        
        // 输出统计结果
        System.out.println("Total requests: " + (successCount.get() + errorCount.get()));
        System.out.println("Success: " + successCount.get());
        System.out.println("Errors: " + errorCount.get());
        
        if (!responseTimes.isEmpty()) {
            double avgTime = responseTimes.stream()
                .mapToLong(Long::longValue)
                .average()
                .orElse(0);
            long maxTime = responseTimes.stream()
                .mapToLong(Long::longValue)
                .max()
                .orElse(0);
            long p95 = calculatePercentile(responseTimes, 95);
            
            System.out.println("Average response time: " + avgTime + "ms");
            System.out.println("Max response time: " + maxTime + "ms");
            System.out.println("P95 response time: " + p95 + "ms");
        }
    }
    
    private long calculatePercentile(List<Long> times, double percentile) {
        List<Long> sorted = new ArrayList<>(times);
        Collections.sort(sorted);
        int index = (int) Math.ceil(percentile / 100.0 * sorted.size());
        return sorted.get(index - 1);
    }
}

6.3 连接有效性测试

模拟MySQL连接超时后,验证Druid是否能正确检测并恢复:

@SpringBootTest
class ConnectionValidationTest {
    
    @Autowired
    private DataSource dataSource;
    
    @Test
    void testConnectionRecovery() throws Exception {
        DruidDataSource druidDataSource = (DruidDataSource) dataSource;
        
        // 获取初始连接
        Connection conn1 = dataSource.getConnection();
        System.out.println("Initial connection: " + conn1);
        
        // 模拟连接长时间空闲(超过MySQL wait_timeout)
        System.out.println("Simulating connection idle beyond MySQL timeout...");
        Thread.sleep(350000); // 等待5分50秒,超过5分钟
        
        // 尝试使用连接
        try {
            Statement stmt = conn1.createStatement();
            ResultSet rs = stmt.executeQuery("SELECT 1");
            if (rs.next()) {
                System.out.println("Connection still valid (unexpected)");
            }
            rs.close();
            stmt.close();
        } catch (SQLException e) {
            System.out.println("Connection invalid as expected: " + e.getMessage());
        }
        
        // 关闭旧连接
        conn1.close();
        
        // 获取新连接
        Connection conn2 = dataSource.getConnection();
        System.out.println("New connection: " + conn2);
        
        // 验证新连接是否有效
        Statement stmt = conn2.createStatement();
        ResultSet rs = stmt.executeQuery("SELECT 1");
        if (rs.next()) {
            System.out.println("New connection works correctly");
        }
        
        rs.close();
        stmt.close();
        conn2.close();
        
        // 检查连接池状态
        System.out.println("Active count: " + druidDataSource.getActiveCount());
        System.out.println("Pooling count: " + druidDataSource.getPoolingCount());
        System.out.println("Destroy count: " + druidDataSource.getDestroyCount());
    }
}

7. 生产环境巡检清单

最后,我整理了一份生产环境Druid连接池的巡检清单。建议定期(如每周)检查这些项目,确保连接池健康运行:

7.1 配置检查

  • [ ] maxEvictableIdleTimeMillis < MySQL wait_timeout * 1000 * 0.9
  • [ ] timeBetweenEvictionRunsMillis ≤ minEvictableIdleTimeMillis / 6
  • [ ] testWhileIdle = true
  • [ ] validationQuery 已正确设置
  • [ ] keepAlive = true(Druid 1.1.22+)
  • [ ] removeAbandoned = false(生产环境)

7.2 监控指标检查

  • [ ] ActiveCount 峰值不超过 maxActive 的80%
  • [ ] WaitThreadCount 大部分时间为0
  • [ ] NotEmptyWaitMillis P95 < 100ms
  • [ ] PoolingCount 在 minIdle 附近波动
  • [ ] ConnectErrorCount 增长缓慢(每天<10次)

7.3 日志分析

  • [ ] 没有频繁的"discard long time none received connection"警告
  • [ ] 没有"Communications link failure"错误
  • [ ] 连接创建和销毁频率正常(<10次/分钟)
  • [ ] 慢SQL数量在可接受范围内

7.4 定期维护任务

  • [ ] 每月检查一次MySQL的wait_timeout和interactive_timeout设置
  • [ ] 每季度进行一次连接池压力测试
  • [ ] 每次应用发布前,验证连接池配置与数据库配置的兼容性
  • [ ] 建立连接池异常告警机制,关键指标异常时及时通知

连接池的稳定性直接关系到整个应用的可用性。通过深入理解Druid的工作原理,合理配置各项参数,建立完善的监控体系,我们可以最大限度地避免因连接问题导致的系统故障。记住,连接池不是"配置一次就忘记"的组件,它需要持续的观察和调整,才能在各种业务场景下保持最佳状态。

更多推荐