MySQL事务死锁源于InnoDB锁机制与PHP事务逻辑不匹配,需通过日志分析、SQL优化、事务边界调整及应用层重试四步解决。

MySQL事务死锁不是PHP代码写错了,而是多个并发请求在数据库里“抢道”时卡住的结果。核心在于InnoDB引擎的锁机制与PHP事务逻辑不匹配——排查要进数据库看真实锁状态,优化要从SQL执行顺序、索引有效性、事务边界三处下手。
查死锁:先看MySQL日志和状态
别只盯着PHP报错,死锁现场全在MySQL里:
- 执行
SHOW VARIABLES LIKE 'log_error';确认错误日志路径(如C:\phpEnv\mysql\data\mysql.err),打开文件找以*** (1) TRANSACTION:开头的块,这是完整死锁快照 - 立刻运行
SHOW ENGINE INNODB STATUS\G,聚焦LATEST DETECTED DEADLOCK区域:它会显示两个事务各自的SQL、持有的锁(holds the locks)、等待的锁(waiting for this lock)、主键值和索引名 - 若日志里只有最后一次死锁,说明
innodb_print_all_deadlocks没开——登录MySQL后执行SET GLOBAL innodb_print_all_deadlocks = ON;,并把这行加到my.cnf的[mysqld]段下确保重启不失效
定位PHP触发点:从SQL反推业务代码
死锁日志里的SQL是线索,不是终点:
- 提取SQL中的表名、WHERE条件字段、主键值(如
UPDATE orders SET status=2 WHERE id=105),在项目中全局搜索orders、status、105等关键词,快速定位DAO方法或Eloquent调用位置 - 重点盯三类高危模式:先SELECT再UPDATE(如
find()->update())、跨表更新顺序不一致(A事务先users后orders,B事务先orders后users)、批量更新+单行更新并发(如UPDATE products SET stock=stock-1 WHERE id IN (1,2,3)和UPDATE products SET stock=stock-1 WHERE id=2同时跑) - 在关键事务入口加日志:
error_log("[TX] Stock deduct, pid={$pid}, items=".json_encode($items));,让死锁发生时能直接关联到具体请求和参数
PHP代码层防死锁:缩短、统一、避开陷阱
很多死锁靠改PHP就能避免,不用动数据库配置:
立即学习“PHP免费学习笔记(深入)”;
-
缩小事务范围:把HTTP请求、缓存写入、日志记录等非DB操作移出
beginTransaction()和commit()之间;只让真正需要原子性的读-改-写包裹在事务内 -
强制访问顺序一致:所有涉及
users、orders、payments三张表的事务,全部按users → orders → payments顺序更新,哪怕某次只用到其中两张 -
绕过间隙锁陷阱:避免
WHERE created_at > '2026-05-01' ORDER BY id DESC LIMIT 1这类无索引排序+LIMIT;高频扣减场景改用UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty >= 1,省掉SELECT步骤 -
警惕ORM暗坑:ThinkPHP的
save()、Laravel的updateOrCreate()内部是先查后更,若并发且无唯一索引,极易和别的事务争抢间隙锁——确认业务唯一性后,优先建联合唯一索引
应用层兜底:捕获死锁并智能重试
完全杜绝不现实,但可以让失败变得可控:
- PDO必须开启异常模式:
$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); - 捕获错误码1213:
if ($e->getCode() == 1213) { /* 触发重试 */ },重试前务必调$pdo->beginTransaction()新建事务,不能复用已中断的连接 - 用指数退避代替固定sleep:
usleep(50000 * pow(2, $retryCount));,重试上限设为3次——超时说明SQL或索引设计有硬伤,该重构而不是加更多重试 - 只对幂等操作重试:扣库存、状态机流转可以;发短信、写外部日志、生成订单号这类有副作用的操作,绝不能盲目重试



















