Too many connections:486 个 Sleep 连接,把数据库占满了
早高峰连接数打满,CPU 却只有 22%。486 个连接里 402 个在 Sleep,全来自同一个导出接口:Connection 关了,ResultSet 没关。连接预算怎么算、四类连接怎么分、kill 的顺序,以及 HikariCP 的泄漏检测。
早上九点十分,监控群开始刷屏:订单服务报 Communications link failure,网关 5xx 从 0.02% 涨到 18%。第一反应是数据库被打挂了,登上去看,CPU 22%,QPS 和昨天同一时间几乎一样。
真正报错的位置在应用侧连接池:拿不到连接。Threads_connected 是 486,max_connections 是 500。数据库不是被压垮的,是被「占满」的。
SHOW PROCESSLIST 排在前面的几十行全是 Sleep,最长的一条挂了 3 小时 47 分钟。那一刻我基本确定,这不是容量问题,是有人借了连接没还。
一、现象:连接数满了,业务量却没涨
先把故障时段的几个数放在一起看,方向就出来了:
| 指标 | 昨天同一时间 | 故障时 |
|---|---|---|
| QPS | 3 200 | 3 150 |
| 主库 CPU | 20% | 22% |
| Threads_connected | 180 | 486 |
| Threads_running | 12 | 9 |
| 网关 5xx 比例 | 0.02% | 18% |
Threads_running 只有 9,意思是「此刻正在执行 SQL 的连接」不到十个;486 个连接里绝大多数在 Sleep。这个对比排除了慢查询堆积,也排除了流量突增——真要是请求太多,Threads_running 会跟着涨。
剩下的解释只有一个:连接被借出去,用完没还,池子里可用的被耗光,后来的请求就等 connectionTimeout,等不到抛异常。
SHOW VARIABLES LIKE 'max_connections';
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Threads_running';
SHOW STATUS LIKE 'Aborted_connects';
-- 按用户和来源统计,看是谁占的
SELECT user, SUBSTRING_INDEX(host, ':', 1) AS client_ip, COUNT(*) AS conns
FROM information_schema.processlist
GROUP BY user, client_ip
ORDER BY conns DESC;
Aborted_connects 在故障期间涨了 3 万多,说明大量 TCP 上来之后连握手都没完成就被拒了,这是连接数打满的连带伤害。
二、先算账:这个池子本来该配多大
在怀疑代码之前,我习惯先把预算算一遍。很多「连接数打满」其实是配置从一开始就超卖了:
| 服务 | 实例数 | maxPoolSize | 连接小计 |
|---|---|---|---|
| order-api | 12 | 30 | 360 |
| user-api | 6 | 20 | 120 |
| job-worker | 4 | 30 | 120 |
| 报表/离线任务 | 3 | 10 | 30 |
| 合计 | 630 |
max_connections 是 500,所有客户端加起来要 630。也就是说只要业务全起来,必然有一部分客户端连不上——平时没炸,只是报表任务大多在凌晨跑,白天没和主流量撞上。这次有人手动触发了一次数据导出,三个报表实例各开 10 个连接不释放,把余量吃干了。
我当时选择先压连接池,而不是调大 max_connections:每个连接都要占线程栈和会话缓冲,盲目翻倍只会把「连不上」变成「连上了但在 swap」。
# /etc/my.cnf
[mysqld]
max_connections = 800
max_user_connections = 200
wait_timeout = 600
interactive_timeout = 600
wait_timeout 从默认 28800(8 小时)降到 600,是这次最有用的一个动作:它让被借走却没人管的连接在十分钟后被服务端回收,而不是挂一整天。
三、Processlist 里的四类连接,各查各的
同样是「连接多」,原因完全不同。我用一条 SQL 把长时间 Sleep 的连接捞出来,再按状态分类:
SELECT id, user, host, db, command, time, state, LEFT(info, 60) AS sql_head
FROM information_schema.processlist
WHERE command = 'Sleep' AND time > 60
ORDER BY time DESC
LIMIT 30;
- 长时间 Sleep。 典型的应用侧泄漏,连接池借出没归还。这次就是这一类。
Waiting for table metadata lock。 有人开了长事务没提交,后面的 DDL 全卡在这个表上,连接越积越多。Binlog Dump。 从库或 CDC 组件在拉 binlog,属于正常连接,不要误杀。Query且time很大。 真慢查询,去慢日志里看执行计划,和连接池无关。
我这次的 486 个连接里,第一类 402 个,第二类 71 个,第三类 8 个,第四类 5 个。第二类那 71 个是另一个隐患:有个定时任务在 ALTER TABLE 加索引,被一个未提交的批量更新挡了四十多分钟。
四、根因:一个没有关闭的 ResultSet
把第一类连接按 host 聚合,402 个里有 340 个来自 order-api。翻代码,问题在「导出订单明细」这个接口:
// 错误写法:Connection 关了,Statement / ResultSet 没关
public List<Order> exportOrders(LocalDate day) throws SQLException {
Connection conn = dataSource.getConnection();
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT * FROM orders WHERE day = '" + day + "'");
List<Order> list = new ArrayList<>();
while (rs.next()) {
list.add(map(rs));
}
conn.close(); // 只关了 Connection
return list;
}
这里有个容易忽略的细节:连接池返回的 Connection 是代理对象,conn.close() 不是真断开 TCP,而是把连接还给池子。但如果 ResultSet 还开着,某些驱动实现在归还前会做隐式清理,一旦这次清理抛异常,连接就卡在「已借出但未归还」的状态里,池子认为它还在用。
并发一高,getConnection() 的等待会互相叠加,connectionTimeout 一到就抛异常,而前面那些连接还挂在池子的活跃计数里。
改成 try-with-resources,让三层资源按逆序关闭:
public List<Order> exportOrders(LocalDate day) throws SQLException {
String sql = "SELECT id, order_no, amount, status FROM orders WHERE day = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setDate(1, java.sql.Date.valueOf(day));
ps.setFetchSize(500);
try (ResultSet rs = ps.executeQuery()) {
List<Order> list = new ArrayList<>();
while (rs.next()) {
list.add(map(rs));
}
return list;
}
}
}
顺手把 Statement 换成 PreparedStatement,fetchSize 设 500,避免一次把几十万行全拉进内存。HikariCP 那边把泄漏检测开上:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000
max-lifetime: 1200000
leak-detection-threshold: 20000
leak-detection-threshold: 20000 表示连接借出超过 20 秒没归还就打印堆栈。上线第二天抓到两处,都是别的接口在 finally 里只关了 ResultSet 忘了关 Statement。
五、修复顺序:先止血,再改代码,最后收参数
顺序错了会二次伤害,故障当时我是这么排的:
第一步,杀掉明确的泄漏连接。 只杀 Sleep 且 time > 600、来源是应用机器的,不碰 Binlog Dump:
# 先只生成语句,看一眼数量
mysql -uroot -p -N -e "
SELECT CONCAT('KILL ', id, ';')
FROM information_schema.processlist
WHERE command = 'Sleep' AND time > 600 AND user = 'app_rw';" > /tmp/kill.sql
wc -l /tmp/kill.sql # 几百条以内再执行
mysql -uroot -p < /tmp/kill.sql
一次性 KILL 上千条会瞬间释放大量会话内存,我一般分批,每批 100 条,中间停两秒。
第二步,临时压低池子上限。 order-api 从 30 降到 15,实例数不变,总连接从 630 掉到 420,水位回到 70% 以下。
第三步,改代码。 就是上面那个 try-with-resources。
第四步,收服务端参数。 wait_timeout = 600,max_user_connections = 200 给单应用设上限,防止一个服务把池子吃光。
六、防复发:把「连接占用率」做成告警
之前只有「CPU > 80%」和「慢查询数 > 10」两个告警,连接数打满这种事两个都不触发。补了三条:
mysql -uroot -p -N -e "
SELECT
(SELECT VARIABLE_VALUE FROM performance_schema.global_status
WHERE VARIABLE_NAME='Threads_connected')
/ (SELECT VARIABLE_VALUE FROM performance_schema.global_variables
WHERE VARIABLE_NAME='max_connections') * 100 AS conn_pct;"
| 告警项 | 阈值 | 说明 |
|---|---|---|
| 连接占用率 | > 70% 持续 3 分钟 | 提前量足够,能赶在打满前处理 |
| Sleep 超 600 秒的连接数 | > 50 | 泄漏的早期信号 |
Aborted_connects 增速 |
> 100/分钟 | 已经开始拒连 |
| 单实例连接数 / 池上限 | > 90% | 应用侧池子也满了 |
另外把「连接预算表」写进了发布检查单:新增服务上线前要登记实例数和 maxPoolSize,总和不得超过 max_connections 的 80%。这条规矩比监控更管用——它让超卖在评审阶段就暴露,而不是在早高峰。
小结
- 先看
Threads_running:它很低而Threads_connected很高,说明是泄漏而不是压力。 - 配连接池前先算总账,所有客户端
maxPoolSize × 实例数之和别超过max_connections的 80%。 processlist里的 Sleep、metadata lock、Binlog Dump、长 Query 是四种病,别用一种药。- 代码侧用 try-with-resources 管住三层资源,配合
leak-detection-threshold让泄漏自己暴露。 wait_timeout从 8 小时降到 10 分钟,是成本最低的一道兜底。- 监控要盯「连接占用率」,CPU 平静的连接数故障最容易被漏掉。
正在加载评论…