一个普通的周二上午,线上突然报警:接口大面积超时,数据库 CPU 跑满,连运维自己都登不进去看监控。复盘下来,根因往往不是某条 SQL 写得烂,而是一条本该很快返回的查询被卡住后,连接被一条条占满,新请求在池外排队、又触发重试,最终把数据库彻底拖死。这种「雪崩」几乎每个月都在不同团队重演。本文把连接池和超时的三道闸讲清楚——它们不是银弹,但是把一次普通故障挡在「全站挂掉」之前的最后一道工程防线。
雪崩是怎么来的
先想清楚崩溃的链路,才知道闸该装在哪。假设数据库连接池最大 20 个连接,平时稳定在 10 个左右:
- 某个慢查询(缺索引、统计信息过期、锁等待)突然要跑 30 秒;
- 这 30 秒里它一直占着一个连接,池可用连接变成 19;
- 流量不变,越来越多请求进来,连接被慢慢借光,池耗尽;
- 池耗尽后,新请求不再拿到连接,而是在「获取连接」这一步无限等待——这是最致命的一步;
- 调用方等不及开始重试,重试又来抢连接,雪球越滚越大;
- 数据库那边慢查询还没结束,新连接请求还在堆积,CPU 和连接数双双打满,彻底失联。
注意第 4 步:如果「拿不到连接就一直等」,那么故障会从「一条慢 SQL」放大成「全站拿不到连接」。所以连接池治理的第一原则就是——永远给获取连接和每条语句都设超时,宁可快速失败,也不无限等待。
第一道闸:限制并发连接数
连接池存在的意义,就是给数据库的并发连接数设一个天花板。天花板太低,吞吐上不去;太高,数据库反而被自己压垮。经验值是:先按「数据库能稳定承受的连接数」倒推,再留出余量。
Java 的 HikariCP 用 maximumPoolSize:
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://db.internal:3306/app");
config.setUsername("app");
config.setPassword(System.getenv("DB_PWD"));
// 并发连接上限:按数据库承压能力倒推,别拍脑袋给 200
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
HikariDataSource ds = new HikariDataSource(config);
Go 的 database/sql 则是一对 SetMaxOpenConns / SetMaxIdleConns:
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
// 最多同时打开 20 个连接
db.SetMaxOpenConns(20)
// 空闲时保留 5 个,避免冷启动抖动
db.SetMaxIdleConns(5)
这两个数的核心作用:当数据库已经接近极限时,连接池替你在应用侧把压力「截断」在 20 个并发以内,而不是放任每个实例都开几百个连接把数据库冲垮。
第二道闸:拿连接的超时
上一节只限制了「最多」多少连接,但没限制「等多久」。如果池被借光,下一个请求默认行为是阻塞等待,这正是雪崩的第 4 步。必须给获取连接设一个上限。
HikariCP 用 connectionTimeout(毫秒,默认 30000):
// 拿不到连接最多等 3 秒,超时直接抛 SQLException,由上层走降级
config.setConnectionTimeout(3000);
3 秒拿不到连接,说明池已经彻底饱和,与其让请求卡在线程里白白占着资源,不如立刻失败、走熔断或返回兜底数据。这个超时通常设得比单条语句超时更短,因为它是「排队」的上限,不是「执行」的上限。
第三道闸:连接最大存活时间
这一道闸很多人会漏。数据库(如 MySQL)有 wait_timeout,超过这个时间没活动的连接会被服务端悄悄断开。如果连接池里的连接一直不回收、又不感知这个断开,就会出现:池里明明有「空闲连接」,一借出来执行就报 connection has gone away。
解决方法是让连接的最大存活时间略小于数据库的 wait_timeout:
// 连接最长活 25 分钟,小于 MySQL 默认 wait_timeout(28800s) 很多
config.setMaxLifetime(25 * 60 * 1000);
// 空闲连接超过 10 分钟回收,释放给数据库
config.setIdleTimeout(10 * 60 * 1000);
Go 侧对应 SetConnMaxLifetime 与 SetConnMaxIdleTime:
// 连接最长活 25 分钟,规避服务端静默断连
db.SetConnMaxLifetime(25 * time.Minute)
db.SetConnMaxIdleTime(10 * time.Minute)
设了这闸,连接会在到期前被池主动换掉,你永远借不到一条已经被数据库踢掉的「假空闲连接」。
语句级超时:别让慢查询占着连接
连接是稀缺资源,一条慢查询占着连接 30 秒,等于这段时间少了一个并发额度。所以还要在「语句」层面设超时,让慢查询自己先死。
JDBC 可以用 Statement.setQueryTimeout(单位秒):
try (Connection conn = ds.getConnection();
Statement stmt = conn.createStatement()) {
// 单条语句最多跑 5 秒,超时抛 SQLTimeoutException
stmt.setQueryTimeout(5);
try (ResultSet rs = stmt.executeQuery(
"SELECT * FROM orders WHERE user_id = ?")) {
// ...
}
}
MySQL Connector/J 也可以在连接串里加 socketTimeout,从网络层兜底。Go 则习惯用 context 带超时:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx,
"SELECT * FROM orders WHERE user_id = ?", uid)
if err != nil {
// context deadline exceeded 即语句超时,直接降级
return fallback(ctx, uid)
}
语句超时和获取连接超时是两层不同的闸:前者管「执行慢」,后者管「排不上队」,两者都要有。
池耗尽时快速失败,而不是堆积
三道闸装好后,池饱和时请求会快速抛异常。真正的工程闭环是:捕获这个异常,走熔断 + 降级,而不是让调用方无脑重试放大流量。
func queryWithCircuit(ctx context.Context, uid int64) ([]Order, error) {
if cb.IsOpen() { // 熔断已开,直接降级
return nil, ErrDegraded
}
rows, err := db.QueryContext(ctx, sql, uid)
if err != nil {
cb.RecordFailure() // 连续失败累积,触发熔断
return nil, err
}
defer rows.Close()
cb.RecordSuccess()
// ... 解析 rows
}
熔断的意义在于:当数据库真的扛不住时,主动拒绝一部分流量,保住它喘息和恢复的空间,而不是用重试把仅存的连接也填满。等依赖的慢查询修好、数据库恢复,再把熔断慢慢放开。
一份可直接抄的 HikariCP 配置
把前面几道闸合到一起,一份线上可用的配置长这样(数值按你的数据库承压能力调整,不要照抄 20):
HikariConfig config = new HikariConfig();
config.setJdbcUrl(jdbcUrl);
config.setUsername(user);
config.setPassword(pwd);
config.setMaximumPoolSize(20); // 并发连接上限
config.setMinimumIdle(5); // 保活空闲连接
config.setConnectionTimeout(3000); // 拿连接最多等 3s
config.setMaxLifetime(25 * 60 * 1000); // 连接最多活 25min
config.setIdleTimeout(10 * 60 * 1000); // 空闲 10min 回收
config.setConnectionTestQuery("SELECT 1"); // 借出前探活(旧驱动用)
HikariDataSource ds = new HikariDataSource(config);
配套地,在每条语句上设 5 秒 queryTimeout,在调用层接熔断。三层叠起来,「一条慢 SQL」最多影响 5 秒内的少量请求,不会再演变成全站雪崩。
小结
雪崩的本质是「无限等待」被层层放大:连接无限等、调用方无限重试、数据库无限堆积。连接池和超时的治理,就是在这条链路上装三道闸——限制并发连接数截断压力、获取连接超时拒绝无谓排队、连接存活时间规避假空闲,再补一层语句超时和熔断降级保底。它们不解决慢查询本身,但能保证一条慢 SQL 只是一条慢 SQL,而不是一次事故。