☰
mysql2事务裸return不回滚:连接池全池卡死复盘
2026/10/2 13:50:17 网站建设 项目流程

一、现象:偶发的"全接口超时",重启进程立刻恢复

先交代来源:这个问题是我在做一款本地化部署的微信自动回复工具时踩到的。服务端是 Node.js,数据访问层用 mysql2 的连接池(mysql2/promise),业务里有一批多表写入的操作走事务——比如消息应答时要同时更新会话状态、写入应答流水、递增计数。

故障形态非常典型:偶发的全接口超时。平时接口都在两位数毫秒档位响应,但每隔一两天,会突然出现一波"所有接口集体变慢直至超时"的窗口——不是某一个慢接口拖累别的,而是包括纯读接口在内的所有请求一起超时。这个窗口持续几分钟到十几分钟不等,通常自己恢复,偶尔恢复不了,得重启 Node 进程才彻底缓解。重启之后一切正常,仿佛无事发生。

这类"重启就好"的问题最容易被人用一句"偶发抖动"糊弄过去,但有一个观测结果让我没法这么交代:窗口期内,Node 进程本身是活的——日志在滚、健康检查接口(不碰数据库的那个)毫秒级响应;而数据库层面,MySQL 自己也活着,手工连上去执行SELECT 1秒回。两头都活,中间死,那问题就只能出在两者之间那条路上——对这套架构来说,就是连接池。

1.1 第一个量化观测:池在等,数据库不忙

故障窗口期内,我们抓了两边的关键指标:

观测点平时故障窗口期
接口 P99 耗时~120ms>15s(网关超时)
MySQL CPU<10%<10%(无异常)
MySQL 慢查询日志无新增无新增
连接池已出借连接数峰值 8/1010/10,长期不还
新请求的getConnection()即时挂起,永不返回
SHOW PROCESSLIST空闲为主10 个 Sleep 连接

表格里的关键一行是最后第二行:getConnection()挂起不返回。这解释了为什么"全接口"超时——连不执行任何 SQL 的代码路径只要中途要拿连接,也会被卡死在池的排队环节。同时也解释了为什么 MySQL 那边风平浪静:10 个连接全部处于 Sleep 状态,没有查询在跑,没有 CPU 消耗,慢查询日志自然一条都不会有。数据库不忙但池耗尽,这是一个非常明确的信号:连接被借走之后,没有归还。

1.2 第二个观测:information_schema.innodb_trx里有"长寿事务"

连接不归还是一回事,但"为什么重启之前有时自己恢复不了"还需要解释。趁故障窗口,我手工连上 MySQL 执行了那次后来成为定位关键的查询:

-- 看当前所有 InnoDB 事务,按运行时长倒序SELECTtrx_id,trx_state,trx_started,TIMESTAMPDIFF(SECOND,trx_started,NOW())AStrx_age_sec,trx_rows_locked,trx_rows_modified,trx_queryFROMinformation_schema.innodb_trxORDERBYtrx_started;

结果里躺着一条运行了1400 多秒的事务——二十多分钟,trx_rows_locked不为零,trx_query却是 NULL(事务开着,但当前没有在执行的语句)。再对照SHOW PROCESSLIST,它对应的线程正 Sleep。这个组合含义非常具体:有一条事务在几分钟前被打开,一条语句执行到一半就再也没了下文,事务没有提交也没有回滚,就这么敞着口挂在那里。它持有的行锁没有被释放,任何后来要改这些行的事务,都会排队等待——等待链一长,业务写入全部超时,这就是"偶尔重启前也恢复不了"的原因:只要那条僵尸事务还在,被它锁住的行就一直锁着。

两条线索合流:池耗尽(连接不还)+ 长寿事务(事务不收口)。接下来要找的,是一段既"借了连接不还"、又"开了事务不收口"的代码。这里先给出整条因果链的骨架,后面逐环验证:某条业务路径开着事务提前返回 → 触发finally把连接还进池 → 事务和行锁还挂在连接上 → 下一个租客复用这条"假空闲"连接 → 要么自己卡在行锁上,要么把残留事务继续往下传 → 可用干净连接越来越少 →getConnection()全线排队 → 全接口超时。看懂了骨架,排查就是在每一环上找证据。

二、排查:从事务表反查到那段 12 行的问题代码

2.1 先厘清一个容易误判的方向:不是数据库锁风暴

故障窗口期最反直觉的地方在于:现象像"数据库死锁",但数据库侧毫无动静。如果把方向押在 MySQL 上——查死锁日志、查锁等待图、查慢查询——会一无所获:SHOW ENGINE INNODB STATUS里没有新的死锁记录,data_locks里没有大面积锁等待,性能视图里的写入量平稳。原因前面已经提到:卡住的不是数据库的处理能力,而是 Node 侧拿到连接这件事本身。数据库视角看,那 10 个连接安安静静地 Sleep,没有人找它干活。这个"数据库很闲但业务很卡"的错位,本身就是排除数据库侧问题的第一证据,也是把视线推向连接池的转折点。

2.2 顺藤摸瓜:从innodb_trx反查业务表

innodb_trx能看到事务在锁哪些行,顺着trx_rows_locked对应的表和行,再结合故障时间窗的业务日志,反查到了出问题的业务函数。这是一段消息应答链路里的"领取会话"逻辑,改动前的原始形态:

// 反面教材:改动前的原始代码asyncfunctionclaimSession(sessionId,workerId){constconn=awaitpool.getConnection();// 1. 从池里借连接try{awaitconn.beginTransaction();// 2. 开事务const[rows]=awaitconn.query('SELECT * FROM session WHERE id = ? FOR UPDATE',[sessionId]);if(!row_valid(rows)){return'already_claimed';// 3. 裸 return:灾难点}awaitconn.query('UPDATE session SET owner = ?, status = ? WHERE id = ?',[workerId,'claimed',sessionId]);awaitconn.query('INSERT INTO claim_log(session_id, worker_id) VALUES (?, ?)',[sessionId,workerId]);awaitconn.commit();// 4. 只有这条正路会提交return'ok';}catch(err){awaitconn.rollback();throwerr;}finally{conn.release();// 5. 连接"总是"会还——但这不够}}

finally里有conn.release(),看起来连接总会归还,为什么池还会耗尽?这正是本题最反直觉、也最值得写清楚的地方。

2.3 关键机制:release 归还的是"连接",不是"干净状态"

逐条拆解那个裸 return 的后果链。为了叙述方便,先把故障时刻两个并发的请求摆出来:请求 A 走到裸 return 分支,请求 B 在几毫秒后到达,从池里借走同一条被 A 归还的连接。

  1. beginTransaction()已经在连接上发出START TRANSACTION,这条连接从此进入"显式事务打开"状态;
  2. SELECT ... FOR UPDATE对会话行加了排他行锁——锁是跟着事务走的,事务不结束,锁不释放;
  3. 裸 return 触发finally,conn.release()执行,连接回到池里。但 MySQL 侧的事务还开着,行锁还挂在上面;
  4. 请求 B 从池里借到这条"假空闲"连接。它在上面执行的第一条语句,实际上跑在 A 留下的那条没结束的事务里——后续它自己beginTransaction时,会接到这条旧事务上(同一条连接上重复START TRANSACTION会隐式提交上一条事务,时序上产生各种诡异交错);它后续的写操作会撞上 A 留下的残留行锁,要么报锁等待超时,要么自己也挂进等待队列;
  5. 更隐蔽的是"半截事务状态污染":如果 B 不显式开事务,它写的每一行都会被卷进 A 那条未收口的长事务里,直到某次显式 commit 才意外收口——A 的半截业务可能被 B 的 commit 误提交,B 的业务也可能被 A 残留状态的回滚连坐,数据一致性完全失控。

也就是说,release()归还的是连接的使用权,不是连接的状态复位。事务的生命周期属于"连接上的 MySQL 会话",不归 Node 侧的 try/finally 管;finally保证的是"借的东西还了",而事务收口必须由业务代码自己显式做完。裸 return 恰好绕过了所有显式收口路径:不经过 catch(没有异常),不执行 commit(return 抢先跳出了),finally 又只管还连接——于是"开着事务、挂着行锁的连接"就大摇大摆地回了池。

顺带验证了池耗尽的完整闭环:出问题的这个分支在"会话已被领取"时触发,属于正常业务路径,触发频率不低;每次触发就少一个干净连接、多一条挂着行锁的假空闲连接。池一共 10 个连接,被污染的连接反复被租客借走、反复被卡住(租客的语句等行锁直到超时,超时后异常又走 catch 路径把连接还回来,但它可能已经又开了新的事务……),污染在池里自我繁殖。用不了多久,池里要么凑不出干净连接,要么所有租客都在等锁,全接口超时的窗口就此形成。

2.4 池耗尽的完整闭环

裸 return 只污染一条连接,为什么能拖死整个池?把污染的传播路径完整推演一遍就明白了。出问题的分支在"会话已被领取"时触发,属于正常业务路径,触发频率不低;每次触发就少一个干净连接、多一条挂着行锁的假空闲连接。被污染的连接随后反复被租客借走:租客在它上面执行写语句时卡在行锁等待(默认innodb_lock_wait_timeout是 50 秒),这几十秒里连接既不还池也不报错,等于被拉长的占用;租客等到超时后异常抛出,连接走 catch 路径还回来,但租客自己的 rollback 发生在 A 的残留事务之上,可能又留下新的敞口状态。污染在池里自我繁殖。用不了多久,池里要么凑不出干净连接,要么所有租客都在等锁,全接口超时的窗口就此形成。

2.5 一个佐证:为什么"读接口"也死

有同事最初不理解:污染的是写路径的事务,为什么纯读接口也超时?答案在池的排队机制上。mysql2 的池在耗尽时的行为是排队等待(waitForConnections: true默认开):getConnection()不报错、不超时,就是无限期排队。被污染的连接虽然"在池里",但租户代码在它们上面执行语句时会长时间卡在锁等待,连接迟迟不 release;池外可用连接归零后,后续所有需要连接的请求——包括只读的——都在getConnection()上排队。池是一个串行瓶颈:它不分读写,不分接口,只认"有没有连接可用"。这就是"全接口超时"的全貌。

三、根因归纳与 mysql2 池的行为核对

把根因浓缩成一句话:事务函数里存在绕过 commit/rollback 的控制流路径(裸 return、throw 前未回滚、提前 return 的每个分支),而release()不负责事务收口,导致连接带着打开的事务和行锁回池。

这里把 mysql2 池相关的几个行为逐条核对清楚,都是排障时查过源码和文档确认的,避免读者在参数理解上踩坑:

配置/行为默认值实际语义与本故障的关系
connectionLimit10池内连接总数上限池只有 10 个,污染几个就见底
waitForConnectionstrue池耗尽时新请求排队等待而非报错卡死的直接形态:getConnection()挂起
queueLimit0(不限)等待队列最大长度,0 表示不限等待请求无限堆积,故障被放大
maxIdle/idleTimeout同 limit / 60s空闲连接收缩策略只收缩空闲连接,借出的连接不受影响
acquireTimeout—新版 mysql2 已移除该选项池排队没有内建超时,别指望它兜底
conn.release()—归还使用权,不做事务收口(resetOnRelease默认关闭)根因成立的机制前提

特别说明两点。其一,acquireTimeout这一项是排障时的一个乌龙:老版本 mysql 曾有这个参数,mysql2 在 3.x 版本里把它移除了(changelog 里明确写着 remove acquireTimeout invalid option),所以"等连接超时自动失败"这条路在新版里根本不存在——池的排队要么等到天荒地老,要么手动放弃。其二,源码里能看到release()的实现只做状态标记和队列搬运,事务的收口是调用方的责任;resetOnRelease可以在归还时执行会话复位,但它是较新的选项且默认关闭,而且即便开启也不该成为裸 return 的挡箭牌——复位掩盖问题,不如收口消灭问题。

再补一个边界情况,是评审时被问到的:“如果裸 return 的分支里还没执行 FOR UPDATE,是不是就无害了?”——不是。只要beginTransaction()已执行,即使事务里还没有任何语句,敞口的事务也会阻止连接把状态还给会话(比如后续自动提交语义的错乱),并且事务本身一直占着撤销日志引用,长事务对主从复制和撤销段回收的拖累是老话题了。事务边界一开,任何路径都必须收口,没有"小事了"的例外。

四、修复:统一事务边界包装,让裸 return 无路可走

修复的思路不是"找到那个 return 改掉",而是消灭"自己写收口逻辑"这个模式:把事务的开启、提交、回滚、还连接全部收进一个包装函数,业务代码只声明"我要在一个事务里干这些事",收口由包装函数强制执行。

4.1 第一步:写一个强制收口的withTransaction

// mysql2/promise 连接池上的事务包装:任何退出路径都强制 commit | rollbackasyncfunctionwithTransaction(pool,work){constconn=awaitpool.getConnection();try{awaitconn.beginTransaction();constresult=awaitwork(conn);// 业务只拿 conn,碰不到池awaitconn.commit();returnresult;}catch(err){try{awaitconn.rollback();// 回滚失败也要还连接}catch(rollbackErr){// 回滚本身异常(如连接已断)时,销毁连接,绝不让它带病回池conn.destroy();throwrollbackErr;// 保留回滚错误,业务语义更真实}throwerr;// 重新抛出,把回滚决策权交给上层}finally{// 双保险:正常路径 release;若 work 内吞掉了异常(不推荐但防御),// 连接在未经 commit 的情况下归还时直接销毁if(!conn._released){conn.release();}}}// 用法:改写后的 claimSessionasyncfunctionclaimSession(sessionId,workerId){returnwithTransaction(pool,async(conn)=>{const[rows]=awaitconn.query('SELECT * FROM session WHERE id = ? FOR UPDATE',[sessionId]);if(!row_valid(rows)){return'already_claimed';// 裸 return 现在是安全的:}// 包装函数会替它 rollbackawaitconn.query('UPDATE session SET owner = ?, status = ? WHERE id = ?',[workerId,'claimed',sessionId]);awaitconn.query('INSERT INTO claim_log(session_id, worker_id) VALUES (?, ?)',[sessionId,workerId]);return'ok';});}

这个包装的防御纵深有三层:业务函数体里的任何return——包括提前 return、条件 return——都会先走commit(语义正确:没抛异常就是想提交),不存在"绕过收口"的路径;任何异常都会走rollback,且 rollback 自身失败时用conn.destroy()把连接从池里物理移除(destroy会把连接踢出池并断开,绝不复用);finally 里对"已被 release"做了幂等防重(mysql2 的PoolConnection.release()本身有_released标记防重入,这里显式判断只是让意图可读)。还有一个设计取舍值得说明:为什么包装函数不允许业务接触conn.release()——归还权收归包装层,业务拿到的连接对象只能执行 SQL,没有还回池的资格,这类"权限最小化"直接堵死了"提前还连接但事务没完"的孪生 bug。

顺带一提,mysql2 新版提供了conn.transaction()(Symbol.asyncDispose支持)这类语法糖,方向相同;但自己包装一遍的价值在于:可以在包装里加事务超时、慢事务打点、重试语义,这些内建糖不给扩展点。我们保留了自研包装。

4.2 第二步:全仓扫查 + 评审清单

包装函数落地后,用和上次修界面冻死一样的"全仓扫查法"清存量:

# 1) 所有 beginTransaction 的调用点(每处都要核对是否走包装)grep-rn"beginTransaction"src/--include="*.js"# 2) 所有 getConnection 调用点(不允许业务层直接借连接)grep-rn"getConnection"src/--include="*.js"# 3) 事务函数体里的所有 return 分支(人工审读:是否有绕过收口的路径)grep-rn-A40"beginTransaction"src/--include="*.js"|grep-n"return\|throw"

扫出来 7 处手写事务,其中 2 处存在和本次同型的裸 return 分支(一个在"参数校验失败"分支,一个在"幂等命中"分支),全部改走包装。同时把规则写进评审清单,这几条是从血里泡出来的:

  1. 事务函数内禁止裸 return 之外的任何"自行归还"——一律走withTransaction,直接pool.getConnection()需评审特批;
  2. 评审事务代码时,逐个 return 分支过一遍:每个分支都要能回答"这个路径谁负责 commit/rollback";
  3. conn.release()出现在业务代码里即为坏味道(包装层除外);
  4. catch里的 rollback 要处理 rollback 自身的失败,失败时destroy连接而不是放它回池;
  5. 事务内禁止长耗时操作:事务里调用外部 HTTP、跑复杂计算都是变相长事务,与本次故障同源。

4.3 第三步:监控兜底,让下一次故障在 5 分钟内被发现

修复消灭了已知的生成源,但这类问题的可怕在于静默累积——从第一个污染连接出现到全池卡死有几分钟的窗口,正是利用这个窗口做告警。落了三条监控:

// 1) 池等待队列监控:借连接耗时 P99 超阈值即告警pool.on('enqueue',()=>{enqueueCounter.increment();// 排队事件计数});// 另在连接借用处打点:await pool.getConnection() 前后计时,// 借用耗时 P99 > 500ms 持续 1 分钟 => 告警(正常应当 <5ms)// 2) 慢事务打点:withTransaction 内对 work() 计时constt0=Date.now();constresult=awaitwork(conn);metrics.txDuration.observe(Date.now()-t0);// >1s 的事务单独计数
-- 3) 定时巡检(每 30s):长寿事务直接告警,这是最硬的信号SELECTtrx_id,TIMESTAMPDIFF(SECOND,trx_started,NOW())ASage_sec,trx_rows_lockedFROMinformation_schema.innodb_trxWHERETIMESTAMPDIFF(SECOND,trx_started,NOW())>10;

三者的分工:借用耗时看的是"池还够不够用",慢事务看的是"业务有没有在事务里磨蹭",innodb_trx巡检看的是"数据库侧有没有敞口事务"——第三条最直接,任何超过 10 秒的打开事务在本系统里都是异常(正常事务都在百毫秒内收口)。故障窗口从此从"几十分钟"压缩到"告警后 5 分钟内人工介入"。

五、验证:回归测试与故障注入

5.1 针对性回归:把"裸 return"写成测试用例

修复不能只靠"看着对了",我们把事故本身写成了回归测试:

it('提前 return 的分支必须回滚并归还干净连接',async()=>{// 场景:会话已被领取,触发 already_claimed 裸 return 分支awaitclaimSession(1,'worker-a');// 正常领取constr=awaitclaimSession(1,'worker-b');// 走裸 return 分支expect(r).to.equal('already_claimed');// 验证一:数据库侧无敞口事务const[trx]=awaitpool.query('SELECT COUNT(*) AS n FROM information_schema.innodb_trx');expect(trx[0].n).to.equal(0);// 验证二:归还的连接可被下一个租客干净复用(此前会撞行锁或状态污染)const[owner]=awaitpool.query('SELECT owner FROM session WHERE id = 1');expect(owner[0].owner).to.equal('worker-a');// 未被误改});

配套再写一个异常路径用例(work 内 throw 后连接仍可用)、一个回滚失败路径用例(模拟连接断开,断言连接被 destroy 而非回池)。三绿之后,这组用例进了 CI。

5.2 故障注入与压测对比

最后做一次带故障注入的压测:写一个开关,随机以 5% 概率让claimSession走"提前 return"路径,跑 30 分钟高并发。结果对照:

指标修复前(含注入)修复后(含注入)
30 分钟内全池卡死次数3 次0 次
借连接 P99 耗时一度 >60s(排队)4ms
innodb_trx最长事务1400s+0.9s
接口错误率窗口期 >30%0.1%(注入产生的预期 5% 提前返回分支正常返回)

修复前那三次卡死复现了线上的全部特征:先是借连接耗时爬升,然后全接口超时,innodb_trx里躺着长寿事务。修复后同样的注入流量下,所有敞口事务在毫秒级被收口,池水位平稳。

六、复盘清单

最后沉淀规则清单,同样是评审可直接引用的形态:

  1. 事务收口与连接归还是两件事:release()还的是使用权,事务的 commit/rollback 必须显式完成;只要beginTransaction执行过,任何退出路径都必须收口,没有例外分支。
  2. 事务边界统一包装:withTransaction一类包装函数强制"任何路径 commit|rollback",业务代码不再手写收口;直接pool.getConnection()写事务在评审中一律打回。
  3. 评审清单:事务函数内禁止裸 return——准确说,允许 return,但每个 return 分支必须显式回答"谁负责收口";答不上来的写法直接打回。
  4. 回滚自身失败要 destroy 连接:带病连接宁可销毁重建,不可回池复用。
  5. 池耗尽的第一信号不是数据库忙:getConnection()排队挂起 + MySQL 空闲 + 慢查询日志干净,这个组合直接指向"连接不还/事务不收",去查innodb_trx。
  6. innodb_trx是事务问题的第一现场:trx_age_sec大、trx_query为 NULL、对应线程 Sleep,三件套一出现就是敞口事务实锤。
  7. 监控兜底三件套:借连接耗时 P99、慢事务计数、长寿事务巡检,三者分别盯池、业务、数据库三个层面。
  8. 排障期参数核对要查版本:网上大量 mysql2 资料还在讲acquireTimeout,新版已移除;参数语义以当前版本的源码和 changelog 为准。

这次故障和之前修过的那次界面冻死,本质上是同一个教训的两次显形:共享资源的健康度,取决于最不守规矩的那个使用方——线程池如此,连接池也如此。而守住底线的手段也一样:把"守规矩"做成机制(包装函数、统一边界),而不是做成对每个调用点的自觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询