解决死锁之路(终结篇)- 再见死锁



1、开启锁监控
mysql

在遇到线上死锁问题时,咱们应该第一时间获取相关的死锁日志。咱们能够经过 show engine innodb status 命令来获取死锁信息,可是它有个限制,只能拿到最近一次的死锁日志。MySQL 提供了一套 InnoDb 的监控机制,用于周期性(每隔 15 秒)输出 InnoDb 的运行状态到 mysqld 服务的标准错误输出(stderr)。默认状况下监控是关闭的,只有当须要分析问题时再开启,而且在分析问题以后,建议将监控关闭,由于它对数据库的性能有必定影响,另外每 15 秒输出一第二天志,会使日志文件变得特别大。web

InnoDb 的监控主要分为四种:标准监控(Standard InnoDB Monitor)、锁监控(InnoDB Lock Monitor)、表空间监控(InnoDB Tablespace Monitor)和表监控(InnoDB Table Monitor)。后两种监控已经基本上废弃了,关于各类监控的做用能够参考 MySQL 的官方文档 Enabling InnoDB Monitors 或者 这篇文章。sql

要获取死锁日志,咱们须要开启 InnoDb 的标准监控,我推荐将锁监控也打开,它能够提供一些额外的锁信息,在分析死锁问题时会颇有用。开启监控的方法有两种:数据库

1. 基于系统表

MySQL 使用了几个特殊的表名来做为监控的开关,好比在数据库中建立一个表名为 innodb_monitor 的表开启标准监控,建立一个表名为 innodb_lock_monitor 的表开启锁监控。MySQL 经过检测是否存在这个表名来决定是否开启监控,至于表的结构和表里的内容无所谓。相反的,若是要关闭监控,则将这两个表删除便可。这种方法有点奇怪,在 5.6.16 版本以后,推荐使用系统参数的形式开启监控。微信

1网络

2并发

3app

4框架

5工具

6

7

8

9

10

11

-- 开启标准监控

CREATE TABLE innodb_monitor (a INT) ENGINE=INNODB;

 

-- 关闭标准监控

DROP TABLE innodb_monitor;

 

-- 开启锁监控

CREATE TABLE innodb_lock_monitor (a INT) ENGINE=INNODB;

 

-- 关闭锁监控

DROP TABLE innodb_lock_monitor;

2. 基于系统参数

在 MySQL 5.6.16 以后,能够经过设置系统参数来开启锁监控,以下:

1

2

3

4

5

6

7

8

9

10

11

-- 开启标准监控

set GLOBAL innodb_status_output=ON;

 

-- 关闭标准监控

set GLOBAL innodb_status_output=OFF;

 

-- 开启锁监控

set GLOBAL innodb_status_output_locks=ON;

 

-- 关闭锁监控

set GLOBAL innodb_status_output_locks=OFF;

另外,MySQL 提供了一个系统参数 innodb_print_all_deadlocks 专门用于记录死锁日志,当发生死锁时,死锁日志会记录到 MySQL 的错误日志文件中。

1

set GLOBAL innodb_print_all_deadlocks=ON;

除了 MySQL 自带的监控机制,还有一些有趣的监控工具也颇有用,好比 Innotop 和 Percona Toolkit 里的小工具 pt-deadlock-logger。

2、读懂死锁日志

一切准备就绪以后,咱们从 DBA 那里拿到了死锁日志(其中的SQL语句作了省略):

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

------------------------

LATEST DETECTED DEADLOCK

------------------------

2017-09-06 11:58:16 7ff35f5dd700

*** (1) TRANSACTION:

TRANSACTION 182335752, ACTIVE 0 sec inserting

mysql tables in use 1, locked 1

LOCK WAIT 11 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 15

MySQL thread id 12032077, OS thread handle 0x7ff35ebf6700, query id 196418265 10.40.191.57 RW_bok_db update

INSERT INTO bok_task

                 ( order_id ...

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:

RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task`

    trx id 182335752 lock_mode X insert intention waiting

*** (2) TRANSACTION:

TRANSACTION 182335756, ACTIVE 0 sec inserting

mysql tables in use 1, locked 1

11 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 15

MySQL thread id 12032049, OS thread handle 0x7ff35f5dd700, query id 196418268 10.40.189.132 RW_bok_db update

INSERT INTO bok_task

                 ( order_id ...

*** (2) HOLDS THE LOCK(S):

RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task`

    trx id 182335756 lock_mode X

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:

RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task`

    trx id 182335756 lock_mode X insert intention waiting

*** WE ROLL BACK TRANSACTION (2)

日志中列出了死锁发生的时间,以及致使死锁的事务信息(只显示两个事务,若是由多个事务致使的死锁也只显示两个),并显示出每一个事务正在执行的 SQL 语句、等待的锁以及持有的锁信息等。下面咱们就来研究下这份死锁日志,看看从这份死锁日志中能不能发现死锁的缘由?

首先看事务一的信息:

* (1) TRANSACTION:
TRANSACTION 182335752, ACTIVE 0 sec inserting

ACTIVE 0 sec 表示事务活动时间,inserting 为事务当前正在运行的状态,可能的事务状态有:fetching rows,updating,deleting,inserting 等。

mysql tables in use 1, locked 1
LOCK WAIT 11 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 15

tables in use 1 表示有一个表被使用,locked 1 表示有一个表锁。LOCK WAIT 表示事务正在等待锁,11 lock struct(s) 表示该事务的锁链表的长度为 11,每一个链表节点表明该事务持有的一个锁结构,包括表锁,记录锁以及 autoinc 锁等。heap size 1184 为事务分配的锁堆内存大小。
2 row lock(s) 表示当前事务持有的行锁个数,经过遍历上面提到的 11 个锁结构,找出其中类型为 LOCK_REC 的记录数。undo log entries 15 表示当前事务有 15 个 undo log 记录,由于二级索引不记 undo log,说明该事务已经更新了 15 条汇集索引记录。

MySQL thread id 12032077, OS thread handle 0x7ff35ebf6700, query id 196418265 10.40.191.57 RW_bok_db update

事务的线程信息,以及数据库 IP 地址和数据库名,对咱们分析死锁用处不大。

INSERT INTO bok_task

1

( order_id ...

这里显示的是正在等待锁的 SQL 语句,死锁日志里每一个事务都只显示一条 SQL 语句,这对咱们分析死锁很不方便,咱们必需要结合应用程序去具体分析这个 SQL 以前还执行了哪些其余的 SQL 语句,或者根据 binlog 也能够大体找到一个事务执行的 SQL 语句。

* (1) WAITING FOR THIS LOCK TO BE GRANTED:

RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task` trx id 182335752 lock_mode X insert intention waiting

这里显示的是事务正在等待什么锁。RECORD LOCKS 表示记录锁(而且能够看出要加锁的索引为 order_id_un),space id 为 300,page no 为 5480,n bits 552 表示这个记录锁结构上留有 552 个 bit 位(该 page 上的记录数 + 64)。
lock_mode X 表示该记录锁为排他锁,insert intention waiting 表示要加的锁为插入意向锁,并处于锁等待状态。

在上面有提到 innodb_status_output_locks 这个系统变量能够开启 InnoDb 的锁监控,若是开启了,这个地方还会显示出锁的一些额外信息,包括索引记录的 info bits 和数据信息等:

1

2

3

Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0

 0: len 4; hex 80000002; asc     ;;

 1: len 4; hex 80000001; asc     ;;

在 《了解常见的锁类型》 中咱们说过,一共有四种类型的行锁:记录锁,间隙锁,Next-key 锁和插入意向锁。这四种锁对应的死锁日志各不相同,以下:

  • 记录锁(LOCK_REC_NOT_GAP): lock_mode X locks rec but not gap

  • 间隙锁(LOCK_GAP): lock_mode X locks gap before rec

  • Next-key 锁(LOCK_ORNIDARY): lock_mode X

  • 插入意向锁(LOCK_INSERT_INTENTION): lock_mode X locks gap before rec insert intention

这里有一点要注意的是,并非在日志里看到 lock_mode X 就认为这是 Next-key 锁,由于还有一个例外:若是在 supremum record 上加锁,locks gap before rec 会省略掉,间隙锁会显示成 lock_mode X,插入意向锁会显示成 lock_mode X insert intention。譬以下面这个:

1

2

RECORD LOCKS space id 0 page no 307 n bits 72 index `PRIMARY` of table `test`.`test` trx id 50F lock_mode X

Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0

看起来像是 Next-key 锁,可是看下面的 heap no 1 表示这个记录是 supremum record(另外,infimum record 的 heap no 为 0),因此这个锁应该看做是一个间隙锁。

看完第一个事务,再来看看第二个事务:

* (2) TRANSACTION:

TRANSACTION 182335756, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
11 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 15
MySQL thread id 12032049, OS thread handle 0x7ff35f5dd700, query id 196418268 10.40.189.132 RW_bok_db update
INSERT INTO bok_task

1

( order_id ...

* (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task` trx id 182335756 lock_mode X
* (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task` trx id 182335756 lock_mode X insert intention waiting

事务二和事务一的日志基本相似,不过它多了一部分 HOLDS THE LOCK(S),表示事务二持有什么锁,这个锁每每就是事务一处于锁等待的缘由。这里能够看到事务二正在等待索引 order_id_un 上的插入意向锁,而且它已经持有了一个 X 锁(Next-key 锁,也有多是 supremum 上的间隙锁)。

到这里为止,咱们获得了不少关键信息,此时咱们能够逆推出死锁发生的缘由吗?这可能也是每一个开发人员和 DBA 最关心的问题,如何经过死锁日志来诊断死锁的成因?实际上这是很是困难的。

若是每一个事务都只有一条 SQL 语句,这种状况的死锁成因还算比较好分析,由于咱们能够从死锁日志里找到每一个事务执行的 SQL 语句,只要对这两条 SQL 语句的加锁过程有必定的了解,死锁缘由通常不难定位。但也有可能死锁的成因很是隐蔽,这时须要咱们对这两条 SQL 语句的加锁流程作很是深刻的研究才有可能分析出死锁的根源。

不过大多数状况下,每一个事务都不止一条 SQL 语句,譬如上面的死锁日志里显示的 undo log entries 15,说明执行 INSERT 语句以前确定还执行了其余的 SQL 语句,可是具体是什么,咱们不得而知,咱们只能根据 HOLDS THE LOCK(S) 部分知道有某个 SQL 语句对 order_id_un 索引加了 Next-key 锁(或间隙锁)。另外事务二在 WAITING FOR 插入意向锁,至于它和事务一的哪一个锁冲突也不得而知,由于事务一的死锁日志里并无 HOLDS THE LOCK(S) 部分。

因此,对死锁的诊断不能仅仅靠死锁日志,还应该结合应用程序的代码来进行分析,若是实在接触不到应用代码,还能够经过数据库的 binlog 来分析(只要你的死锁不是 100% 必现,那么 binlog 日志里确定能找到一份完整的事务一和事务二的 SQL 语句)。经过应用代码或 binlog 理出每一个事务的 SQL 执行顺序,这样分析死锁时就会容易不少。

3、常见死锁分析

尽管上面说经过死锁日志来推断死锁缘由很是困难,但我想也不是彻底不可能。我在 Github 上新建了一个项目 mysql-deadlocks,这个项目收集了一些常见的 MySQL 死锁案例,大多数案例都来源于网络,并对它们进行分类汇总,试图经过死锁日志分析出每种死锁的缘由,还原出死锁现场。这虽然有点痴人说梦的感受,但仍是但愿能给后面的开发人员在定位死锁问题时带来一些便利。

我将这些死锁按事务执行的语句和正在等待或已持有的锁进行分类汇总(目前已经收集了十余种死锁场景):

表中的语句虽然只列出了 delete 和 insert,但实际上绝大多数的 delete 语句和 update 或 select ... for update 加锁机制是同样的,因此为了不重复,对于 update 语句就不在一块儿汇总了(固然也有例外,譬如使用 update 对索引进行更新时加锁机制和 delete 是有区别的,这种状况我会单独列出)。

对每个死锁场景,我都会定义一个死锁名称(实际上就是事务等待和持有的锁),每一篇分析,我都分红了 死锁特征、死锁日志、表结构、重现步骤、分析和参考 这几个部分。

对于这种分类方法我感受并非很好,但也想不出什么其余更好的方案,若是你有更好的建议,欢迎讨论。另外,若是你有新的死锁案例,或者对某个死锁的解释有异议,欢迎 给我提 Issue 或 PR。

下面咱们介绍几种常见的死锁场景,仍是之前面提到的 students 表为例:

其中,id 为主键,no(学号)为二级惟一索引,name(姓名)和 age(年龄)为二级非惟一索引,score(学分)无索引。数据库隔离级别为 RR。

3.1 死锁案例一

死锁的根本缘由是有两个或多个事务之间加锁顺序的不一致致使的,这个死锁案例实际上是最经典的死锁场景。

首先,事务 A 获取 id = 20 的锁(lock_mode X locks rec but not gap),事务 B 获取 id = 30 的锁;而后,事务 A 试图获取 id = 30 的锁,而该锁已经被事务 B 持有,因此事务 A 等待事务 B 释放该锁,而后事务 B 又试图获取 id = 20 的锁,这个锁被事务 A 占有,因而两个事务之间相互等待,致使死锁。

3.2 死锁案例二

首先事务 A 和事务 B 执行了两条 UPDATE 语句,可是因为 id = 25 和 id = 26 记录都不存在,事务 A 和 事务 B 并无更新任何记录,可是因为数据库隔离级别为 RR,因此会在 (20, 30) 之间加上间隙锁(lock_mode X locks gap before rec),间隙锁和间隙锁并不冲突。以后事务 A 和事务 B 分别执行 INSERT 语句要插入记录 id = 25 和 id = 26,须要在 (20, 30) 之间加插入意向锁(lock_mode X locks gap before rec insert intention),插入意向锁和间隙锁冲突,因此两个事务互相等待,最后造成死锁。

要解决这个死锁很简单,显然,前面两条 UPDATE 语句是无效的,将其删除便可。另外也能够将数据库隔离级别改为 RC,这样在 UPDATE 的时候就不会有间隙锁了。这个案例正是文章开头提到的死锁日志中的死锁场景,别看这个 UPDATE 语句是无效的,看起来很傻,可是确实是真实的场景,由于在真实的项目中代码会很是复杂,好比采用了 ORM 框架,应用层和数据层代码分离,通常开发人员写代码时都不知道会生成什么样的 SQL 语句,我也是从 DBA 那里拿到了 binlog,而后从里面找到了事务执行的全部 SQL 语句,发现其中居然有一行无效的 UPDATE 语句,最后追本溯源,找到对应的应用代码,将其删除,从而修复了这个死锁。

3.3 死锁案例三

别看这个案例里每一个事务都只有一条 SQL 语句,可是却实实在在可能会致使死锁问题,其实提及来,这个死锁和案例一并无什么区别,只不过理解起来要更深刻一点。要知道在范围查询时,加锁是一条记录一条记录挨个加锁的,因此虽然只有一条 SQL 语句,若是两条 SQL 语句的加锁顺序不同,也会致使死锁。

在案例一中,事务 A 的加锁顺序为:id = 20 -> 30,事务 B 的加锁顺序为:id = 30 -> 20,正好相反,因此会致使死锁。这里的情景也是同样,事务 A 的范围条件为 id < 30,加锁顺序为:id = 15 -> 18 -> 20,事务 B 走的是二级索引 age,加锁顺序为:(age, id) = (24, 18) -> (24, 20) -> (25, 15) -> (25, 49),其中,对 id 的加锁顺序为 id = 18 -> 20 -> 15 -> 49。能够看到事务 A 先锁 15,再锁 18,而事务 B 先锁 18,再锁 15,从而造成死锁。

3.4 如何避免死锁

在工做过程当中偶尔会遇到死锁问题,虽然这种问题遇到的几率不大,但每次遇到的时候要想完全弄懂其原理并找到解决方案却并不容易。其实,对于 MySQL 的 InnoDb 存储引擎来讲,死锁问题是避免不了的,没有哪一种解决方案能够说彻底解决死锁问题,可是咱们能够经过一些可控的手段,下降出现死锁的几率。

  1. 如上面的案例一和案例三所示,对索引加锁顺序的不一致极可能会致使死锁,因此若是能够,尽可能以相同的顺序来访问索引记录和表。在程序以批量方式处理数据的时候,若是事先对数据排序,保证每一个线程按固定的顺序来处理记录,也能够大大下降出现死锁的可能;

  2. 如上面的案例二所示,Gap 锁每每是程序中致使死锁的真凶,因为默认状况下 MySQL 的隔离级别是 RR,因此若是能肯定幻读和不可重复读对应用的影响不大,能够考虑将隔离级别改为 RC,能够避免 Gap 锁致使的死锁;

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

  4. 咱们知道 MyISAM 只支持表锁,它采用一次封锁技术来保证事务之间不会发生死锁,因此,咱们也可使用一样的思想,在事务中一次锁定所须要的全部资源,减小死锁几率;

  5. 避免大事务,尽可能将大事务拆成多个小事务来处理;由于大事务占用资源多,耗时长,与其余事务冲突的几率也会变高;

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

  7. 设置锁等待超时参数:innodb_lock_wait_timeout,这个参数并非只用来解决死锁问题,在并发访问比较高的状况下,若是大量事务因没法当即得到所需的锁而挂起,会占用大量计算机资源,形成严重性能问题,甚至拖跨数据库。咱们经过设置合适的锁等待超时阈值,能够避免这种状况发生。

总结

一开始是去年 9 月份的时候,线上某个系统遇到了一个死锁问题,当时对这个死锁百思不得其解,慢慢的从困惑到感兴趣,虽然那时花了大概一个礼拜的时间研究后就已经把这个死锁问题解决了,可是对死锁的执念却一直没有放下,开始翻阅大量的文档和资料,看 MySQL 官方文档,买 MySQL 书籍,甚至去读 MySQL 源码,从事务、隔离级别、索引一直看到加锁机制、死锁分析等等。再到后来,忽然以为没意思想放弃,感受就算知道了什么语句加什么锁也没有多大意义,这个都是死记硬背的东西,花时间把这些规则背下来没什么价值,还不如须要的时候本身去实验一把也就知道了。中间歇了有两个多月时间,后来想一想不能半途而废,逼本身必定要把这个系列写完,最后一篇死锁问题的分析确定须要收集而后对这些死锁问题进行大量的分析,因而去网上找了各类各样的死锁日志,而后一次一次的作实验,这不收集还好,一收集便停不下来,这应该就是死锁收藏癖吧。

对死锁的研究前先后后烧了很多的脑细胞,特别是后期收集死锁日志的时候,才发现死锁场景各式各样,有些死的很荒谬,有些死的很精妙,还有些死的不明不白,直到如今我还没搞懂为何。全部的这些死锁案例都收集在 这里,若是你感兴趣,欢迎来和我一块儿添砖加瓦,但愿有一天,真的能够和死锁说再见。




物流IT圈 



泛物流行业IT知识分享传播、从业人士互帮互助,覆盖快递快运/互联网物流平台/城配/即时配送/3PL/仓配/货代/冷链/物流软件公司/物流装备/物流自动化设备/物流机器人等细分行业。长按二维码即刻加入咱们,若是你是以上行业公司中的IT从业人士加运营小哥微信后可入群交流。

              公众号              

运营小哥


本文分享自微信公众号 - 物流IT圈(exiter18)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索