
一、现象偶发的全接口超时重启进程立刻恢复先交代来源这个问题是我在做一款本地化部署的微信自动回复工具时踩到的。服务端是 Node.js数据访问层用 mysql2 的连接池mysql2/promise业务里有一批多表写入的操作走事务——比如消息应答时要同时更新会话状态、写入应答流水、递增计数。故障形态非常典型偶发的全接口超时。平时接口都在两位数毫秒档位响应但每隔一两天会突然出现一波所有接口集体变慢直至超时的窗口——不是某一个慢接口拖累别的而是包括纯读接口在内的所有请求一起超时。这个窗口持续几分钟到十几分钟不等通常自己恢复偶尔恢复不了得重启 Node 进程才彻底缓解。重启之后一切正常仿佛无事发生。这类重启就好的问题最容易被人用一句偶发抖动糊弄过去但有一个观测结果让我没法这么交代窗口期内Node 进程本身是活的——日志在滚、健康检查接口不碰数据库的那个毫秒级响应而数据库层面MySQL 自己也活着手工连上去执行SELECT 1秒回。两头都活中间死那问题就只能出在两者之间那条路上——对这套架构来说就是连接池。1.1 第一个量化观测池在等数据库不忙故障窗口期内我们抓了两边的关键指标观测点平时故障窗口期接口 P99 耗时~120ms15s网关超时MySQL CPU10%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){constconnawaitpool.getConnection();// 1. 从池里借连接try{awaitconn.beginTransaction();// 2. 开事务const[rows]awaitconn.query(SELECT * FROM session WHERE id ? FOR UPDATE,[sessionId]);if(!row_valid(rows)){returnalready_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. 只有这条正路会提交returnok;}catch(err){awaitconn.rollback();throwerr;}finally{conn.release();// 5. 连接总是会还——但这不够}}finally里有conn.release()看起来连接总会归还为什么池还会耗尽这正是本题最反直觉、也最值得写清楚的地方。2.3 关键机制release 归还的是连接不是干净状态逐条拆解那个裸 return 的后果链。为了叙述方便先把故障时刻两个并发的请求摆出来请求 A 走到裸 return 分支请求 B 在几毫秒后到达从池里借走同一条被 A 归还的连接。beginTransaction()已经在连接上发出START TRANSACTION这条连接从此进入显式事务打开状态SELECT ... FOR UPDATE对会话行加了排他行锁——锁是跟着事务走的事务不结束锁不释放裸 return 触发finallyconn.release()执行连接回到池里。但 MySQL 侧的事务还开着行锁还挂在上面请求 B 从池里借到这条假空闲连接。它在上面执行的第一条语句实际上跑在 A 留下的那条没结束的事务里——后续它自己beginTransaction时会接到这条旧事务上同一条连接上重复START TRANSACTION会隐式提交上一条事务时序上产生各种诡异交错它后续的写操作会撞上 A 留下的残留行锁要么报锁等待超时要么自己也挂进等待队列更隐蔽的是半截事务状态污染如果 B 不显式开事务它写的每一行都会被卷进 A 那条未收口的长事务里直到某次显式 commit 才意外收口——A 的半截业务可能被 B 的 commit 误提交B 的业务也可能被 A 残留状态的回滚连坐数据一致性完全失控。也就是说release()归还的是连接的使用权不是连接的状态复位。事务的生命周期属于连接上的 MySQL 会话不归 Node 侧的 try/finally 管finally保证的是借的东西还了而事务收口必须由业务代码自己显式做完。裸 return 恰好绕过了所有显式收口路径不经过 catch没有异常不执行 commitreturn 抢先跳出了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){constconnawaitpool.getConnection();try{awaitconn.beginTransaction();constresultawaitwork(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)){returnalready_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]);returnok;});}这个包装的防御纵深有三层业务函数体里的任何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-rnbeginTransactionsrc/--include*.js# 2) 所有 getConnection 调用点不允许业务层直接借连接grep-rngetConnectionsrc/--include*.js# 3) 事务函数体里的所有 return 分支人工审读是否有绕过收口的路径grep-rn-A40beginTransactionsrc/--include*.js|grep-nreturn\|throw扫出来 7 处手写事务其中 2 处存在和本次同型的裸 return 分支一个在参数校验失败分支一个在幂等命中分支全部改走包装。同时把规则写进评审清单这几条是从血里泡出来的事务函数内禁止裸 return 之外的任何自行归还——一律走withTransaction直接pool.getConnection()需评审特批评审事务代码时逐个 return 分支过一遍每个分支都要能回答这个路径谁负责 commit/rollbackconn.release()出现在业务代码里即为坏味道包装层除外catch里的 rollback 要处理 rollback 自身的失败失败时destroy连接而不是放它回池事务内禁止长耗时操作事务里调用外部 HTTP、跑复杂计算都是变相长事务与本次故障同源。4.3 第三步监控兜底让下一次故障在 5 分钟内被发现修复消灭了已知的生成源但这类问题的可怕在于静默累积——从第一个污染连接出现到全池卡死有几分钟的窗口正是利用这个窗口做告警。落了三条监控// 1) 池等待队列监控借连接耗时 P99 超阈值即告警pool.on(enqueue,(){enqueueCounter.increment();// 排队事件计数});// 另在连接借用处打点await pool.getConnection() 前后计时// 借用耗时 P99 500ms 持续 1 分钟 告警正常应当 5ms// 2) 慢事务打点withTransaction 内对 work() 计时constt0Date.now();constresultawaitwork(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);// 正常领取constrawaitclaimSession(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排队4msinnodb_trx最长事务1400s0.9s接口错误率窗口期 30%0.1%注入产生的预期 5% 提前返回分支正常返回修复前那三次卡死复现了线上的全部特征先是借连接耗时爬升然后全接口超时innodb_trx里躺着长寿事务。修复后同样的注入流量下所有敞口事务在毫秒级被收口池水位平稳。六、复盘清单最后沉淀规则清单同样是评审可直接引用的形态事务收口与连接归还是两件事release()还的是使用权事务的 commit/rollback 必须显式完成只要beginTransaction执行过任何退出路径都必须收口没有例外分支。事务边界统一包装withTransaction一类包装函数强制任何路径 commit|rollback业务代码不再手写收口直接pool.getConnection()写事务在评审中一律打回。评审清单事务函数内禁止裸 return——准确说允许 return但每个 return 分支必须显式回答谁负责收口答不上来的写法直接打回。回滚自身失败要 destroy 连接带病连接宁可销毁重建不可回池复用。池耗尽的第一信号不是数据库忙getConnection()排队挂起 MySQL 空闲 慢查询日志干净这个组合直接指向连接不还/事务不收去查innodb_trx。innodb_trx是事务问题的第一现场trx_age_sec大、trx_query为 NULL、对应线程 Sleep三件套一出现就是敞口事务实锤。监控兜底三件套借连接耗时 P99、慢事务计数、长寿事务巡检三者分别盯池、业务、数据库三个层面。排障期参数核对要查版本网上大量 mysql2 资料还在讲acquireTimeout新版已移除参数语义以当前版本的源码和 changelog 为准。这次故障和之前修过的那次界面冻死本质上是同一个教训的两次显形共享资源的健康度取决于最不守规矩的那个使用方——线程池如此连接池也如此。而守住底线的手段也一样把守规矩做成机制包装函数、统一边界而不是做成对每个调用点的自觉。