僵尸事务阻塞生产库:数据库写操作全部卡死的排查与处理
背景
某天下午三点,监控突然告警:API 响应时间从正常的 50ms 飙升到 30 秒,大量请求超时。
查看应用日志,报错集中在数据库写操作:
PDOException: SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction读操作还算正常,所有涉及写入的接口全部超时。
第一步:确认是锁等待问题
-- 查看当前等待锁的事务SELECT*FROMinformation_schema.INNODB_LOCK_WAITS;输出一大堆记录,几十个事务都在等锁,而且等待时间都超过了 50 秒。
锁等待是症状,持锁不放的事务才是根源。
第二步:找出僵尸事务
-- 查看当前所有活跃事务,按运行时长倒序SELECTtrx_id,trx_state,trx_started,TIMESTAMPDIFF(SECOND,trx_started,NOW())AStrx_duration_sec,trx_rows_locked,trx_rows_modified,trx_queryFROMinformation_schema.INNODB_TRXORDERBYtrx_startedASC;结果一目了然:
trx_id: 88888 trx_state: RUNNING trx_started: 2024-03-15 13:01:23 trx_duration_sec: 7156 ← 已经跑了将近 2 小时! trx_rows_locked: 48392 ← 持有将近 5 万个行锁 trx_rows_modified: 0 trx_query: NULL ← 当前没有在执行任何 SQL找到了:一个事务开启了将近两个小时,持有大量行锁,但当前没有在执行任何 SQL(trx_query = NULL)。这就是典型的僵尸事务——事务开启后由于某种原因(程序异常、连接假死、忘记提交)卡在那里,一直占着锁不释放。
第三步:找到对应的数据库连接
-- 通过 trx_id 关联找到对应的进程 IDSELECTp.idASprocess_id,p.user,p.host,p.db,p.command,p.time,p.state,p.info,t.trx_id,t.trx_duration_secFROMinformation_schema.PROCESSLIST pJOIN(SELECTtrx_id,trx_mysql_thread_id,TIMESTAMPDIFF(SECOND,trx_started,NOW())AStrx_duration_secFROMinformation_schema.INNODB_TRX)tONp.id=t.trx_mysql_thread_idORDERBYt.trx_duration_secDESC;输出:
process_id: 1234 user: app_user host: 192.168.1.100:54321 command: Sleep ← 连接处于睡眠状态,没有在执行任何操作 time: 7156 trx_id: 88888 trx_duration_sec: 7156command = Sleep说明这个连接已经"睡着了",但事务还开着,锁还在持有。
第四步:kill 掉僵尸事务
确认这个进程确实是问题所在后,果断 kill:
KILL1234;执行后,MySQL 会回滚这个事务,释放所有行锁。
几秒钟内,之前等待的几十个事务陆续拿到锁,写操作恢复正常,API 响应时间回落到 50ms。
事后分析:僵尸事务是怎么产生的
查应用代码,发现问题出在一段批量导入逻辑:
// 有问题的代码DB::beginTransaction();foreach($rowsas$row){// 处理每一行数据$result=$this->processRow($row);// processRow 内部调用了外部 HTTP 接口// 接口超时设置是 30 秒,实际等了 2 小时才超时(配置错误)// 在等待期间,事务一直开着,锁一直持有DB::table('import_records')->insert($result);}DB::commit();processRow内部调用了一个第三方 HTTP 接口,接口超时配置错误,等了将近两个小时。而整个过程事务一直是开着的,持有的行锁一直没释放。
根本原因:在数据库事务内部做了耗时的外部调用。
从根源上避免僵尸事务
原则一:事务内不做外部调用
// 错误示范:事务内调用 HTTP 接口DB::transaction(function()use($data){$result=Http::post('https://api.example.com/process',$data);// 可能超时DB::table('records')->insert($result->json());});// 正确做法:外部调用移到事务外$result=Http::timeout(5)->post('https://api.example.com/process',$data);DB::transaction(function()use($result){DB::table('records')->insert($result->json());});原则二:设置合理的事务超时时间
-- 单个事务最长执行时间(秒),超时自动回滚SETSESSIONinnodb_lock_wait_timeout=10;-- 全局配置,写入 my.cnf[mysqld]innodb_lock_wait_timeout=10原则三:建立长事务监控告警
-- 定时检查是否有超过 60 秒的事务SELECTtrx_id,trx_started,TIMESTAMPDIFF(SECOND,trx_started,NOW())ASduration_sec,trx_rows_locked,trx_queryFROMinformation_schema.INNODB_TRXWHERETIMESTAMPDIFF(SECOND,trx_started,NOW())>60;把这个查询加入监控,超过阈值触发告警,在影响业务之前就能发现处理。
原则四:连接池设置最大空闲时间
# PHP-FPM + PDO 配置 # 连接空闲超过 60 秒自动断开,防止连接假死持锁 [mysqld] wait_timeout = 60 interactive_timeout = 60完整排查命令速查
-- 1. 查看所有活跃事务,找出运行时间异常长的SELECTtrx_id,trx_state,trx_started,TIMESTAMPDIFF(SECOND,trx_started,NOW())ASduration_sec,trx_rows_locked,trx_queryFROMinformation_schema.INNODB_TRXORDERBYtrx_startedASC;-- 2. 查看锁等待关系SELECT*FROMinformation_schema.INNODB_LOCK_WAITS;-- 3. 找到僵尸事务对应的进程 IDSELECTp.id,p.user,p.host,p.command,p.timeFROMinformation_schema.PROCESSLIST pJOINinformation_schema.INNODB_TRX tONp.id=t.trx_mysql_thread_idWHERETIMESTAMPDIFF(SECOND,t.trx_started,NOW())>60;-- 4. Kill 掉僵尸事务(替换为实际的 process_id)KILL1234;总结
僵尸事务的处理步骤:
- 监控告警发现写操作大量超时
INNODB_TRX找出运行时间异常长的事务- 关联
PROCESSLIST找到对应进程 ID - 确认后
KILL释放锁 - 事后分析事务为什么没有正常提交,修复代码
预防比治疗重要:事务内不做外部调用、设置锁等待超时、建立长事务监控,三条规则写进团队开发规范。