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 平静的连接数故障最容易被漏掉。
查看「数据库与缓存」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。