在多用户并发访问数据库的环境里,如果没有任何控制手段,两个事务同时修改同一条数据就会出现互相覆盖的情况,这就是所谓的丢失更新问题。为了解决这类问题,SQL数据库引入了锁机制,通过对数据资源加锁来保证事务的隔离性。但锁是把双刃剑,用得不好反而会引发阻塞甚至死锁。本文将从锁的基本原理讲起,深入分析死锁产生的根本原因,并结合实际案例给出可落地的解决方案。

一、SQL数据库常见锁类型及其原理
数据库中的锁按照粒度可以划分为表级锁、页级锁和行级锁。表级锁的加锁开销最小,一次锁定整张表,实现简单但并发度低,典型的代表是MyISAM存储引擎。行级锁则只锁定被操作的行,并发性能最好,但加锁、解锁以及死锁检测的开销较大,InnoDB就是行级锁的代表。页级锁介于两者之间,锁定数据页,开销和并发度都处于中间水平。
按照锁的模式划分,最基础的是共享锁和排他锁。共享锁也叫读锁,事务对数据加共享锁后,其他事务仍然可以读取这份数据,但不能修改;排他锁也叫写锁,一旦加上排他锁,其他事务既不能读也不能写。此外,InnoDB还引入了意向锁,用于表达表下层级的加锁意图,这样表锁和行锁之间就能快速判断兼容性,避免逐行扫描判断。
还有一个容易被忽视的是间隙锁。在可重复读隔离级别下,InnoDB为了防止幻读,不仅会锁定已存在的记录,还会锁定记录之间的间隙。比如执行SELECT * FROM users WHERE age = 25 FOR UPDATE时,即使age等于25的记录不存在,InnoDB也会锁定相关的间隙,阻止其他事务插入符合条件的数据。间隙锁在并发插入频繁的场景下极易引发死锁,需要特别注意。
二、死锁是如何产生的
死锁的本质是多个事务互相等待对方持有的锁,形成循环等待,谁都无法继续执行。经典的场景是:事务A锁定了行1,接下来想锁定行2;事务B锁定了行2,接下来想锁定行1。两个事务各持有一把锁又都在等对方释放,于是陷入僵局。数据库的死锁检测机制发现循环等待后,通常会选择回滚代价较小的事务作为牺牲者,让另一个事务继续执行。
下面用代码还原一个典型的死锁场景。两个会话几乎同时执行,但加锁顺序相反:
-- 会话A BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; -- 锁住id=1 UPDATE account SET balance = balance + 100 WHERE id = 2; -- 等待id=2的锁 -- 会话B(几乎同时执行) BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 2; -- 锁住id=2 UPDATE account SET balance = balance + 100 WHERE id = 1; -- 等待id=1的锁,死锁发生
除了加锁顺序不一致,缺少索引也是死锁的常见诱因。当UPDATE或DELETE语句的过滤条件没有索引支撑时,InnoDB只能走全表扫描,并对扫描到的每一行都加锁,实际上相当于锁住了大量甚至全部数据行。原本只想更新一条记录的事务,却锁住了几十万条数据,其他事务被大范围阻塞,死锁概率自然大大增加。
事务持有锁的时间过长同样危险。有些开发者习惯在事务中间调用外部接口、执行耗时计算,或者事务开启后长时间不提交,这期间所有已获取的锁都不会释放,其他事务只能排队等待,系统并发能力急剧下降,死锁和超时问题接踵而至。
三、如何定位和分析死锁
遇到死锁问题时,第一步是拿到死锁日志。MySQL可以通过SHOW ENGINE INNODB STATUS命令查看最近的死锁信息,重点关注LATEST DETECTED DEADLOCK部分,里面记录了两个事务各自持有的锁和正在等待的锁,以及最终被回滚的事务。
-- 查看最近一次死锁详情 SHOW ENGINE INNODB STATUS; -- 开启把所有死锁记录到错误日志 SET GLOBAL innodb_print_all_deadlocks = ON; -- 查看当前锁等待情况 SELECT * FROM information_schema.INNODB_TRX; SELECT * FROM performance_schema.data_lock_waits;
分析死锁日志时,重点看几个字段:TRANSACTION部分展示了事务正在执行的SQL语句;HOLDS THE LOCK(S)说明该事务持有哪个索引上的什么模式的锁;WAITING FOR则说明它在等待哪把锁。把两个事务的信息对照着看,就能还原出完整的循环等待链条,进而定位到具体是哪两条SQL、哪个索引、哪些行之间发生了冲突。
如果死锁问题频繁出现且难以直接定位,可以在测试环境复现时借助性能视图持续监控锁等待链,或者开启数据库的锁等待超时日志。对于已经上线的系统,把应用层的死锁异常捕获下来并记录完整的事务上下文,也是排查问题的重要手段。数据积累得越多,死锁的规律就越容易总结出来。
四、死锁的解决方案与预防实践
最核心的解决办法是让所有事务按照相同的顺序访问资源。比如上面的转账例子,无论哪个方向的转账,都约定先操作id小的账户再操作id大的账户,循环等待的条件就不成立了,死锁自然无从发生。在业务设计阶段就应该梳理清楚资源的访问顺序,并作为编码规范固定下来。
第二个手段是为查询条件建立合适的索引,确保更新和删除语句能够精准锁定目标行。索引建好后,InnoDB只需要锁住索引命中的少量记录,锁的持有范围大幅缩小。可以通过EXPLAIN分析执行计划,确认SQL没有退化为全表扫描。同时要注意,如果查询条件涉及的列上索引区分度太低,优化器可能放弃索引,这也需要纳入考量。
第三是控制事务的粒度和持有时间。事务内部只保留必要的数据库操作,把远程调用、复杂计算等耗时逻辑移到事务外;事务尽早提交,缩短锁的持有时间。对于一次性处理大批量数据的场景,可以拆分成小批次提交,每批几百到几千条,既减小锁的范围,也降低主从复制的压力。
第四是利用更低隔离级别或乐观并发控制。如果业务能接受不可重复读,可以将隔离级别从可重复读降到读已提交,减少间隙锁的使用;对于冲突概率很低的场景,可以改用乐观锁,在表中增加版本号字段,更新时通过WHERE version = 旧版本号判断是否有人修改过,失败则重试,完全避免了锁等待。
最后还要做好应用层的兜底。捕获死锁异常后自动重试是标准做法,因为死锁被数据库检测到后回滚的一方重试通常就能成功。需要注意的是,重试次数要有上限,同时重试的事务必须是完整的、可重入的,否则可能造成数据不一致。
五、总结
锁机制是数据库并发控制的基石,理解共享锁、排他锁、意向锁和间隙锁的工作原理,是分析和解决死锁问题的前提。死锁的根源几乎都可以归结为加锁顺序不一致、锁范围过大和事务持有时间过长这三类。实践中只要坚持统一的资源访问顺序、建好索引缩小锁范围、保持事务短小精悍,再配合应用层的重试兜底,绝大多数死锁问题都能被有效消除。养成良好的事务编写习惯,比事后救火要划算得多。