死锁是生产环境中最常见且令人头疼的数据库问题之一。它时而低频出现,时而集中爆发导致服务雪崩,其成因也往往比日志提示更为复杂。本文将以一个真实的线上支付场景死锁案例为背景,带您从 SQL 语句到锁机制,再到事务行为,进行全链路分析,彻底理清死锁的来龙去脉。
一、线上告警:Deadlock found when trying to get lock
监控系统在业务高峰期突然告警,大量支付流程请求失败,错误信息明确指向数据库死锁。
为了快速定位,我们立即执行了诊断命令:
SHOW ENGINE INNODB STATUS\G;
输出结果中的关键部分如下:
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
UPDATE order SET status = 1 WHERE id = 1001
*** (1) WAITING FOR THIS LOCK:
RECORD LOCKS space id 123 page no 300 n bits 80 index `PRIMARY` of table `order`
***
*** (2) TRANSACTION:
UPDATE order SET pay_time = NOW() WHERE id = 1002
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 123 page no 300 n bits 80 index `PRIMARY` of table `order`
*** (2) WAITING FOR THIS LOCK:
RECORD LOCKS space id 123 page no 300 n bits 80 index `PRIMARY` of table `order`
*** WE ROLL BACK TRANSACTION (1)
这段日志揭示了三个关键事实:
- 两个
UPDATE 事务在争锁同一个数据页(Page 300)。
- 事务1和事务2各自持有部分锁,同时又都在等待对方释放锁,形成了循环等待。
- InnoDB 引擎自动选择回滚了事务(1)以解除死锁。
这为我们的排查指明了方向。
二、死锁成因:锁资源的循环等待
死锁的本质是多个事务对锁资源的循环等待。常见模式包括交叉更新、范围锁冲突等。在本案例中,日志清晰地指向了一种典型情况:两条不同的 UPDATE 语句更新了同一数据页中的不同记录,但由于加锁顺序不一致,导致了交叉等待。
三、业务逻辑还原
具体到业务代码,存在两个核心的数据库操作:
操作A:更新订单状态
UPDATE order SET status = 1 WHERE id = ?;
操作B:更新支付时间
UPDATE order SET pay_time = NOW() WHERE id = ?;
在高并发场景下,这两个请求可能以如下顺序交织执行:
| 事务 |
执行操作 |
| T1 |
UPDATE order SET status = 1 WHERE id = 1001 |
| T2 |
UPDATE order SET pay_time = NOW() WHERE id = 1002 |
如果 id=1001 和 id=1002 这两条记录恰好存储在同一个数据页中,就可能触发:
- T1 持有
id=1001 的行锁,尝试获取 id=1002 的锁。
- T2 持有
id=1002 的行锁,尝试获取 id=1001 的锁。
从而形成 T1 等 T2 → T2 等 T1 的经典死锁环。
四、核心原理:为何更新不同ID也会死锁?
一个常见的疑问是:“明明是按主键ID更新,为何会影响其他记录?” 这需要理解InnoDB的存储与加锁机制。
InnoDB 数据按页(Page)组织,默认每页16KB。一个数据页内通常存放多条记录。示意图如下:
Page 300:
---------------------------------------
| id=1001 | id=1002 | id=1003 | ... |
---------------------------------------
当并发事务更新同一个页内的不同行时:
- 事务会对目标行加行锁(Record Lock)。
- 但在实现上,行锁是页级锁结构的一部分。
- 如果多个事务对同一页内不同行的加锁顺序不一致,就可能因管理这些行锁的页面结构而产生死锁。这也是数据库/中间件优化中需要重点关注的高并发锁争用问题。
五、解决方案与优化实践
针对这类“同页不同行”更新导致的死锁,可以从以下几个层面进行优化:
1. 保证锁顺序一致性(最有效)
在应用层设计时,确保对多个对象的更新遵循固定的顺序,例如始终按照ID升序处理。
-- 批量更新时使用 ORDER BY
UPDATE order SET status=1 WHERE id IN (1001, 1002) ORDER BY id;
在业务代码中,强制约定“先获取ID较小的记录锁”。
2. 数据分片,降低页热度
通过分表(如按时间、用户ID哈希)将数据分散到不同的物理页上,减少多事务同时访问同一热点页的概率。
-- 例如按月分表
order_202501
order_202502
3. 使用悲观锁预先锁定
在事务开始时,使用 SELECT ... FOR UPDATE 提前锁定所有可能需要修改的行,使整个事务的加锁路径一次确定。
START TRANSACTION;
SELECT * FROM order WHERE id IN (1001, 1002) FOR UPDATE;
-- ... 执行后续更新操作
COMMIT;
4. 缩短事务持有锁的时间
- 避免在事务中执行远程调用(RPC)、大量循环或文件IO等耗时操作。
- 将用户交互(如确认弹窗)移出事务范围。
- 将长事务拆分为多个短事务。
5. 精简索引,避免非必要更新
更新非索引字段或精简索引数量,可以减少更新操作需要维护的锁数量,从而降低死锁概率。
6. 调整事务隔离级别(需谨慎评估)
将隔离级别从 REPEATABLE READ 降至 READ COMMITTED,可以减少Next-Key Lock(间隙锁)的使用,从而避免因间隙锁导致的死锁,但会引入不可重复读等一致性问题。
六、死锁排查黄金三步法
遇到死锁时,可遵循以下系统性的排查路径:
-
获取死锁详情
第一时间执行 SHOW ENGINE INNODB STATUS\G,从 LATEST DETECTED DEADLOCK 部分提取关键信息:涉及的事务、等待的锁、持有的锁、冲突的页和索引。
-
分析SQL与锁模式
结合获取到的信息,分析相关SQL:是否走了正确的索引?是否涉及间隙锁(GAP Lock)?更新记录是否位于同一数据页?事务的加锁顺序是否存在不一致?深入理解数据库/中间件的锁机制是这一步的关键。
-
重现、验证与优化
根据分析结论,在测试环境模拟高并发场景进行验证。然后从“锁顺序”、“索引设计”、“数据分布”、“事务粒度”四个维度着手实施优化方案。
七、总结
死锁在复杂的并发系统中难以完全杜绝,但可以通过系统性的方法进行管控和优化。解决死锁的关键不在于猜测,而在于精准分析:
- 看日志:读懂
SHOW ENGINE INNODB STATUS 的输出。
- 理资源:明确事务各自持有和等待的锁资源。
- 画链路:梳理出事务间清晰的锁等待关系图。
- 定策略:从应用层逻辑或数据库设计层面打破循环等待。
只要能够完整、准确地还原出锁等待链,你就能解决绝大部分的数据库死锁问题。