后端开发:数据库连接池配置踩坑实录
去年双十一前,我们订单服务突然大量超时。监控上 QPS 从 8000 掉到 200,但 CPU 使用率只有 15%,GC 也正常。我登上机器 jstack 一看,200 个 Tomcat 线程全部卡在 HikariPool.getConnection 上。当时我就懵了——连接池最大连接数明明设了 50,数据库 PostgreSQL 的 max_connections 是 500,怎么会拿不到连接?后来翻了 HikariCP 的源码才发现,connectionTimeout 默认 30 秒,而我们的慢 SQL 平均要跑 3 秒,50 个连接被 200 个线程抢,排队时间直接爆炸。更坑的是,Druid 在这种情况下会抛 GetConnectionTimeoutException,而 HikariCP 会一直等,直到超时。那次事故后,我花了整整一周,把 HikariCP、Druid、Go 的 database/sql 的源码和文档全撸了一遍,还做了 2000 万次请求的压测。
先看 HikariCP 的默认值:maximumPoolSize=10,minimumIdle=10,connectionTimeout=30000ms,idleTimeout=600000ms,maxLifetime=1800000ms。注意 maximumPoolSize 默认只有 10,不是 50 也不是 100。HikariCP 官方文档里有个公式:connections = ((core_count * 2) + effective_spindle_count)。我拿一台 4 核 8G 的机器算,4*2+1=9,所以默认 10 是合理的。Druid 的默认值不一样:initialSize=0,minIdle=0,maxActive=8,maxWait=-1(无限等待)。Druid 的 maxWait=-1 是个大坑,它会让你线程永远挂起,而不是快速失败。我们当时从 Druid 迁到 HikariCP,就是因为这个。但 HikariCP 也不是没毛病,它的 maxLifetime 默认 30 分钟,而 MySQL 的 wait_timeout 默认 8 小时,所以一般没问题;但如果你用云数据库,比如 AWS RDS 的 wait_timeout 可能是 300 秒,那 HikariCP 的 maxLifetime 必须小于这个值,否则会拿到已被服务端关闭的连接,报 Communications link failure。
Go 的 database/sql 是标准库,参数更少:SetMaxOpenConns 默认 0(无限制),SetMaxIdleConns 默认 2,SetConnMaxLifetime 默认 0(永不过期)。听起来很美好?但无限制的 MaxOpenConns 在流量突增时会打爆数据库。我压测过:用 Go 写一个简单的 HTTP 服务,每个请求查一次 PostgreSQL,当并发从 100 涨到 5000 时,MaxOpenConns=0 的情况下,PostgreSQL 的 max_connections 直接被打满,报 FATAL: sorry, too many clients already。后来我把 MaxOpenConns 设成 100,MaxIdleConns 设成 100,ConnMaxLifetime 设成 5 分钟,QPS 反而比无限制时高了 18%,因为避免了频繁建立连接的开销。这里有个反直觉的点:很多人以为连接池越大吞吐越高,但实际在 PostgreSQL 上,连接数超过 CPU 核数的 2 倍后,上下文切换和锁竞争会让吞吐下降。我用 sysbench 测过,4 核机器上,连接数从 10 涨到 200,TPS 从 12000 降到 9000。
基于压测,我总结了一个 HikariCP 的配置模板(适用于 4 核 8G,PostgreSQL 14,SSD):
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 10
connection-timeout: 3000
idle-timeout: 300000
max-lifetime: 1200000
validation-timeout: 3000
keepalive-time: 300000
注意 connection-timeout 从 30000 降到 3000,让拿不到连接的请求快速失败,而不是排队等 30 秒。max-lifetime 设 20 分钟,小于云数据库的 30 分钟 wait_timeout。另外,keepalive-time 是 HikariCP 3.4.0 引入的,每 5 分钟发一次心跳,防止连接被防火墙切断。Druid 的话,我建议 maxActive=20,maxWait=3000,validationQuery=SELECT 1,testWhileIdle=true,timeBetweenEvictionRunsMillis=60000。Go 的话,db.SetMaxOpenConns(20),db.SetMaxIdleConns(10),db.SetConnMaxLifetime(20*time.Minute),db.SetConnMaxIdleTime(5*time.Minute)。这些数字不是拍脑袋,是我在 2000 万请求压测下,P99 延迟从 1.2s 降到 180ms 的结果。
说一个可能被喷的观点:大部分业务系统根本不需要连接池。如果你的 QPS 低于 500,直接用短连接,每个请求新建一个数据库连接,性能损失不到 5%,但省去了所有连接池的调优和故障排查。PostgreSQL 建立连接的开销大约是 1.5ms(本地),MySQL 是 0.8ms,而一个慢查询动辄 10ms 以上。连接池真正有价值的地方是长事务、高并发、或者数据库连接建立成本极高的场景(比如跨可用区)。我见过太多团队,为了“性能”把 HikariCP 的 maximumPoolSize 设成 200,结果数据库先挂了。所以,下次你调连接池参数时,先问自己:我的瓶颈真的是连接数吗?还是慢 SQL?还是锁?用 pg_stat_activity 或者 SHOW PROCESSLIST 看一眼,比盲目调参有用得多。