InnoDB中的死锁
死锁是指多个事务由于互相持有对方需要的锁, 谁也无法继续往下执行的情况。因为都在等待资源, 谁都不会释放自己持有的锁。
比如通过 UPDATE
或者 SELECT ... FOR UPDATE
之类的语句, 锁定多张表或者多个行时, 如果以相反的顺序来执行, 就可能会发生死锁。如果SQL语句需要锁定索引范围、或者锁定间隙时, 由于时序问题, 每个事务都只获取到了一部分锁时, 也会发生死锁。
要减少死锁产生的可能性:
- 请使用事务,尽量不要使用
LOCK TABLES
语句; - 让执行
insert
或update
的事务足够小, 这样事务开启的时间不会太长; - 不同的事务更新多张表或者大范围的行时, 让每个事务都保持相同的操作顺序;
- 在
SELECT ... FOR UPDATE
和UPDATE ... WHERE
语句用到的列创建索引。
产生死锁的可能性不受隔离级别的影响, 因为隔离级别只是改变了读取操作的行为
, 而死锁则是由于写操作发生的
。
启用(默认开启的)死锁检测(deadlock detection)时, InnoDB会自动检测到哪里产生了死锁, 并自动回滚其中的一个事务(称为受害方, victim)。 如果使用 innodb_deadlock_detect
选项禁用了自动死锁检测, 则 InnoDB 只能通过 innodb_lock_wait_timeout
指定的超时时间来回滚事务。 即使应用程序逻辑是完全正确的, 也需要处理事务重试等情况。 我们可以使用 SHOW ENGINE INNODB STATUS
命令查看最近发生死锁的事务。 如果频繁发生死锁问题, 需要进行事务结构调整, 或者需要进行错误处理时, 可以对 mysqld
的启动参数指定 innodb_print_all_deadlocks
选项, 以将死锁相关的全部信息打印到错误日志中。
InnoDB死锁示例
下面通过示例来演示导致死锁时会发生怎样的错误。 这个示例中涉及两个客户端: A和B。
首先, 客户端A创建了一张表, 并插入一条数据, 然后开启事务。 在事务中, 客户端A通过共享模式查询,来获得该行的 S
锁:
# 客户端A
mysql> CREATE TABLE t (i INT) ENGINE = InnoDB;
Query OK, 0 rows affected (1.07 sec)
mysql> INSERT INTO t (i) VALUES(1);
Query OK, 1 row affected (0.09 sec)
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT * FROM t WHERE i = 1 LOCK IN SHARE MODE;
+------+
| i |
+------+
| 1 |
+------+
接下来,客户端B开启事务, 并尝试从表中删除这一行:
# 客户端B
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> DELETE FROM t WHERE i = 1;
删除操作需要获取X
锁。 但由于X
锁与A客户端持有的S
锁不兼容, 无法立即得到授权, 需要加入该行的锁请求等待队列进行排队, 客户端B因此被阻塞。
然后,客户端A也尝试从表中删除该行:
# 客户端A
mysql> DELETE FROM t WHERE i = 1;
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction
可以看到这里发生了死锁, 因为客户端A需要先获取X
锁才能删除该行。 但由于客户端B请求 X
锁, 正在等待客户端A释放S
锁, 所以客户端A的X
锁请求不能被授予。 而且是B客户端先请求的 X
锁, 导致A持有的S
锁也不能升级为X
锁。 结果就是 InnoDB 让其中一个客户端产生错误, 并释放其持有的锁。 客户端返回的错误信息类似这样:
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction
届时, 另一个客户端的锁请求会被授予, 接着执行, 从表中删除这一行数据。
Deadlock Detection
InnoDB 默认会开启死锁检测(
deadlock detection
), 能自动检测到事务产生的“死锁”, 并自动回滚其中的一个或多个事务以打破死锁状态。 InnoDB 会尝试选择回滚较小的事务, 至于事务的大小判断, 则取决于已经 inserted, updated, 和 deleted 的行数。
默认情况下, innodb_table_locks = 1
, 如果 autocommit = 0
, InnoDB会感知到表锁, 上层的MySQL也能感知行级锁。 否则, 如果涉及到 MySQL LOCK TABLES
语句设置的表锁, 或者由其他存储引擎设置的锁, 那么 InnoDB 无法自动检测到死锁。 只能通过系统变量 innodb_lock_wait_timeout
设置的超时时间来解决这类情况。
如果 InnoDB Monitor 输出的 LATEST DETECTED DEADLOCK
一节中包含了这样的信息: “TOO DEEP OR LONG SEARCH IN THE LOCK TABLE WAITS-FOR GRAPH, WE WILL ROLL BACK FOLLOWING TRANSACTION,” 就表明在等待列表中的事务数量达到了200个的限制。 超过200个事务的等待列表将被视为死锁, 尝试检查等待列表的事务将被回滚。 如果等待列表中的事务持有了超过 100万个以上的锁, 还有锁线程要来检查, 也可能会发生相同的错误。
禁用死锁检测
可以使用 innodb_deadlock_detect
选项来禁用死锁检测。
在高并发系统中, 多个线程等待同一个锁时, 死锁检测会导致响应速度变慢。 有时候, 依靠 innodb_lock_wait_timeout
指定的超时时间来进行事务回滚, 可能比自动死锁检测的效率更高。
如何降低死锁概率, 处理死锁错误
死锁是事务型关系数据库中的典型问题, 但死锁并不可怕, 除非是频繁发生死锁而导致无法执行某些事务。 通常, 由于死锁错误导致事务发生回滚时, 我们的应用程序需要重新执行这个事务【有些业务可以由人工触发】。
InnoDB 使用自动行级锁。 即使在插入或删除单行数据的事务中, 也可能会产生死锁。 因为这些操作并不是真正的原子
操作;插入或删除行对应的(一到多个)索引记录时, 数据库会自动上锁。
下面介绍的技术手段可以用来处理死锁, 并降低产生死锁的可能性:
-
随时通过
SHOW ENGINE INNODB STATUS
命令来查看最近死锁的原因。 可以帮助我们调整应用程序以避免死锁。 -
如果频繁发生死锁警告, 请启用
innodb_print_all_deadlocks
配置选项来收集更多 DEBUG 信息。 在MySQL “错误日志”中输出每一次死锁相关的信息。 调试完成后, 记得禁用此选项。 -
如果由于死锁而导致事务失败, 请务必重新执行事务。 死锁并不可怕, 一般来说再试一次就行。
-
保持事务小巧, 让事务持续的时间更短, 以减少发生冲突的可能性。
-
进行数据库更改后, 及时提交事务, 减少发生冲突的可能。 特别是, 不要让长时间交互的
mysql
会话保持打开状态却不提交事务。 -
如果使用锁定读(
SELECT ... FOR UPDATE
或者SELECT ... LOCK IN SHARE MODE
), 可以尝试切换到较低的隔离级别, 例如READ COMMITTED
。 -
如果在一个事务中修改多张表, 或者多组数据, 那么每次都以一致的顺序来执行这些操作。 这样事务可以形成定义明确的队列, 并且不会死锁。 例如, 将数据库操作封装到特定的函数或服务方法中, 或者调用保存服务, 而不要在多个地方编写零散的 INSERT, UPDATE 和 DELETE 语句。
-
合理添加索引。 这样我们的SQL查询就只需要扫描很少的索引记录, 上锁的记录也就更少。 可以使用
EXPLAIN SELECT
来确定MySQL服务器会默认使用哪个索引来执行SQL查询。 -
少加锁。 如果可以从旧版本快照中读取数据, 就没必要使用
FOR UPDATE
或者LOCK IN SHARE MODE
子句。 经常发生死锁的话, 使用READ COMMITTED
隔离级别会比较好, 因为同一事务中的每次一致性读, 都是从自己的新快照中读取。 -
如果没有其他办法, 那就用表级锁来让我们的事务串行化。 在InnoDB这种支持事务的存储引擎中, 使用
LOCK TABLES
的正确方法是: 先通过SET autocommit = 0
(而不是START TRANSACTION
)开启事务, 然后在事务中执行LOCK TABLES
, 直到明确提交事务之后再调用UNLOCK TABLES
。 例如, 需要从表t2
中读取数据, 并写入表t1
, 则可以按下面这种顺序来执行:SET autocommit=0; LOCK TABLES t1 WRITE, t2 READ; ... do something with tables t1 and t2 here ... COMMIT; UNLOCK TABLES;
表级锁可以防止其他会话对这张表进行并发更新, 也就避免了死锁, 但代价是对高负载的系统来说, 响应速度会变慢。
-
让事务串行化的另一种办法, 是创建一张“信号量(semaphore)” 辅助表, 里面只包含一行数据。 在读写其他表之前, 每个事务都要先更新这行数据。 这样也能保证所有事务以串行方式执行。 注意, 这种情况下, InnoDB 的死锁检测算法也会生效, 因为这种序列化操作对应的是行级锁。 使用 MySQL表锁时, 就只能通过超时来解决死锁问题了。