|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
一、具体的问题
上一周接手一个订单系统,业务方的反馈很直接:每天高峰期下单接口频繁报 Deadlock found when trying to get lock; try restarting transaction,偶尔还夹着 Lock wait timeout exceeded。开发已经加了"捕获异常就重试",但重试本身又制造了新的并发压力,死锁没减少,锁等待超时反而从每天两三涨到每天上千次,P99 下单耗时从 200ms 涨到接近 3 秒。更麻烦的是,谁也说不清是哪两条 SQL 在互相卡:SHOW ENGINE INNODB STATUS 里只留了最近一次死锁的现场,高峰期一分钟刷掉好几次,根本来不及看。
这类问题的通病是:把死锁当成"偶发故障"来重试掩盖,而不是当成"加锁顺序问题"来根治。这篇文章就把 MySQL 死锁与锁等待的定位、复现、根治一条线讲完,所有命令都可以直接照做。
二、核心原理
先纠正一个常见误解:死锁不是"锁太多",而是"加锁顺序不一致"。两个事务各自先拿到一把对方想要的锁,再反过来等对方手里的锁,就构成循环等待。InnoDB 的死锁检测会立刻发现环,并选择回滚代价较小(undo 量较少)的那个事务,抛出 1213 错误。所以死锁的解决方向从来不是"加更多锁",而是让所有事务按同一个顺序拿锁。
第二个关键点是间隙锁。在默认的 REPEATABLE READ 隔离级别下,SELECT ... FOR UPDATE 走二级索引时,锁的不是一行,而是"记录 + 记录之间的间隙"(next-key lock)。典型的死锁模式是:事务 A 用 WHERE order_no = 'X' FOR UPDATE 锁住某个区间防止重复下单,事务 B 对同一个不存在的值做 INSERT,插入意向锁被 A 的间隙锁挡住;此时 A 再反过来插入同一段间隙,插入意向锁又被 B 排队——环就成了。这也是"防重查询 + 插入"这种写法在 RR 下高并发必死锁的原因。
锁等待(错误码 1205)则是单向阻塞:一个事务持有锁不释放,后面的事务等到 innodb_lock_wait_timeout(默认 50 秒)超时。它通常是死锁的"前奏"——长事务、大事务把锁窗口拉长,等待链越排越长。定位手段上,performance_schema.data_locks / data_lock_waits 是实时现场,sys.innodb_lock_waits 是现成的等待链视图,innodb_print_all_deadlocks 能把每一次死锁都写进错误日志补齐"只留最近一次"的短板。
三、实例参考(动手步骤)
步骤 0:确认现状基线
先摸清参数与死锁频率:
- SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; -- 默认 50,业务通常改小到 3~5
- SHOW VARIABLES LIKE 'innodb_print_all_deadlocks'; -- 默认 OFF,建议打开
- SHOW VARIABLES LIKE 'transaction_isolation'; -- 确认 RR 还是 RC
复制代码
统计历史死锁次数(MySQL 8.0,按错误码计数):
- SELECT SUM(errors_count) AS deadlock_count
- FROM performance_schema.events_errors_summary_global_by_error
- WHERE error_number = 1213;
复制代码
打开长期采样(需 root,改完即生效,写进错误日志):
- SET GLOBAL innodb_print_all_deadlocks = ON;
复制代码
再看当前锁等待链——这是排查锁等待的第一入口:
- SELECT * FROM sys.innodb_lock_waits\G
复制代码
重点关注四个字段:wait_pid(被堵的连接)、blocking_pid(元凶连接)、blocking_query(元凶正在跑的 SQL)、wait_age_secs(已经等了多少秒)。如果 wait_age_secs 超过 60 且 blocking_query 是个还没结束的大查询,KILL <blocking_pid> 先止血,再去查它为什么跑这么久。
步骤 1:用错误日志还原死锁现场
打开采样后,从错误日志里捞死锁记录:
- grep -A 40 "LATEST DETECTED DEADLOCK" /var/log/mysql/error.log | tail -80
复制代码
解读要点:*** (1) 和 *** (2) 是两个事务各自已持有和正在等待的锁;看 lock_mode X locks gap before rec 字样,就说明间隙锁参与了死锁;*** (2) WAITING FOR 后面那行是环闭合的位置。如果连续几份现场都出现同样的两条 SQL,根因基本就锁定了。
步骤 2:手工复现一次最小死锁
开两个会话,按下面时序执行(订单表 orders 有唯一键 order_no):
| 时序 | 会话 A | 会话 B | | 1 | BEGIN; SELECT * FROM orders WHERE order_no='N1' FOR UPDATE; | | | 2 | | BEGIN; INSERT INTO orders(order_no,...) VALUES('N1',...);(挂起) | | 3 | INSERT INTO orders(order_no,...) VALUES('N1',...); | (报 1213,回滚) |
第 1 步的 FOR UPDATE 在唯一索引上加了间隙锁;第 2 步 B 的插入意向锁排队等待;第 3 步 A 自己也要在同一个间隙插入,插入意向锁被 B 挡住——环闭合,InnoDB 立刻回滚代价小的事务。这个五分钟就能搭出来的场景,和线上事故是一模一样的机制。
步骤 3:根治改写(按优先级)
① 统一加锁顺序。 批量更新多行时,永远按主键排序后处理:
- -- 两个事务都要更新 id 5 和 id 9,必须都写这样:
- SELECT id, stock FROM orders WHERE id IN (5, 9) ORDER BY id FOR UPDATE;
复制代码
② 用"插入 + 唯一键冲突重试"替代"先查后插"。 防重复下单最稳的写法是靠唯一键兜底,而不是 FOR UPDATE 锁间隙:
- INSERT INTO orders(order_no, user_id, amount)
- VALUES('N1', 1001, 99.00)
- ON DUPLICATE KEY UPDATE amount = amount;
- -- 若仍走"先查后插",插入失败捕获 1062 后降级为更新,不要靠间隙锁防重
复制代码
③ 缩小事务里的锁窗口。 把"查询、调外部接口、再更新"的大事务拆开:外部接口调用(可能几十秒)绝不能放在 FOR UPDATE 和 COMMIT 之间。锁只包住真正需要原子性的那几条 SQL。
④ 业务允许时降级到 READ COMMITTED。 RC 下没有间隙锁(外键检查与唯一键检查除外),绝大多数"二级索引 FOR UPDATE 互卡"的死锁直接消失:
- SET GLOBAL transaction_isolation = 'READ-COMMITTED';
- -- 应用连接串同步设置;灰度一台验证后再全量
复制代码
⑤ 应用侧退避重试。 捕获 1213 与 1205,指数退避后重试整个事务,且必须保证事务幂等(用 order_no 这类业务键做幂等判断),否则重试会造成重复下单:
- for i in range(3):
- try:
- run_tx()
- break
- except pymysql.err.OperationalError as e:
- if e.args[0] in (1205, 1213):
- time.sleep(0.05 * (2 ** i))
- continue
- raise
复制代码
治理前后对比
| 指标 | 治理前 | 治理后 | | 死锁次数 | 每小时 40+ | 0(近两周) | | 锁等待超时 | 1200 次/天 | 3 次/天 | | P99 下单耗时 | 2.8 s | 180 ms | | innodb_lock_wait_timeout | 50 s(默认,故障放大) | 5 s | | 重试引发重复单 | 存在(未做幂等) | 0 | | 定位手段 | 事后翻 STATUS | print_all_deadlocks 持续采样 |
四、实操检查清单
- innodb_print_all_deadlocks = ON 已打开,错误日志有轮转,不丢现场。
- innodb_lock_wait_timeout 已按业务下调到 3~5 秒,避免故障时连接堆积。
- 用 performance_schema.events_errors_summary_global_by_error 按周统计 1213/1205 错误数,有趋势可查。
- 所有"先查后改"的写法都排查过:查询走了唯一索引还是二级索引?会不会锁间隙?
- 批量更新多行的 SQL,WHERE IN 列表已确认按主键排序加锁。
- 大事务清单已梳理:事务内无外部 HTTP/RPC 调用、无 sleep、无人工等待。
- 防重逻辑靠唯一键 + ON DUPLICATE KEY 或捕获 1062,不靠 FOR UPDATE 锁不存在的行。
- 应用捕获 1213/1205 后指数退避重试,且事务具备幂等性。
- 隔离级别变更(RR→RC)做过灰度,业务侧确认无可重复读依赖。
- 有 sys.innodb_lock_waits 的应急止血手册:查链、找元凶、KILL、事后归因。
几个容易踩的坑
- KILL 阻塞源只是止血。 元凶被杀后排队的事务一拥而上,下一个元凶马上出现。止血必须跟上根因修复。
- RC 不是万能。 降级消掉了大部分间隙锁,但外键检查、唯一键检查仍会加锁,且业务若依赖可重复读(如对账类逻辑)就不能降。降级前先审业务语义。
- 重试不幂等等于埋雷。 死锁回滚后重试,若事务里有"先插入再扣库存",非幂等写法会双扣。幂等键先行。
- SHOW ENGINE INNODB STATUS 只保留最近一次死锁。 不开 print_all_deadlocks,高峰期现场一分钟就被覆盖,事后无据可查。
- 二级索引上的 FOR UPDATE 比想象中锁得宽。 next-key lock 锁的是索引区间,非唯一二级索引上的等值查询照样锁间隙,走主键点查是最小锁路径。
- 长事务是放大器。 锁窗口从 10ms 拉长到 10s,原本无感的等待全部暴露成超时。监控 information_schema.innodb_trx 里 trx_started 超过 60 秒的事务,及时告警。
- 客户端超时小于是服务端。 应用侧超时若比 innodb_lock_wait_timeout 还小,客户端断开但事务还在跑,锁继续持有——连接池务必配 socketTimeout 并确认连接回收时事务被回滚。
|
|