一、导读
在 MySQL 中,当两个或以上的事务相互持有和请求锁,并形成一个循环的依赖关系,就会产生死锁。在一个事务系统中,死锁是确切存在并且是不能完全避免的。 InnoDB 会自动检测事务死锁,立即回滚其中某个事务,并且返回一个错误。它根据某种机制来选择那个最简单(代价最小)的事务来进行回滚。偶然发生的死锁不必担心,但死锁频繁出现的时候就要引起注意了。
在 MySQL 5.6 之前,只有最新的死锁信息可以使用 show engine innodb status 命令来进行查看。使用 Percona Toolkit 工具包中的 pt-deadlock-logger 可以从 show engine innodb status 的结果中得到指定的时间范围内的死锁信息,同时写入文件或者表中,等待后面的诊断分析。对于 pt-deadlock-logger 工具的更多信息可以参考手册:https://www.percona.com/doc/percona-toolkit/2.2/pt-deadlock-logger.html 。 如果使用的是 MySQL 5.6 或以上版本,您可以启用一个新增的参数 innodb_print_all_deadlocks 把 InnoDB 中发生的所有死锁信息都记录在错误日志里面。
在开始讲诊断之前,得到应用程序捕捉到的死锁错误(MySQL error no. 1213),以及通过重试来处理失败事务是一个重要的做法。
二、如何诊断MySQL死锁
一个 MySQL 死锁可能会涉及到两个或以上的事务,而 LATEST DETECTED DEADLOCK 这一节仅仅展示了最后两个事务,并且只显示这两个事务中最后执行的一条语句和循环依赖中的锁信息。而忽略掉了前面执行过的有可能真正申请并持有锁的语句。下面我会给大家讲一些如何收集这些被忽略掉的语句的 tips。
让我们一起来看两个例子,看 LATEST DETECTED DEADLOCK 都给出了什么信息。例 1:
1 141013 6:06:22 2 *** (1) TRANSACTION: 3 TRANSACTION 876726B90, ACTIVE 7 sec setting auto-inc lock 4 mysql tables in use 1, locked 1 5 LOCK WAIT 9 lock struct(s), heap size 1248, 4 row lock(s), undo log entries 4 6 MySQL thread id 155118366, OS thread handle 0x7f59e638a700, query id87987781416 localhost msandbox update 7 INSERT INTO t1 (col1, col2, col3, col4) values (10, 20, 30, 'hello') 8 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: 9 TABLE LOCK table `mydb`.`t1` trx id 876726B90 lock mode AUTO-INC waiting 10 *** (2) TRANSACTION: 11 TRANSACTION 876725B2D, ACTIVE 9 sec inserting 12 mysql tables in use 1, locked 1 13 876 lock struct(s), heap size 80312, 1022 row lock(s), undo log entries 1002 14 MySQL thread id 155097580, OS thread handle 0x7f585be79700, query id87987761732 localhost msandbox update 15 INSERT INTO t1 (col1, col2, col3, col4) values (7, 86, 62, "a lot of things"),(7, 76, 62, "many more") 16 *** (2) HOLDS THE LOCK(S): 17 TABLE LOCK table `mydb`.`t1` trx id 876725B2D lock mode AUTO-INC 18 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: 19 RECORD LOCKS space id 44917 page no 529635 n bits 112 index `PRIMARY` of table`mydb`.`t2` trx id 876725B2D lock mode S locks rec but not gap waiting 20 *** WE ROLL BACK TRANSACTION (1) |
第 1 行是死锁发生的时间。如果你的应用程序捕捉和记录死锁错误到日志中,那么你可以根据这个时间戳和应用程序日志中的死锁错误的时间戳进行匹配。这样你可以得到已回滚的事务,及事务中的所有语句。
第 3 和 11 行,注意事务的序号和活跃时间。如果你定期地把 show engine innodb status 的输出信息记录到日志文件(这是一个很好的做法),那么你就可以使用事务编号在之前的输出日志中查到同一个事务中所希望看到的更多的语句。活跃时间提供了一个线索来判断这个事务是单个语句的事务,还是包含多个语句的事务。
第 4 和 12 行,使用到的表和锁只是针对于当前的语句。因此,使用到一张表,并不意味着事务仅仅涉及到一张表。
第 5 和 13 行,这里的信息需要重点关注,因为它告诉我们事务做了多少的改变,也就是 "undo log entries";"row lock(s)" 则告诉我们持有多少行锁。这些信息都会提示我们这个事务的复杂程度。
第 6 和 14 行,留意线程 ID、连接主机和用户。如果你在不同的应用程序中使用不同的 MySQL 用户,这将是另外一个好的习惯,这样你就可以根据连接主机和用户来定位到事务来自于哪个应用程序。
第 9 行,对于第一个事务,它只是显示了处于锁等待状态,在这个例子中,是表 t1 的 AUTO_INC 锁。其他的可能:共享锁(S),有间隙锁(gap lock)的排他锁(X),及没有间隙锁(gap lock)的排他锁(X)。
第 16 和 17 行,对于第二个事务,显示了它持有的锁,在本示例中,是事务1 (TRANSACTION (1)) 所请求并等待中的 AUTO-INC 锁。
第 18 和 19 行,显示了事务2 (TRANSACTION (2)) 所等待的锁的信息。在本例中,是一个在另一个表 t2 的主键上面共享的没有间隙的记录锁。在 InnoDB 中只有少数情况会产生 共享记录锁:
1) 使用了 SELECT … LOCK IN SHARE MODE 的语句
2) 外键引用记录
3) 源表上的共享锁,使用了 INSERT INTO… SELECT 的语句
事务2 的当前语句是一个简单的 insert to t1,所以 1 和 3 被排除了。通过检查 show create table t1,你可以确定共享锁(S) 是由于父表t2 的外键一致性约束。
例 2:使用 MySQL 社区版,每个记录锁的记录内容都会被打印出来。
1 2014-10-11 10:41:12 7f6f912d7700 2 *** (1) TRANSACTION: 3 TRANSACTION 2164000, ACTIVE 27 sec starting index read 4 mysql tables in use 1, locked 1 5 LOCK WAIT 3 lock struct(s), heap size 360, 2 row lock(s), undo log entries 1 6 MySQL thread id 9, OS thread handle 0x7f6f91296700, query id 87 localhost ro otupdating 7 update t1 set name = 'b' where id = 3 8 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: 9 RECORD LOCKS space id 1704 page no 3 n bits 72 index `PRIMARY` of table `test`.`t1`trx id 2164000 lock_mode X locks rec but not gap waiting 10 Record lock, heap no 4 PHYSICAL RECORD: n_fields 5; compact format; info bit s0 11 0: len 4; hex 80000003; asc ;; 12 1: len 6; hex 000000210521; asc ! !;; 13 2: len 7; hex 180000122117cb; asc ! ;; 14 3: len 4; hex 80000008; asc ;; 15 4: len 1; hex 63; asc c;; 16 17 *** (2) TRANSACTION: 18 TRANSACTION 2164001, ACTIVE 18 sec starting index read 19 mysql tables in use 1, locked 1 20 3 lock struct(s), heap size 360, 2 row lock(s), undo log entries 1 21 MySQL thread id 10, OS thread handle 0x7f6f912d7700, query id 88 localhost root updating 22 update t1 set name = 'c' where id = 2 23 *** (2) HOLDS THE LOCK(S): 24 RECORD LOCKS space id 1704 page no 3 n bits 72 index `PRIMARY` of table `test`.`t1`trx id 2164001 lock_mode X locks rec but not gap 25 Record lock, heap no 4 PHYSICAL RECORD: n_fields 5; compact format; info bit s0 26 0: len 4; hex 80000003; asc ;; 27 1: len 6; hex 000000210521; asc ! !;; 28 2: len 7; hex 180000122117cb; asc ! ;; 29 3: len 4; hex 80000008; asc ;; 30 4: len 1; hex 63; asc c;; 31 32 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: 33 RECORD LOCKS space id 1704 page no 3 n bits 72 index `PRIMARY` of table `test`.`t1`trx id 2164001 lock_mode X locks rec but not gap waiting 34 Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bit s0 35 0: len 4; hex 80000002; asc ;; 36 1: len 6; hex 000000210520; asc ! ;; 37 2: len 7; hex 17000001c510f5; asc ;; 38 3: len 4; hex 80000009; asc ;; 39 4: len 1; hex 62; asc b;; |
第 9 和 10 行:"space id" 是表空间 ID,"page no" 指出了这个表空间里面记录锁所在的数据页,"n bits" 不是数据页偏移量,而是锁位图里面的 bits 数。在第 10 行记录的 "heap no" 是数据页偏移量。
第 11 到 15 行:显示了记录数据的十六进制编码。字段 0 表示聚集索引(即主键),忽略最高位,值为 3。字段 1 表示最后修改这条记录的事务的ID号,上面实例中的十进制值是 2164001,即是 TRANSACTION (2)。字段 2 表示回滚指针。从字段 3 开始,表示的是余下的行数据:字段 3 表示一个整型列,值为 8,字段 4 表示一个字符串列,值为 'c'。通过阅读这些信息,我们可以准确知道哪一行被锁了,哪些是当前值。
三、我们还可以从分析中学到什么
既然大多数 MySQL 死锁是发生在两个事务之间,那么我们可以基于这个假设来进行分析。在例1 中,事务2 (trx(2)) 在等待一个共享锁,因此事务1 (trx(1)) 在表 t2 的主键记录上,要么持有一个共享锁,要么持有一个排他锁。假设 col2 是一个外键列,通过检查 trx(1) 的当前语句,我们可以知道它不需要相同的记录锁,因而它肯定是一些在 trx(1) 之前就已经申请了在 t2 主键索引上的 S 或者 X 锁的语句。trx(1) 在 7 秒钟里面仅改变了 4 行数据。
因此,我们可以得到 trx(1) 的一些特征:它做了很多处理,却做了很少变化;变化涉及到 t1 和 t2,单个记录插入到 t2。这些信息结合着其他数据可以帮助开发人员定位到那个事务。
四、我们还可以从哪里找到事物之前的语句
除了应用程序日志和之前的 show engine innodb status 的输出信息外,还可以利用 binlog、low log,甚至是 general log。
通过 binlog,如果 binlog_format = statement,binlog 中的每个 event 都会拥有一个 thread_id。只有已提交的事务会被记录到 binlog 中,因此,我们只能在 binlog 中查找 trx(2) 的细信息。在例1 中,我们知道死锁发生的时间,还有 trx(2) 是在 9 秒前开始的。我们可以执行 mysqlbinlog 命令来解析对应的 binlog 文件,并且寻找带有 thread_id = 155097580 的语句。使用应用程序代码来进行交叉确认也是很好的。
$ mysqlbinlog -vvv --start-datetime=“2014-10-13 6:06:12” --stop-datatime=“2014-10-136:06:22” mysql-bin.000010 > binlog_1013_0606.out |
在 Percona Server 5.5 或者更早版本,可以通过设置 log_slow_verbosity 选项来把 事务 ID 记录在 slow log 中。如果你设置了 long_query_time = 0,你可以捕获到包括那些回滚到 slow log 里面的所有语句。通过 general log,可以用 thread id 来查找相关的语句。
五、如何避免死锁
在了解死锁之后,我们可以做一些事情来避免它。
- 对应用程序进行调整/修改。在某些情况下,你可以通过把大事务分解成多个小事务,使得锁能够更快被释放,从而极大程度地降低死锁发生的频率。在其他情况下,死锁的发生是因为两个事务采用不同的顺序操作了一个或多个表的相同的数据集。需要改成以相同顺序读写这些数据集,换言之,就是对这些数据集的访问采用串行化方式。这样在并发事务时,就让死锁变成了锁等待。
- 修改表的 schema,例如删除外键约束来分离两张表,或者添加索引来减少扫描和锁定的行。
- 如果发生了间隙锁,你可以把会话或者事务的事务隔离级别更改为 RC(read committed)级别来避免,但此时需要把 binlog_format 设置成 row 或者 mixed 格式。
本文来自合作伙伴“DBGEEK”