数据库连接池折腾手记

先复现,再谈优化。

很多人一上来就讲数据库连接池的全景图;我更想先把这次卡住的点说清楚。

问题的开始

线上环境是 Java Spring Boot 应用,MySQL 8.0,用的是默认的 HikariCP。初期流量不大的时候一切正常,但随着业务增长,开始出现偶发的超时。

最开始以为是不是数据库有问题,检查了慢查询、索引、锁等待,都没什么异常。直到有一次把连接池监控打开了,才发现问题的端倪。

# 查看当前数据库连接数
mysql> SHOW STATUS LIKE 'Threads_connected';
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| Threads_connected | 487   |
+-------------------+-------+

# 查看最大连接数配置
mysql> SHOW VARIABLES LIKE 'max_connections';
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| max_connections | 500   |
+-----------------+-------+

连接数已经快到上限了,但应用实际并发并没有这么高。显然,连接没有正常回收。

连接池的基本原理

在深入排查之前,先理一下连接池的基本工作方式。

数据库连接的建立是个开销很大的操作:TCP 三次握手、MySQL 认证协议、权限校验、会话初始化,这一套下来通常要几十毫秒。如果每个请求都重新建连接,那大部分时间都花在建连接上了。

连接池的核心思路很简单:预先建立一批连接,用的时候从池子里拿,用完还回去。但这里有几个关键点需要理解:

graph LR A[应用请求] --> B{池中有空闲连接?} B -->|有| C[分配连接] B -->|没有| D{池已满?} D -->|未满| E[创建新连接] D -->|已满| F[等待或超时] C --> G[执行SQL] G --> H[归还连接到池] E --> G

关键在于"归还"这个动作。如果代码拿了连接但没还回去,或者还回去之前就抛了异常,连接就会泄漏。池里的连接越来越少,最终所有请求都在等连接,超时就不可避免。

另一个常见误区是以为连接池里的连接永远都是好的。实际上数据库那边的连接可能因为网络问题、超时、服务器重启等原因失效,这时候就需要连接池有检测和重建机制。

连接泄漏的排查

回到我们的问题,第一件事就是排查哪里可能有连接泄漏。

最直接的方法是开启连接池的详细日志:

# application.yml
spring:
  datasource:
    hikari:
      leak-detection-threshold: 60000  # 60秒未归还视为泄漏
      max-lifetime: 1800000             # 30分钟
      connection-timeout: 30000         # 30秒获取超时
      idle-timeout: 600000              # 10分钟空闲超时
      maximum-pool-size: 50
      minimum-idle: 10
      pool-name: BusinessHikariCP

设置 leak-detection-threshold 后,HikariCP 会在检测到连接泄漏时打印堆栈信息。重新部署后,很快就定位到了问题。

问题代码大概长这样:

public void processData() {
    Connection conn = dataSource.getConnection();
    try {
        Statement stmt = conn.createStatement();
        ResultSet rs = stmt.executeQuery("SELECT * FROM large_table");
        // 处理结果集
        while (rs.next()) {
            // 某些条件下直接返回,没有关闭连接
            if (someCondition) {
                return;
            }
            // 处理数据...
        }
        rs.close();
        stmt.close();
    } catch (SQLException e) {
        log.error("数据库操作失败", e);
    }
    // finally 块里没有关闭连接
}

典型的资源泄漏问题。return 语句直接退出,连接没有关闭;异常处理里也没有确保连接关闭。

正确的写法应该用 try-with-resources:

public void processData() {
    try (Connection conn = dataSource.getConnection();
         Statement stmt = conn.createStatement();
         ResultSet rs = stmt.executeQuery("SELECT * FROM large_table")) {
        while (rs.next()) {
            if (someCondition) {
                return;  // 自动关闭所有资源
            }
            // 处理数据...
        }
    } catch (SQLException e) {
        log.error("数据库操作失败", e);
    }
}

修复完所有类似的代码后,连接数稳定下来了,但响应时间仍然不够理想。于是开始第二轮优化。

连接池参数调优

修复泄漏只是基础,要让连接池真正发挥作用,还需要根据实际场景调优参数。

HikariCP 的配置项不多,但每个都有它的讲究:

参数默认值说明调优建议
maximum-pool-size10最大连接数根据数据库并发能力设置,通常不超过 CPU 核心数 * 2 + 有效磁盘数
minimum-idle10最小空闲连接数建议设置为与最大连接数相同,减少连接创建开销
connection-timeout30000获取连接超时时间根据业务容忍度设置,太短容易超时,太长会堆积请求
idle-timeout600000空闲连接超时时间10分钟左右合适,太短频繁创建连接,太长浪费资源
max-lifetime1800000连接最大生命周期建议比数据库 wait_timeout 稍短一些
leak-detection-threshold0连接泄漏检测阈值生产环境建议开启,设置 60 秒左右

这里有个关键点:maximum-pool-size 不是越大越好。连接数过多会导致数据库上下文切换开销增大,反而降低性能。

我们的环境配置:

spring:
  datasource:
    hikari:
      maximum-pool-size: 32          # 8核机器,32个连接
      minimum-idle: 32               # 保持所有连接常驻
      connection-timeout: 10000      # 10秒超时
      idle-timeout: 300000           # 5分钟
      max-lifetime: 1500000          # 25分钟,小于MySQL的wait_timeout
      leak-detection-threshold: 60000
      validation-timeout: 3000       # 连接验证超时
      connection-test-query: SELECT 1

选择 32 的原因是:我们用的是 8 核机器,每个核心处理 4 个连接是比较均衡的配置。实际测试后发现,超过 40 后性能反而开始下降。

不同连接池的对比

在优化过程中,我们也简单对比了一下几种主流连接池:

// HikariCP 配置
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/db");
config.setUsername("user");
config.setPassword("password");
config.setMaximumPoolSize(32);
HikariDataSource hikariDS = new HikariDataSource(config);

// Druid 配置
DruidDataSource druidDS = new DruidDataSource();
druidDS.setUrl("jdbc:mysql://localhost:3306/db");
druidDS.setUsername("user");
druidDS.setPassword("password");
druidDS.setMaxActive(32);
druidDS.setInitialSize(32);
druidDS.setMinIdle(32);
druidDS.setMaxWait(10000);
druidDS.setValidationQuery("SELECT 1");
druidDS.setTestWhileIdle(true);
druidDS.setTestOnBorrow(false);
druidDS.setTestOnReturn(false);

实际测试结果:

连接池平均响应时间CPU 开销内存占用特点
HikariCP12ms最小轻量快速,配置简单
Druid15ms较大监控功能丰富,配置复杂
DBCP218ms功能全面,但性能一般
C3P025ms最大老牌连接池,性能偏弱

在相同连接池大小(32)下,HikariCP 的平均响应时间明显领先——下图直观对比四款连接池的实测延迟。

HikariCP、Druid、DBCP2 与 C3P0 在 MySQL 8.0 上的平均响应时间对比(ms,maximum-pool-size=32)

HikariCP 比 C3P0 快一倍以上,且 CPU 与内存开销最低,这是我们最终选它的直接依据。

最终我们还是选择 HikariCP,原因很简单:性能好、配置简单、维护成本低。Druid 的监控功能虽然强大,但在我们的场景下,HikariCP 配合 Prometheus 已经足够。

监控和告警

连接池优化完成后,建立监控体系很重要。没有监控的优化就像闭眼开车,你不知道什么时候又会出问题。

我们用 Micrometer 集成 Prometheus 监控:

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
</dependency>
@Configuration
public class MetricsConfig {
    @Bean
    public MeterRegistryCustomizer<HikariDataSource> metricsCommonTags() {
        return (dataSource, registry) -> {
            HikariPoolMXBean poolProxy = dataSource.getHikariPoolMXBean();

            registry.gauge("hikari.active.connections", poolProxy,
                p -> p.getActiveConnections());
            registry.gauge("hikari.idle.connections", poolProxy,
                p -> p.getIdleConnections());
            registry.gauge("hikari.total.connections", poolProxy,
                p -> p.getTotalConnections());
            registry.gauge("hikari.threads.awaiting.connection", poolProxy,
                p -> p.getThreadsAwaitingConnection());
        };
    }
}

关键指标:

  • hikari.active.connections:活跃连接数,应该稳定在一个合理范围
  • hikari.idle.connections:空闲连接数,如果经常为 0 说明连接不够用
  • hikari.threads.awaiting.connection:等待连接的线程数,大于 0 就要警惕
  • hikari.total.connections:总连接数,应该接近 maximum-pool-size

Prometheus 告警规则:

groups:
  - name: hikari_alerts
    rules:
      - alert: HikariPoolNearlyFull
        expr: hikari_active_connections / hikari_total_connections > 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "连接池接近满载"
          description: "{{ $labels.instance }} 连接池使用率超过 80%"

      - alert: HikariConnectionWait
        expr: hikari_threads_awaiting_connection > 5
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "存在连接等待"
          description: "{{ $labels.instance }} 有 {{ $value }} 个线程在等待连接"

有了监控后,我们能够及时发现异常情况。比如某次发布后,监控显示连接等待数突然上升,快速定位到是新代码里有个循环查询没有用批处理,修复后问题马上解决。

常见坑点总结

这次优化过程中,遇到过不少坑,总结一下:

1. 事务和连接池的配合

@Transactional
public void processInTransaction() {
    // 这里获取的连接和事务管理的连接可能不是同一个
    Connection conn = dataSource.getConnection();
    // 执行一些操作...
    conn.close();  // 这可能会把事务连接也关闭
}

正确做法是让 Spring 管理连接:

@Autowired
private JdbcTemplate jdbcTemplate;

@Transactional
public void processInTransaction() {
    jdbcTemplate.query("SELECT * FROM table", (rs, rowNum) -> {
        // 处理结果
        return null;
    });
}

2. 长事务占用连接

@Transactional
public void longRunningTask() {
    // 先查数据
    List<Data> data = repository.findAll();
    // 然后做一些耗时的处理,比如调用外部API
    for (Data item : data) {
        externalApiCall(item);  // 每次调用可能要几秒
        // 数据库连接一直被占用
    }
}

这种情况下,连接会被长时间占用,其他请求就拿不到连接。应该拆分事务:

public void longRunningTask() {
    List<Data> data = repository.findAll();  // 事务结束后连接立即归还

    for (Data item : data) {
        externalApiCall(item);
        repository.updateStatus(item.getId(), "processed");  // 每次更新单独事务
    }
}

3. 连接池和数据库参数不匹配

MySQL 默认的 wait_timeout 是 8 小时,但如果连接池的 max-lifetime 设置得比这个长,就会出现连接失效的问题。

-- 查看 MySQL wait_timeout
SHOW VARIABLES LIKE 'wait_timeout';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| wait_timeout  | 28800 |  -- 8小时
+---------------+-------+

连接池的 max-lifetime 应该设置为比 wait_timeout 小,比如 25 分钟:

max-lifetime: 1500000  # 25分钟

4. 环境差异导致的问题

开发环境可能连接数少、延迟低,一切正常。到了生产环境,连接数多、网络延迟大,问题就暴露出来了。

建议在测试环境模拟生产环境的网络延迟和连接数:

# 使用 tc 模拟网络延迟
tc qdisc add dev eth0 root netem delay 100ms 20ms

优化的最终效果

经过这次完整的优化,效果还是比较明显的:

指标优化前优化后改善
平均响应时间85ms35ms-59%
P99 响应时间450ms120ms-73%
数据库连接数450-50032-35-92%
连接等待超时每小时 20+ 次0-100%
CPU 使用率65%45%-31%

最直观的感受是系统稳定了很多,不再出现那种莫名其妙的超时 spike。监控图表也变得平滑了,连接数稳定在 32 左右,没有异常波动。

一些实践建议

基于这次经验,给一些实践建议:

  1. 开发阶段就要关注连接池配置,不要等到上线了才发现问题
  2. 一定要开泄漏检测leak-detection-threshold 救过我们好几次
  3. 监控比调优更重要,没有监控的调优是盲人摸象
  4. 连接数不是越大越好,根据实际硬件和并发量来设置
  5. 代码审查要检查资源释放,try-with-resources 能避免很多问题
  6. 定期检查连接池指标,把它当成系统健康检查的一部分

最后说一句

连接池看起来是个小问题,但真能影响到整个系统的稳定性。很多时候我们觉得数据库慢,其实是连接池没配置好;觉得代码写得没问题,其实是连接泄漏了。

技术这东西,很多时候就是这些"小地方"决定了最终的效果。就像这次优化,改的不是什么高大上的架构,就是几个配置参数、几处代码写法,但效果是很实在的。


这次连接池优化花了两周,从最初的超时问题到最终的稳定运行,中间踩过不少坑。现在回过头看,很多问题其实都有迹可循,只是当时没有足够的监控和经验。希望这篇记录能帮到遇到类似问题的人。

可用性说明:本文发布于 2021 年 3 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-数据库连接池折腾手记https://blog.thinkmoon.cn/post/107-database-connection-pool-principle-optimization/) 转载或引用必须申明原指尖魔法屋来源及源地址!