MySQL MAX_EXECUTION_TIME 杀查询后 Connector/J 有 500–2000ms「僵尸窗口」,HikariCP active 虚高导致池假满——超时栈到底怎么排?
该问题的正文、回答与讨论。
背景
我负责一个读多写少的数据分析服务:Spring Boot 3.2 + JDK 21 + HikariCP 5.1 + MySQL 8.0.36,单机 8C16G,K8s 4 副本。核心链路是「接收分析请求 → 跑 1~3 条聚合 SQL(涉及 5000 万行级宽表 JOIN)→ 返回结果集」。所有分析 SQL 我都加了 /*+ MAX_EXECUTION_TIME(5000) */ hint,JDBC URL 里配了 socketTimeout=8000&connectTimeout=3000,HikariCP 关键参数:maximumPoolSize=30、connectionTimeout=3000ms、leakDetectionThreshold=60000ms。
现象
当 MySQL buffer pool 处于 page eviction 风暴(通常发生在另一个写服务大批量 INSERT 后,干净页比例骤降)时:
- 聚合 SQL 被
MAX_EXECUTION_TIME=5000正确 kill,MySQL 侧SHOW PROCESSLIST里该 query 立刻消失(状态Killed)。 - 但应用侧的线程并没有立刻拿到异常——用 JFR 抓了
jdk.ExecutionSample+jdk.JavaMonitorEnter,发现从 MySQL kill 到 Connector/J 8.3 把SQLException(Query execution was interrupted)抛回业务线程,中间有 500ms ~ 2000ms 的延迟。这段时间内,HikariCP 的active计数仍然包含这条连接(已借出、未归还),但连接上并没有真正在跑 SQL。 - 高峰期 QPS ~600,按 Little's Law 稳态只需要
600 × 0.08s ≈ 48个并发连接(单副本 12 个),池 30 够用。但一旦 buffer pool 抖动触发多条 SQL 同时进入「僵尸窗口」,active瞬间从 12 跳到 30,后续请求全部排队connectionTimeout=3000ms后报SQLTransientConnectionException。 leakDetectionThreshold=60000ms没有触发——因为连接最终会被归还(只是晚了 0.5~2s),不属于泄漏。
已尝试的排查与结果
- 缩短
socketTimeout到 3000ms:理论上 socket read 超时应比 MySQL kill 更短、更早触发。但实测 Connector/J 8.3 在MAX_EXECUTION_TIME触发时走的是abort()路径(读com.mysql.cj.protocol.NativeProtocol#readPacket的异常分支),socketTimeout似乎没有被当作硬上限——kill 后驱动仍在等 server 发回 error packet 才抛异常,socketTimeout只在 TCP 层无响应时生效。 - HikariCP
connectionTimeout从 3000ms 缩到 500ms:排队超时变快了,但不解决根因——池还是被僵尸连接占满,只是报错更快了,用户体验从「等 3s 再 500」变成「立刻 500」,对上游调用方来说区别不大。 - 把
maximumPoolSize从 30 提到 60:僵尸窗口期间能多容纳几条,但峰值时(4 副本 × 60 = 240,MySQLmax_connections=500)逼近 DB 上限,风险更大。 - 在 SQL 层用
SELECT SLEEP(0)+ 应用侧Future.get(5s, TimeUnit.SECONDS)做超时:绕过了驱动层,但代码侵入太大,且Future.cancel(true)并不能真正中断正在read()的 socket 线程。 - MySQL 侧加
max_execution_time全局变量(而非 hint):效果一样,瓶颈不在 MySQL 侧,在驱动→应用层的异常传播延迟。
我卡住的点
核心矛盾是:MAX_EXECUTION_TIME 解决了「MySQL 不该跑超过 5s」的问题,但没有解决「驱动层在 5s~7s 之间持有连接、池被虚占」的问题。 超时栈目前是:
MAX_EXECUTION_TIME(5000) < socketTimeout(8000) < connectionTimeout(3000ms ← 这是池获取超时,不是执行超时)
connectionTimeout 管的是「拿不到连接等多久」,不是「拿到连接后 SQL 跑多久」——应用侧根本没有一个独立的「单条 SQL 执行硬超时」,完全依赖 MAX_EXECUTION_TIME + 驱动异常传播,而后者有 500~2000ms 的不可控延迟。
期望得到的答案形态
- Connector/J 8.3(或更新的 8.4 / 9.x)是否有配置项能让
socketTimeout真正作为 socket read 的硬上限(即:不管 server 是否在准备 error packet,到时间就断开、抛SocketTimeoutException)?如果驱动层面做不到,有没有已知的 workaround(比如换 MariaDB Connector/J、用Statement.setQueryTimeout())? - HikariCP 有没有(或社区 PR 有没有)「单连接借出后的最大持有时间」参数——类似
maxBorrowTime,到期后强制close()并标记为 broken,从而把僵尸连接从active里踢出去?如果没有,有没有人在生产里用ConnectionProxy/StatementInterceptor自己实现过这种「借出超时强制归还」? - 超时栈的正确排序原则:在「应用层 SQL 执行超时 < socket read 超时 < MySQL
MAX_EXECUTION_TIME」和「MySQLMAX_EXECUTION_TIME< socket read 超时 < 应用层 SQL 执行超时」两种排法里,生产环境哪种更稳?有没有人踩过「短超时层没生效、长超时层先触发」的坑? - 如果社区有同学遇到过完全一样的「buffer pool 抖动 → 批量 SQL 被 kill → 驱动僵尸窗口 → 池假满」链路,欢迎贴当时的
SHOW ENGINE INNODB STATUS、HikariCP metrics 快照和最终解法。
引用了两篇社区已有讨论作为背景参考:谢寒山那篇「Little's Law 定池大小」和超时预算分配那篇,我的场景是在它们基础上多了一层「驱动层异常传播延迟」的不可控因素。