MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

MySQL死锁现象深度剖析

2021-06-127.0k 阅读

1. MySQL 死锁基础概念

在深入剖析 MySQL 死锁现象之前,我们先来明确一下死锁的基本概念。死锁是指两个或多个事务在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,这些事务都将无法推进下去。

在 MySQL 数据库中,锁是实现事务隔离性和一致性的重要机制。当多个事务并发访问和修改数据时,锁可以防止数据的不一致性。然而,如果锁的使用不当,就可能导致死锁的发生。

1.1 死锁产生的必要条件

死锁的产生通常需要满足以下四个必要条件:

  1. 互斥条件:资源在某一时刻只能被一个事务占用。例如,在 MySQL 中,当一个事务对某一行数据加锁后,其他事务就不能同时对该行数据加相同类型的锁。
  2. 占有并等待条件:一个事务已经占有了至少一个资源,但又请求新的资源,而该资源已被其他事务占有,于是该事务只能等待。
  3. 不可剥夺条件:事务所获得的资源在未使用完之前,不能被其他事务强行剥夺,只能由该事务自己释放。
  4. 循环等待条件:存在一个事务等待队列 {T1, T2, ..., Tn},其中 T1 等待 T2 占用的资源,T2 等待 T3 占用的资源,以此类推,Tn 等待 T1 占用的资源,形成一个循环等待链。

2. MySQL 锁的类型

理解 MySQL 死锁,必须先清楚 MySQL 中锁的类型。MySQL 支持多种类型的锁,不同类型的锁在死锁形成过程中扮演着不同角色。

2.1 共享锁(Shared Lock,S 锁)

共享锁又称为读锁,若事务 T1 对数据对象 A 加上共享锁,那么其他事务只能对 A 再加共享锁,而不能加排他锁,直到 T1 释放 A 上的共享锁。多个事务可以同时对同一数据对象加共享锁,这允许并发读操作。例如:

-- 事务 1
START TRANSACTION;
SELECT * FROM users WHERE user_id = 1 LOCK IN SHARE MODE;
-- 事务 2 可以同时执行类似操作
START TRANSACTION;
SELECT * FROM users WHERE user_id = 1 LOCK IN SHARE MODE;

2.2 排他锁(Exclusive Lock,X 锁)

排他锁又称写锁,若事务 T1 对数据对象 A 加上排他锁,那么其他事务不能再对 A 加任何类型的锁,直到 T1 释放 A 上的排他锁。这保证了在任何时刻,只有一个事务可以对数据进行修改。例如:

-- 事务 1
START TRANSACTION;
SELECT * FROM users WHERE user_id = 1 FOR UPDATE;
-- 事务 2 若此时尝试对 user_id = 1 的记录操作会被阻塞
START TRANSACTION;
UPDATE users SET username = 'new_name' WHERE user_id = 1;

2.3 意向锁

意向锁是 InnoDB 存储引擎自动加的,分为意向共享锁(IS 锁)和意向排他锁(IX 锁)。意向锁的作用是在事务要对数据行加共享锁或排他锁之前,先对包含这些数据行的表加意向锁。例如,当事务要对某一行数据加共享锁时,它会先对表加意向共享锁;要加排他锁时,先对表加意向排他锁。这样可以避免在表级锁和行级锁之间出现冲突。

2.4 行锁

行锁是 MySQL 中最细粒度的锁,它只锁定某一行数据。InnoDB 存储引擎默认使用行锁,这大大提高了并发性能。行锁又分为记录锁(Record Lock)、间隙锁(Gap Lock)和临键锁(Next - Key Lock)。

  1. 记录锁:记录锁是对某一行数据加锁。例如,SELECT * FROM users WHERE user_id = 1 FOR UPDATE; 会对 user_id 为 1 的那一行记录加锁。
  2. 间隙锁:间隙锁锁定的是两个相邻记录之间的间隙。例如,表中有 user_id 为 1 和 3 的记录,间隙锁会锁定 (1, 3) 这个间隙。间隙锁的作用是防止其他事务在这个间隙插入新记录,从而避免幻读问题。
  3. 临键锁:临键锁是记录锁和间隙锁的组合,它锁定的是一个记录及其前面的间隙。例如,对于 user_id 为 1 的记录,临键锁会锁定 (-∞, 1] 这个区间。

2.5 表锁

表锁是对整个表进行锁定。当一个事务对表加表锁时,其他事务对该表的任何操作都将被阻塞。表锁的优点是加锁和解锁速度快,缺点是并发性能差。例如:

-- 对表加共享表锁
LOCK TABLES users READ;
-- 对表加排他表锁
LOCK TABLES users WRITE;

3. 死锁场景示例及分析

下面我们通过具体的代码示例来分析不同场景下死锁是如何发生的。

3.1 场景一:两个事务交叉获取锁

假设有两个事务 T1T2,以及一张 accounts 表,表结构如下:

CREATE TABLE accounts (
    account_id INT PRIMARY KEY,
    balance DECIMAL(10, 2)
);

事务 T1 试图从 account_id 为 1 的账户向 account_id 为 2 的账户转账,事务 T2 则相反,从 account_id 为 2 的账户向 account_id 为 1 的账户转账。

-- 事务 T1
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;
COMMIT;

-- 事务 T2
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 2;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 1;
COMMIT;

当这两个事务并发执行时,就可能出现死锁。假设 T1 先执行 UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;,此时它获取了 account_id 为 1 的行锁。然后 T2 执行 UPDATE accounts SET balance = balance - 100 WHERE account_id = 2;,获取了 account_id 为 2 的行锁。接着 T1 试图获取 account_id 为 2 的行锁,而 T2 试图获取 account_id 为 1 的行锁,于是形成了循环等待,导致死锁。

3.2 场景二:间隙锁导致的死锁

假设有一张 orders 表,表结构如下:

CREATE TABLE orders (
    order_id INT PRIMARY KEY AUTO_INCREMENT,
    order_date DATE
);

假设表中已有 order_id 为 1 和 3 的记录。

-- 事务 T1
START TRANSACTION;
SELECT * FROM orders WHERE order_id > 1 AND order_id < 3 FOR UPDATE;
-- 事务 T1 此时持有间隙锁 (1, 3)

-- 事务 T2
START TRANSACTION;
INSERT INTO orders (order_date) VALUES ('2023 - 01 - 01');
-- 事务 T2 试图插入数据,由于间隙锁 (1, 3) 的存在,被阻塞

-- 事务 T1
INSERT INTO orders (order_date) VALUES ('2023 - 02 - 02');
-- 事务 T1 试图插入数据,由于事务 T2 已经开始,也被阻塞

在这个场景中,事务 T1 对间隙 (1, 3) 加了间隙锁,事务 T2 试图插入数据时被阻塞。然后事务 T1 也试图插入数据,由于事务 T2 已经启动,T1 也被阻塞,从而形成死锁。

3.3 场景三:意向锁与行锁的冲突导致死锁

假设有一张 products 表,表结构如下:

CREATE TABLE products (
    product_id INT PRIMARY KEY,
    product_name VARCHAR(100)
);
-- 事务 T1
START TRANSACTION;
SELECT * FROM products WHERE product_id = 1 FOR UPDATE;
-- 事务 T1 对 product_id 为 1 的记录加行排他锁

-- 事务 T2
START TRANSACTION;
LOCK TABLES products WRITE;
-- 事务 T2 试图对表加排他表锁,由于事务 T1 持有行排他锁,事务 T2 被阻塞

-- 事务 T1
UPDATE products SET product_name = 'new_name' WHERE product_id = 2;
-- 事务 T1 试图对 product_id 为 2 的记录加锁,由于事务 T2 持有意向排他锁(表锁申请会先加意向排他锁),事务 T1 被阻塞

在这个场景中,事务 T1 持有行排他锁,事务 T2 试图加排他表锁,由于意向锁机制,事务 T2 会先加意向排他锁,这就导致事务 T1 后续操作被阻塞,而事务 T2 由于事务 T1 持有行锁也被阻塞,从而形成死锁。

4. MySQL 死锁检测与处理机制

MySQL 提供了死锁检测和处理机制,以避免死锁导致系统长时间无法响应。

4.1 死锁检测

InnoDB 存储引擎有一个死锁检测线程,它会定期检查是否存在死锁。当检测到死锁时,InnoDB 会选择一个回滚代价最小的事务进行回滚,以打破死锁局面。回滚代价通常包括事务已修改的行数、已持有的锁数量等因素。

4.2 死锁日志查看

MySQL 会将死锁相关信息记录到错误日志中。通过查看错误日志,可以获取死锁发生的时间、涉及的事务、死锁类型等详细信息。例如,在 MySQL 错误日志中可能会看到类似如下的记录:

2023 - 05 - 10 10:00:00 140414414131456 [ERROR] InnoDB: Deadlock found when trying to get lock; trying to break deadlock by rolling back transaction

4.3 应用层处理

除了 MySQL 自身的死锁检测和处理机制外,应用层也可以采取一些措施来减少死锁的发生。例如,在编写事务代码时,尽量按照相同的顺序获取锁,避免交叉获取锁的情况。同时,可以设置合理的事务超时时间,当事务等待锁的时间超过一定阈值时,自动回滚事务,避免长时间等待导致死锁。

5. 避免死锁的策略与最佳实践

为了减少死锁的发生概率,我们可以遵循以下策略和最佳实践。

5.1 按相同顺序访问资源

在多个事务中,尽量按照相同的顺序访问资源。例如,在前面转账的例子中,如果所有事务都先对 account_id 小的账户进行操作,再对 account_id 大的账户进行操作,就可以避免死锁。

-- 事务 T1
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;
COMMIT;

-- 事务 T2
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;
COMMIT;

5.2 减少锁的持有时间

尽量缩短事务中持有锁的时间,在获取锁完成必要操作后,尽快释放锁。例如,将一些与锁无关的操作放在事务外部执行。

-- 不推荐做法
START TRANSACTION;
SELECT * FROM products WHERE product_id = 1 FOR UPDATE;
-- 执行一些耗时操作,与锁无关
CALL some_long_running_procedure();
UPDATE products SET product_name = 'new_name' WHERE product_id = 1;
COMMIT;

-- 推荐做法
-- 执行一些耗时操作,与锁无关
CALL some_long_running_procedure();
START TRANSACTION;
SELECT * FROM products WHERE product_id = 1 FOR UPDATE;
UPDATE products SET product_name = 'new_name' WHERE product_id = 1;
COMMIT;

5.3 合理设置事务隔离级别

不同的事务隔离级别对锁的使用有不同影响。例如,在 READ - COMMITTED 隔离级别下,锁的持有时间相对较短,可能减少死锁的发生概率。但需要注意的是,降低隔离级别可能会带来其他数据一致性问题,需要根据具体业务需求进行权衡。

-- 设置事务隔离级别为 READ - COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- 事务操作
COMMIT;

5.4 优化 SQL 语句

通过优化 SQL 语句,减少锁的争用。例如,避免全表扫描,使用合适的索引,这样可以减少锁的范围和时间。例如,对于如下查询:

-- 未使用索引,可能导致全表扫描和大量锁争用
SELECT * FROM users WHERE username = 'test_user';

-- 使用索引优化
CREATE INDEX idx_username ON users (username);
SELECT * FROM users WHERE username = 'test_user';

5.5 重试机制

在应用层设置重试机制,当发生死锁导致事务回滚时,自动重试事务。但需要注意设置合理的重试次数和重试间隔,避免无限重试导致系统资源耗尽。

6. 总结常见死锁原因及预防措施

综合前面的分析,我们总结一下常见的死锁原因及预防措施。

6.1 交叉获取锁

原因:多个事务以不同顺序获取锁,形成循环等待。 预防措施:按照固定顺序获取锁,如按主键从小到大的顺序。

6.2 锁持有时间过长

原因:事务在持有锁的情况下执行大量与锁无关的操作。 预防措施:将与锁无关的操作放在事务外部执行,缩短锁的持有时间。

6.3 间隙锁与并发插入

原因:间隙锁阻止其他事务在间隙插入数据,并发插入操作可能导致死锁。 预防措施:合理调整事务执行顺序,避免并发插入与间隙锁冲突。

6.4 意向锁与行锁冲突

原因:意向锁和行锁的申请顺序不当,导致互相等待。 预防措施:了解意向锁机制,合理安排事务对表锁和行锁的申请顺序。

通过深入理解 MySQL 死锁的原理、常见场景以及检测和预防机制,开发人员可以在编写数据库应用程序时,采取有效的措施来减少死锁的发生,提高系统的并发性能和稳定性。同时,及时查看死锁日志,分析死锁原因,也是优化数据库性能的重要手段。在实际应用中,需要根据具体业务场景和数据特点,灵活运用这些知识和技巧,构建高效、稳定的数据库系统。