系统学习 故障排查学习路径:从方法论到系统、网络、数据库与 K8s

incident-db-conn-pool

一次发布引入的连接未释放,如何把“数据库变慢”的误判纠正为应用侧连接池泄漏。

现象

某订单服务发布后约二十分钟,接口 P99 从百毫秒级升到数秒,随后 5xx 快速上升。应用日志开始出现连接池等待超时:

HikariPool-1 - Connection is not available, request timed out after 30000ms.

数据库侧同时出现 FATAL: sorry, too many clients already,与订单服务共用同一实例的其他服务也开始连接失败。奇怪的是,数据库 CPU 只有一成多,磁盘 I/O 和慢查询日志都没有明显变化;应用却是“线程都在、请求不动”。

连接数曲线很有辨识度:发布完成后呈阶梯上升,每一波流量高峰抬高一个台阶,之后从不回落;重启 Pod 会短暂下降,但很快又爬到上限。连接池指标显示 active 长期等于 maximumPoolSize,idle 为 0,等待获取连接的线程持续增加。

时间线

时间 事件
T+0 新版本完成滚动发布,健康检查通过
T+8m P99 与 5xx 告警,值班开始排查
T+12m 先查数据库:CPU、I/O 正常,慢查询日志无新增,但仍按“慢 SQL”方向处理
T+18m 临时调大 max_connections,连接数继续上涨,只买到几分钟缓冲
T+24m 扩容应用副本试图分摊流量,连接总数反而增长更快,故障范围扩大
T+30m 查看 pg_stat_activity 的状态分布,发现大量会话处于 idle,等待事件是 ClientRead
T+36m 抓取线程栈与连接池指标,确认线程阻塞在获取连接,连接池已无空闲连接
T+42m 回滚应用版本,按批次重启 Pod 释放连接
T+50m 清理残留的空闲会话,数据库连接数回落到基线
T+68m 错误率恢复,积压请求消化完成
T+2h 修复代码,补连接预算与监控

影响面

订单写入失败约三十多分钟,部分请求在客户端重试后成功,少量请求需要人工对账。由于多个服务共用数据库实例,连接被占满后发生了级联失败:即使某个服务自身没有泄漏,也会因为拿不到连接而报错。读接口和后台任务同样受影响,消息队列出现积压。

扩容副本的操作在短时间内让情况更糟,因为每个 Pod 都会建立自己的连接池,副本数增加意味着数据库侧连接总数按倍数增长。事故没有造成数据丢失,但重试流量放大了数据库压力,恢复后还需要检查重复下单和幂等性。

误导线索

最初的判断是“数据库慢导致请求堆积”,因为告警同时出现 P99 上升和连接数上涨,而且 pg_stat_activity 顶部确实有几条运行时间很长的 SQL。团队先调大 max_connections,又准备加索引,但都没有触及问题。

推翻这个判断的证据有三条:

  1. 数据库资源与“慢”不匹配。 CPU 只有一成多,磁盘延迟正常,慢查询日志没有新增;如果真是慢 SQL,通常会看到 active 会话占满、CPU 或 I/O 饱和,而不是大量会话空闲。
  2. 会话状态是 idle,等待事件是 ClientRead。 这说明数据库已经执行完上一条命令,正在等客户端发送下一条命令。数据库在等应用,不是应用在等数据库。少数 idle in transaction 会话是另一条长事务留下的噪声,不是本次连接耗尽的主因。
  3. 扩容副本让连接数按 Pod 数线性增长。 如果是流量导致的连接不足,扩容应该缓解;实际结果是每个新 Pod 都再开一组连接,说明连接没有被归还,池子上限只是被复制了更多份。

决定性证据来自应用侧:连接池 active 等于上限、idle 为 0,线程栈大量阻塞在 HikariPool.getConnection,而数据库端对应会话却是空闲的。再对照发布 diff,发现新接口在异常分支提前返回,连接没有关闭。至此“数据库慢”的假设被完全排除。

根因

直接原因是新版本新增的批量查询接口手工从 DataSource 获取连接,在查询异常或超时分支直接 return,没有在 finally 中关闭 Connection、Statement 和 ResultSet。连接对象离开业务方法后仍被连接池视为“已借出”,数据库会话一直保持 idle,直到进程重启或连接被人工终止。

促成条件有四个:

需要区分“泄漏”和“长事务”:泄漏是连接借出后永远不还,池子 active 打满而数据库会话空闲;长事务是连接仍在事务中(典型状态是 idle in transaction)或长时间 active,数据库端能看到事务尚未结束。两者的止血动作不同,混在一起会误判。

修复

止血顺序是“先停止新增泄漏,再释放已泄漏连接,最后修代码”:

# 1. 回滚到上一个稳定版本
kubectl -n order rollout undo deploy/order-svc
kubectl -n order rollout status deploy/order-svc

# 2. 查看连接池与线程状态(若启用了 actuator)
kubectl -n order exec deploy/order-svc -- \
  curl -s localhost:8080/actuator/metrics/hikaricp.connections.active
kubectl -n order exec deploy/order-svc -- \
  curl -s localhost:8080/actuator/metrics/hikaricp.connections.pending

# 3. 确认 Java 进程为 PID 1 时抓线程栈
kubectl -n order exec deploy/order-svc -- jstack 1 | grep -A 20 'HikariPool'

数据库侧先看状态分布,再决定是否终止会话:

psql -h <db-host> -U monitor -d postgres -c \
  "select state, count(*) from pg_stat_activity group by 1 order by 2 desc;"

psql -h <db-host> -U monitor -d postgres -c \
  "select application_name, client_addr, state, count(*),
          max(now() - state_change) as max_idle
     from pg_stat_activity
    group by 1,2,3 order by 4 desc limit 20;"

psql -h <db-host> -U monitor -d postgres -c \
  "select pid, usename, application_name, state, wait_event_type, wait_event,
          now() - state_change as since
     from pg_stat_activity
    where state <> 'active' order by since desc limit 20;"

只有在确认会话属于该服务、处于 idle 且不在事务中时,才按应用名批量终止,避免误杀其他业务。前提是连接串设置了 application_name;如果没有设置,先用 client_addr 映射到 Pod IP 再判断:

psql -h <db-host> -U monitor -d postgres -c \
  "select pg_terminate_backend(pid)
     from pg_stat_activity
    where application_name = 'order-svc'
      and state = 'idle'
      and state_change < now() - interval '5 minutes';"

MySQL 环境对应的检查方式:

mysql -h <db-host> -u monitor -p -e \
  "show status like 'Threads_connected';
   show variables like 'max_connections';
   select id, user, host, db, command, time, state, info
     from information_schema.processlist
    order by time desc limit 20;"

代码修复要落在资源管理上:用 try-with-resources 或框架托管的事务模板,确保异常路径也关闭连接;禁止在持有连接时调用外部 HTTP、发送消息或做长时间计算;为下游调用设置独立超时,且总预算小于连接池等待时间。

连接池参数按实际负载设置,而不是照抄模板:

数据库侧增加保护:为每个服务使用独立账号并设置 CONNECTION LIMIT,设置 statement_timeout 和 idle_in_transaction_session_timeout,让失控连接无法无限占用资源。连接数需求较大的场景可以引入 PgBouncer 做事务级池化,但要注意 session 级特性、预处理语句和 LISTEN/NOTIFY 的兼容性。

预防

相关命令

参考链接

读完自测

合上页面,先回答,再展开对照。

  1. 为什么“数据库 CPU 不高 + 会话大量 idle”更像应用连接池泄漏,而不是数据库慢?
  2. 扩容应用副本为什么可能让连接耗尽更严重?连接预算应该怎么算?
  3. 回滚并重启 Pod 后,还需要做哪些事才能让连接数立即回落并避免复发?
答案
  1. 数据库慢通常表现为 active 会话多、CPU 或 I/O 饱和、慢查询日志增长;这里是数据库执行完命令后等待客户端(ClientRead),说明连接被应用借走却没有继续使用,符合池泄漏特征。
  2. 每个副本都有自己的连接池,扩容会让数据库侧连接总数按副本数线性增加,可能直接突破 max_connections。预算应为所有副本数 × 每 Pod 池大小,加上其他服务和管理保留,并留出扩容与故障切换余量。
  3. 回滚代码、重启 Pod 释放已借出的连接;确认并终止残留的空闲会话;补上 leakDetectionThreshold、连接预算、数据库侧超时和连接上限,并用故障注入验证异常分支会关闭连接。
微信小程序码:扫码打开命令速查小程序

小程序版

微信扫码,随时查命令