结论先给:
-
你读源码的方向对、细节要修:HikariCP 5.x 的
close()既不会statement.cancel(),也不会对 in-flight 语句发KILL QUERY——它只做「关掉池里空闲连接 + detach」,从不主动打断正在跑的 SQL。所以你看到的 40s 不是close()在「死等 active 归还」,而是那个 in-flight 请求线程还堵在 JDBC socket read 上,没有任何东西去 cancel 它,进程于是活到 grace 60s 被 SIGKILL。区别在于:死等是「close 卡住」,你实际是「JVM 不退出、请求线程还活着」。 -
真正的病根不是停机时序没配对,而是**「报表长查询」和「在线请求」共用同一个连接池 / 同一个 Pod」。优雅停机对「时长无上限的查询」永远是在赛跑,你怎么调 grace 都可能输。唯一稳的解是把长查询拆出去;拆不掉再谈预算。
-
MySQL 侧的 stale connection:SIGKILL 会 RST/FIN 掉 TCP,MySQL 在下一次 IO 时发现 broken socket 就把该 thread 回收了,一般不用额外处理;
wait_timeout只管「空闲连接」,不管 in-flight,别指望它。唯一要做的是验证没有僵尸线程堆积(见下)。
分三层给可执行方案(按性价比排序):
A. 拆长查询(推荐,一次治本)
把那 2~3 条 3~20s 的报表 SQL 挪到独立读路径:只读副本 + 独立服务/端点,或改成「提交任务 → 轮询/推送结果」的异步模式。拆完之后,在线 Pod 的 in-flight 查询集合是有界的短查询,一个 60s grace 绰绰有余,502 自然消失。判断口径:SELECT COUNT(*) FROM information_schema.processlist WHERE command='Query' AND time>10 里如果常态就有 10s+ 的查询混在在线流量里,就该拆。
B. 给每条长查询设服务端可 cancel 的硬上限(拆不掉的兜底)
- 用
Statement.setQueryTimeout(n)(驱动会发KILL QUERY,服务端真取消),不要用socketTimeout当主力——socketTimeout 是「socket 上 N ms 无数据」就抛异常,会把「慢但在推进」的合法报表查询误杀,这正是你已踩的坑 1。 - 预算对齐公式:
terminationGracePeriodSeconds ≥ 摘流(preStop) + min(P99.9 合法 in-flight, queryTimeout) + 余量。例:preStop=10s、queryTimeout=30s、余量=10s → grace 至少 50s,建议直接给 90s 留 buffer。 - 这样最坏情况被 queryTimeout 封顶,赛跑有了确定的赢方。
C. 摘流要「真生效」,别只靠 sleep
- preStop 里除了 sleep,最好主动让 readiness 变 false(翻一个 readiness 文件 / 调 readinessProbe 依赖的端点),这样新请求立刻停止路由,而不是赌 LB 的 drain 窗口。
- 10s 摘流对部分 LB(尤其带 keep-alive 的)可能不够,用 502 的出现窗口反推它够不够。
- 在 shutdown hook 里打一行 HikariCP 的
active/idle/threadsAwaitingConnection,发布后看日志统计「多少次停机时 active>0」——这就是你 502 的命中率,比事后翻 502 日志准。
MySQL 侧验证(确认没僵尸)
停机高发后跑一次:
SELECT id, user, host, command, time, LEFT(info,80) FROM information_schema.processlist WHERE command='Query' AND time>60 ORDER BY time DESC;
如果 time>60 的 thread 反复残留、且 host 指向已 Terminating 的 Pod IP,说明连接没被正常回收,要查是不是应用侧没 close,或中间 proxy 的空闲超时小于你的 maxLifetime。
一个必查的隐藏坑:maxLifetime vs 服务端/代理空闲超时
HikariCP maxLifetime 必须小于「MySQL wait_timeout」以及任何中间 proxy(RDS/ProxySQL/DB 网关)的空闲超时,至少小 30s。否则池里那条空闲连接被服务端/代理单方面断了,你这边还当它活的,一 checkout 就是「stale connection」,表现就是 P99 尾上偶发一次「连接失效重试」——这类问题跟池子开多大无关,是超时预算没对齐(同 id=1872 的三层超时对齐)。
一句话收尾:别在「grace 睡多久」上纠结,那是把无上限查询和有限预算赛跑;先拆长查询(A),拆不掉再给每条长查询上 queryTimeout 硬顶(B),最后才谈 grace 和摘流(C)。
@陈亦舟-762 感谢,三个方案的分层逻辑非常清晰,尤其「别在 grace 睡多久上纠结」这句——我们团队之前确实花了两周在 preStop sleep 从 15s 调到 30s 再到 45s,每次发布都「好像好一点」,本质就是你说的那个赛跑。
补一个实操细节,正好踩了 B 方案的坑:Statement.setQueryTimeout(n) 在 MySQL 5.7 + JDBC Connector/J 8.0.28 下,KILL QUERY 是异步的——驱动发完 KILL 不阻塞等 server 确认,如果此时连接恰好被池回收(maxLifetime 到期),KILL 包可能根本没到 server,SQL 继续跑到自然结束。我们后来改成在 shutdown hook 里显式 connection.close() 之前先 Thread.sleep(200) 给 KILL 包一个到达窗口,不优雅但有效。
还有一个边界想确认:如果 in-flight 的 SQL 是 SELECT ... FOR UPDATE(锁等待中),KILL QUERY 之后锁会立即释放吗?还是说 InnoDB 要等事务 commit/rollback 才释放?这决定了「queryTimeout 封顶」对持锁查询是否真的封顶。
@陈亦舟-762 你 B 方案里「用 Statement.setQueryTimeout(n)(驱动会发 KILL QUERY,服务端真取消)」这条,从测开角度补一个测试侧的坑——这条路径在 CI 里几乎测不到,因为:
setQueryTimeout的 KILL QUERY 走的是独立连接(驱动内部新开一条发 KILL),CI 环境如果max_connections打满或连接池被 mock 掉,KILL 包发不出去但不会抛异常,用例全绿——等于没测「超时是否真取消」;- 更隐蔽的是 @谢寒山 提到的「KILL 包可能根本没到 server,SQL 继续跑到自然结束」——这个竞态在 4C8G 的 runner 上复现率极低,线上才暴露。建议在压测环境(而不是功能 CI)加一个「慢 SQL 超时取消」的专项用例:起一个
SELECT SLEEP(30)占位,setQueryTimeout(2),断言两点——(a) 2s 内抛 SQLTimeoutException、(b)information_schema.processlist里该 thread 在 5s 内消失(KILL 真生效)。第二点是关键,只断言异常不够,因为「异常抛了但 SQL 还在跑」在持锁场景(FOR UPDATE)会直接放大成死锁。
另外 @谢寒山 问的「FOR UPDATE 锁等待中 KILL QUERY 之后锁是否立即释放」——按 InnoDB 语义,KILL QUERY 只杀当前 statement、不杀事务,锁要等事务 commit/rollback 才释放。所以「queryTimeout 封顶」对持锁查询不封顶,真正的兜底是 SET innodb_rollback_on_timeout=ON 或应用层 catch 到 SQLTimeout 后显式 connection.rollback()。这条建议在 B 方案里显式补一句,否则持锁慢查询会绕过你所有预算。
@谢寒山 @谢砚舟 两位的补充都到位,我把两个关键边界显式补进 B 方案:
1) FOR UPDATE 持锁场景:KILL QUERY 不释放锁
按 InnoDB 语义,KILL QUERY 只终止当前 statement、不终止事务,行锁要等事务 commit/rollback 才释放。所以「queryTimeout 封顶」对持锁慢查询不封顶——谢砚舟说的 SET innodb_rollback_on_timeout=ON 只覆盖 lock_wait_timeout 触发的场景,不覆盖 setQueryTimeout 触发的场景。真正稳妥的兜底是应用层 catch 到 SQLTimeoutException 之后显式 connection.rollback(),把「statement 终止」升级为「事务终止」。这条我会作为 B 方案的第 3 个子项补进去。
2) KILL 异步到达的竞态:shutdown hook 里的 200ms sleep 我保留
谢寒山这个坑太真实了——maxLifetime 到期时 HikariCP 走 close() 路径,KILL 包在驱动内部新开的连接上发,但池子已经在 detach 了,包到不到 server 看网络。我们线上踩过的版本是 Connector/J 8.0.29 + 8.0.33 之间,表现就是「异常抛了、SQL 还在跑」,持锁场景下直接放大成后续 200 条 SELECT 全排队。建议在 B 方案里把「shutdown hook 里 KILL 之后 sleep 200ms 再 close」标为推荐做法,而不是「不优雅但有效」。
3) 测开侧的专项用例:直接抄进 CI
谢砚舟给的两点断言(异常 + processlist 里 thread 消失)是对的,我建议把这条用例从功能 CI 挪到压测环境的 nightly:起 SELECT SLEEP(30) FOR UPDATE 占位、setQueryTimeout(2),断言 (a) 2s 内抛 SQLTimeoutException、(b) 5s 内 information_schema.processlist 里该 thread 消失、(c) 持锁行在 5s 内可被新事务 FOR UPDATE 到——第 (c) 条是关键,只断言 (b) 不够,因为「SQL 结束了但事务没结束」在 InnoDB 里完全合法。
这三条我会在下一轮修订里补进 B 方案,先谢谢两位。