由索引导致的 MySQL 死锁分析

  • 共享锁(S Lock):允许事务读取一行数据,多个事务可以拿到一把S锁(即读读并行);
  • 排他锁(X Lock):允许事务删除或更新一行数据,多个事务有且只有一个事务可以拿到X锁(即写写/写读互斥);

1.MySQL 锁

在 MySQL 中锁的种类有很多,但是最基本的还是表锁和行锁:表锁指的是对一整张表加锁,一般是 DDL 处理时使用,也可以自己在 SQL 中指定;而行锁指的是锁定某一行数据或某几行,或行和行之间的间隙。行锁的加锁方法比较复杂,但是由于只锁住有限的数据,对于其它数据不加限制,所以并发能力强,通常都是用行锁来处理并发事务。表锁由 MySQL 服务器实现,行锁由存储引擎实现,常见的就是 InnoDb,所以通常我们在讨论行锁时,隐含的一层意义就是数据库的存储引擎为 InnoDb ,而 MyISAM 存储引擎只能使用表锁。

  • 表锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低;
  • 行锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。

1.1 行锁种类

在 MySQL 的源码里,InnoDB存储引擎定义了四种类型的行锁,如下:

#define LOCK_TABLE  16  /* table lock */
#define LOCK_REC    32  /* record lock */

/* Precise modes */
#define LOCK_ORDINARY   0   
#define LOCK_GAP    512 
#define LOCK_REC_NOT_GAP 1024   
#define LOCK_INSERT_INTENTION 2048
  • LOCK_ORDINARY:Next-Key 锁,锁一条记录及其之前的间隙,这是 RR 隔离级别用的最多的锁,从名字也能看出来;lock_mode X
  • LOCK_GAP:间隙锁,锁两个记录之间的 GAP,防止记录插入;lock_mode X locks gap before rec
  • LOCKRECNOT_GAP:记录锁 只锁记录;lock_mode X locks rec but not gap
  • LOCKINSERTINTENSION:插入意向锁,插入意向 GAP 锁,插入记录时使用,是 LOCK_GAP 的一种特例。lock_mode X locks gap before rec insert intention

    这四种行锁将是理解并解决数据库死锁的关键,我们下面将深入研究这四种锁的特点。但是在介绍这四种锁之前,让我们再来看下 MySQL 下锁的模式。

2.MySQL死锁

死锁是指两个或两个以上事务在执行过程中因争抢锁资源而造成的互相等待的现象。

解决死锁的方法:

  • 超时等待:

    即当两个事务互相等待时,当一个事务等待时间超过设置的阈值时,就将其回滚,另外事务继续进行。(缺点:如果回滚的事务更新了很多行,占用了较多的 undo log,那么在回滚的时候花费的时间比另外一个正常执行的事务花费的时间可能还要多,就不太合适)

  • wait-for graph(等待图):

    死锁碰撞检测,是一种较为主动的死锁检测机制,要求数据库保存锁的信息链表和事务等待链表两部分信息,通过这两个部分信息构造出一张图,在每个事务请求锁并发生等待时都会判断是否存在回路,如果在图中检测到回路,就表明有死锁产生,这时候InnoDB存储引擎会选择回滚 undo 量最小的事务。

3.索引可能导致的 MySQL 死锁

InnoDB 引擎,更新操作默认会加行级锁,行级锁会对索引加锁。如果更新语句使用多个索引,行锁会先锁非聚簇索引,再锁聚簇索引
如果两个事务中的 SQL 用到了不同的非聚簇索引或者一个用了一个没有使用(即使用索引的情况不同),这样的话就会导致这两个事务加行锁的顺序不一致,形成了多个事务之间资源(行锁)的循环等待,构成了死锁的四个必要条件之一,而其他3个条件(互斥、请求与保持、不剥夺)已经满足,所以最终导致了死锁。

  • 聚簇索引:

    表数据按照索引的顺序来存储的,也就是说索引项的顺序与表中记录的物理顺序一致。对于聚簇索引,叶子结点即存储了真实的数据行,不再有另外单独的数据页。 一般情况下主键会默认创建聚簇索引,在一张表上最多只能创建一个聚簇索引,因为真实数据的物理顺序只能有一种。
    每个叶子节点包含主键值、事务ID、回滚指针(rollback pointer用于事务和MVCC——多版本并发控制技术)和余下的列。

    "聚簇"指实际的数据行和相关的键值都保存在一起。

    聚簇索引的二级索引:叶子节点不会保存引用的行的物理位置,而是保存了行的主键值。

  • 非聚簇索引:

    表数据存储顺序与索引顺序无关。对于非聚簇索引,叶结点包含索引字段值及指向数据页数据行的逻辑指针,其行数量与数据表行数据量一致。

    MyISAM的B+Tree的叶子节点上的data,并不是数据本身,而是数据存放的地址。主索引和辅助索引没啥区别,只是主索引中的key一定得是唯一的。

INNODB和MYISAM的主键索引与二级索引的对比:

4.开启mysql慢查询和死锁日志

显示慢查询状态及日志目录

show variables like '%slow_query_log%';  

开启慢查询(说明: 1开启;0关闭;)

set global slow_query_log = 1;  

显示慢查询阈值(单位秒),默认执行时间超过10s才会被记录到日志

show variables like '%long_query%';  

设置慢查询阈值 (注意:设置后需要重新打开mysql客户端才能到最新的值)

set global long_query_time = 0.8;  

查看死锁的日志是否开启

show variables like "%innodb_print_all_deadlocks%";  

开启记录死锁

set global innodb_print_all_deadlocks=1  

InnoDB 行锁等待超时时间(默认为50秒)

show variables like 'innodb_lock_wait_timeout'; -- 查看当前会话  
show global variables like 'innodb_lock_wait_timeout'; -- 查看全局设置  

5.获取死锁日志信息

show engine innodb status  

该命令可以用来获取死锁信息,但是它有个限制,只能拿到最近一次的死锁日志。如果想获得周期性的日志可以使用MySQL 提供的一套 InnoDb 监控机制。

死锁日志中列出了死锁发生的时间,以及导致死锁的事务信息(只显示两个事务,如果由多个事务导致的死锁也只显示两个),并显示出每个事务正在执行的 SQL 语句、等待的锁以及持有的锁信息等。

日志中常见关键字样:

LOCK WAIT 表示事务正在等待锁  
n lock struct(s) 表示该事务的锁链表的长度为 n  
n row lock(s) 表示当前事务持有的行锁个数  
RECORD LOCKS 表示记录锁  
lock_mode X 表示该记录锁为排他锁  
insert intention waiting 表示要加的锁为插入意向锁,并处于锁等待状态  

具体的死锁分析和解决办法,可看文末参考文章。

6.如何尽量避免死锁

对于 MySQL 的 InnoDb 存储引擎来说,死锁问题是避免不了的,没有哪种解决方案可以说完全解决死锁问题,但是我们可以通过一些可控的手段,降低出现死锁的概率。

  • 1.对索引加锁顺序的不一致很可能会导致死锁;
    所以如果可以,尽量以相同的顺序来访问索引记录和表。在程序以批量方式处理数据的时候,如果事先对数据排序,保证每个线程按固定的顺序来处理记录,也可以大大降低出现死锁的可能;

  • 2.Gap 锁往往是程序中导致死锁的真凶;
    由于默认情况下 MySQL 的隔离级别是 RR,所以如果能确定幻读和不可重复读对应用的影响不大,可以考虑将隔离级别改成 RC,可以避免 Gap 锁导致的死锁;

  • 3.为表添加合理的索引,如果不走索引将会为表的每一行记录加锁,死锁的概率就会大大增大;

  • 4.我们知道 MyISAM 只支持表锁,它采用一次封锁技术来保证事务之间不会发生死锁,所以,我们也可以使用同样的思想,在事务中一次锁定所需要的所有资源,减少死锁概率;

  • 5.避免大事务;
    尽量将大事务拆成多个小事务来处理;因为大事务占用资源多,耗时长,与其他事务冲突的概率也会变高;

  • 6.避免在同一时间点运行多个对同一表进行读写的脚本; 特别注意加锁且操作数据量比较大的语句;我们经常会有一些定时脚本,应该避免它们在同一时间点运行;

  • 7.设置锁等待超时参数:innodb_lock_wait_timeout
    这个参数并不是只用来解决死锁问题,在并发访问比较高的情况下,如果大量事务因无法立即获得所需的锁而挂起,会占用大量计算机资源,造成严重性能问题,甚至拖跨数据库。我们通过设置合适的锁等待超时阈值,可以避免这种情况发生。


可参考:
一则由于索引导致的MySQL死锁分析
解决死锁之路(终结篇) - 再见死锁