一次发布引入的连接未释放,如何把“数据库变慢”的误判纠正为应用侧连接池泄漏。
某订单服务发布后约二十分钟,接口 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,又准备加索引,但都没有触及问题。
推翻这个判断的证据有三条:
idle in transaction 会话是另一条长事务留下的噪声,不是本次连接耗尽的主因。决定性证据来自应用侧:连接池 active 等于上限、idle 为 0,线程栈大量阻塞在 HikariPool.getConnection,而数据库端对应会话却是空闲的。再对照发布 diff,发现新接口在异常分支提前返回,连接没有关闭。至此“数据库慢”的假设被完全排除。
直接原因是新版本新增的批量查询接口手工从 DataSource 获取连接,在查询异常或超时分支直接 return,没有在 finally 中关闭 Connection、Statement 和 ResultSet。连接对象离开业务方法后仍被连接池视为“已借出”,数据库会话一直保持 idle,直到进程重启或连接被人工终止。
促成条件有四个:
leakDetectionThreshold 为 0(未启用),泄漏只能等到耗尽才暴露。maximumPoolSize 设置偏大,而数据库 max_connections 没有按“所有服务副本数 × 池大小 + 管理余量”做预算。扩容时连接总数直接突破上限。idle_in_transaction_session_timeout,长事务和空闲会话缺少兜底回收;这虽然不是本次主因,但延长了恢复时间。需要区分“泄漏”和“长事务”:泄漏是连接借出后永远不还,池子 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、发送消息或做长时间计算;为下游调用设置独立超时,且总预算小于连接池等待时间。
连接池参数按实际负载设置,而不是照抄模板:
maximumPoolSize 与副本数、数据库连接预算一起计算,HPA 的最大副本数也必须纳入公式。connectionTimeout 要短于上游超时,让失败快速暴露。leakDetectionThreshold 设为高于正常最长查询的阈值,用于日志告警,不能替代修复。maxLifetime 应短于数据库或连接代理的空闲断开时间,避免拿到已被服务端关闭的连接。idleTimeout、keepaliveTime 按数据库和网络环境调整。数据库侧增加保护:为每个服务使用独立账号并设置 CONNECTION LIMIT,设置 statement_timeout 和 idle_in_transaction_session_timeout,让失控连接无法无限占用资源。连接数需求较大的场景可以引入 PgBouncer 做事务级池化,但要注意 session 级特性、预处理语句和 LISTEN/NOTIFY 的兼容性。
所有副本数 × 每 Pod 池大小 + 其他服务 + 管理保留 必须小于数据库上限,并留出故障切换和扩容余量。leakDetectionThreshold 和 statement_timeout 纳入服务模板,在测试环境用故障注入验证异常分支不会泄漏。getConnection() 后是否在所有路径关闭;优先使用框架托管连接。Too many connections 的处理合上页面,先回答,再展开对照。
ClientRead),说明连接被应用借走却没有继续使用,符合池泄漏特征。max_connections。预算应为所有副本数 × 每 Pod 池大小,加上其他服务和管理保留,并留出扩容与故障切换余量。leakDetectionThreshold、连接预算、数据库侧超时和连接上限,并用故障注入验证异常分支会关闭连接。
小程序版
微信扫码,随时查命令