一、死锁概述

(一)什么是数据库死锁

在多线程并发环境下,当多个事务同时操作数据库时,如果事务之间相互等待对方释放资源,而又不主动释放自己持有的资源,就会导致所有事务都无法继续执行,这种现象称为数据库死锁

死锁是数据库并发控制中的一个经典问题,尤其在高并发系统中更为常见。当死锁发生时,数据库系统通常会自动检测并解决死锁(通常是回滚其中一个事务),但这会导致应用程序出现异常,影响用户体验和系统性能。

(二)死锁产生的必要条件

死锁的发生必须同时满足以下四个条件:

  1. 互斥条件:资源是排他性的,一次只能被一个事务使用
  2. 请求与保持条件:事务已经持有了某些资源,同时又请求新的资源
  3. 不剥夺条件:事务获得的资源在未使用完之前,不能被强行剥夺
  4. 循环等待条件:存在一个事务资源的循环等待链,形成环路

只有同时满足这四个条件,死锁才会发生。因此,解决死锁问题的关键是破坏这四个条件中的至少一个。

(三)MySQL中的锁类型

MySQL中的锁主要分为以下几种类型:

1. 按锁的粒度分类

  • 表级锁:锁定整张表,开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低
  • 行级锁:锁定表中的某一行或多行记录,开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高
  • 页面锁:介于表锁和行锁之间,锁定数据库中的一个页(通常是4KB或8KB)

2. 按锁的模式分类

  • 共享锁(S锁):允许事务读取一行数据,阻止其他事务获得相同数据集的排他锁
  • 排他锁(X锁):允许获取排他锁的事务更新数据,阻止其他事务取得相同的数据集共享读锁和排他写锁
  • 意向共享锁(IS锁):表示事务意图在表中的某些行上设置共享锁
  • 意向排他锁(IX锁):表示事务意图在表中的某些行上设置排他锁

3. 特殊的锁类型

  • 记录锁:锁住索引记录,防止其他事务修改或删除
  • 间隙锁:锁住索引记录之间的间隙,防止其他事务在间隙中插入记录
  • 临键锁(Next-Key锁):记录锁和间隙锁的组合,锁住记录及其前面的间隙

二、多线程环境下的死锁案例分析

(一)事务间资源访问顺序交替导致的死锁

1. 案例描述

这是最常见的死锁类型,当两个或多个事务以不同的顺序访问相同的资源时,就可能发生死锁。

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
29
30
31
32
33
// 线程A的事务
public void methodA() {
try {
connection.setAutoCommit(false); // 开启事务

// 先锁住表A
statement.executeUpdate("UPDATE tableA SET column1 = 'value1' WHERE id = 1");

// 再锁住表B
statement.executeUpdate("UPDATE tableB SET column1 = 'value1' WHERE id = 1");

connection.commit(); // 提交事务
} catch (Exception e) {
connection.rollback(); // 回滚事务
}
}

// 线程B的事务
public void methodB() {
try {
connection.setAutoCommit(false); // 开启事务

// 先锁住表B
statement.executeUpdate("UPDATE tableB SET column1 = 'value2' WHERE id = 1");

// 再锁住表A
statement.executeUpdate("UPDATE tableA SET column1 = 'value2' WHERE id = 1");

connection.commit(); // 提交事务
} catch (Exception e) {
connection.rollback(); // 回滚事务
}
}

在上面的代码中,线程A先锁住表A再锁住表B,而线程B先锁住表B再锁住表A。如果两个线程同时执行,就可能出现线程A锁住了表A等待表B,而线程B锁住了表B等待表A的情况,从而导致死锁。

2. 解决方案

  • 统一资源访问顺序:确保所有事务按照相同的顺序访问资源,例如总是先访问表A再访问表B
  • 减少事务范围:尽量缩小事务的范围,减少事务持有锁的时间
  • 使用事务隔离级别:根据业务需求选择合适的事务隔离级别,如读已提交(Read Committed)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 修改后的线程A和线程B都使用相同的访问顺序
public void methodImproved() {
try {
connection.setAutoCommit(false); // 开启事务

// 统一先锁住表A
statement.executeUpdate("UPDATE tableA SET column1 = 'value' WHERE id = 1");

// 再锁住表B
statement.executeUpdate("UPDATE tableB SET column1 = 'value' WHERE id = 1");

connection.commit(); // 提交事务
} catch (Exception e) {
connection.rollback(); // 回滚事务
}
}

(二)行锁升级导致的死锁

1. 案例描述

当一个事务持有共享锁,然后尝试升级为排他锁时,如果此时另一个事务也在等待获取排他锁,就可能发生死锁。

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
29
30
31
32
33
34
35
36
37
38
39
// 线程A的事务
public void methodA() {
try {
connection.setAutoCommit(false); // 开启事务

// 获取共享锁
ResultSet rs = statement.executeQuery("SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE");

// 一些业务逻辑处理...
Thread.sleep(1000); // 模拟业务处理时间

// 尝试升级为排他锁
statement.executeUpdate("UPDATE users SET name = 'newName' WHERE id = 1");

connection.commit(); // 提交事务
} catch (Exception e) {
connection.rollback(); // 回滚事务
}
}

// 线程B的事务
public void methodB() {
try {
connection.setAutoCommit(false); // 开启事务

// 获取共享锁
ResultSet rs = statement.executeQuery("SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE");

// 一些业务逻辑处理...
Thread.sleep(1000); // 模拟业务处理时间

// 尝试升级为排他锁
statement.executeUpdate("UPDATE users SET name = 'anotherName' WHERE id = 1");

connection.commit(); // 提交事务
} catch (Exception e) {
connection.rollback(); // 回滚事务
}
}

在上面的代码中,线程A和线程B都先获取了共享锁,然后尝试升级为排他锁。由于共享锁之间不互斥,两个线程都能获取共享锁,但当它们尝试升级为排他锁时,由于排他锁与共享锁互斥,两个线程都无法获取排他锁,从而导致死锁。

2. 解决方案

  • 直接使用排他锁:如果事务需要修改数据,直接使用排他锁,而不是先获取共享锁再升级
  • 使用乐观锁:使用版本号或时间戳等机制实现乐观锁,避免使用数据库锁
  • 减少锁持有时间:尽量减少事务持有锁的时间,及时提交或回滚事务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 修改后直接使用排他锁
public void methodImproved() {
try {
connection.setAutoCommit(false); // 开启事务

// 直接使用排他锁
ResultSet rs = statement.executeQuery("SELECT * FROM users WHERE id = 1 FOR UPDATE");

// 一些业务逻辑处理...

// 更新数据
statement.executeUpdate("UPDATE users SET name = 'newName' WHERE id = 1");

connection.commit(); // 提交事务
} catch (Exception e) {
connection.rollback(); // 回滚事务
}
}

(三)插入意向锁导致的死锁

1. 案例描述

在InnoDB中,当执行插入操作时,会先获取插入意向锁。如果两个事务都想在同一个范围内插入数据,就可能发生死锁。

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
29
30
31
32
33
34
35
36
37
// 线程A的事务
public void methodA() {
try {
connection.setAutoCommit(false); // 开启事务

// 查询记录是否存在
ResultSet rs = statement.executeQuery("SELECT * FROM orders WHERE order_no = '1007' FOR UPDATE");

if (!rs.next()) {
// 记录不存在,插入新记录
statement.executeUpdate("INSERT INTO orders (order_no, amount) VALUES ('1007', 100)");
}

connection.commit(); // 提交事务
} catch (Exception e) {
connection.rollback(); // 回滚事务
}
}

// 线程B的事务
public void methodB() {
try {
connection.setAutoCommit(false); // 开启事务

// 查询记录是否存在
ResultSet rs = statement.executeQuery("SELECT * FROM orders WHERE order_no = '1008' FOR UPDATE");

if (!rs.next()) {
// 记录不存在,插入新记录
statement.executeUpdate("INSERT INTO orders (order_no, amount) VALUES ('1008', 200)");
}

connection.commit(); // 提交事务
} catch (Exception e) {
connection.rollback(); // 回滚事务
}
}

在上面的代码中,如果order_no是非唯一索引,当两个线程同时执行时,由于InnoDB的锁机制,可能会导致死锁。这是因为当查询的记录不存在时,InnoDB会在索引上加next-key锁,锁住一个范围,而当两个事务都想在这个范围内插入数据时,就可能发生死锁。

2. 解决方案

  • 使用唯一索引:为order_no字段创建唯一索引,避免范围锁定
  • 使用INSERT ON DUPLICATE KEY UPDATE语句:使用MySQL特有的语法,避免先查询再插入的操作
  • 减少事务范围:尽量减少事务的范围,及时提交或回滚事务
1
2
3
4
5
6
7
8
9
10
11
12
13
// 修改后使用INSERT ON DUPLICATE KEY UPDATE语句
public void methodImproved() {
try {
connection.setAutoCommit(false); // 开启事务

// 使用INSERT ON DUPLICATE KEY UPDATE语句
statement.executeUpdate("INSERT INTO orders (order_no, amount) VALUES ('1007', 100) ON DUPLICATE KEY UPDATE amount = 100");

connection.commit(); // 提交事务
} catch (Exception e) {
connection.rollback(); // 回滚事务
}
}

三、死锁的检测与分析

(一)MySQL死锁日志

MySQL提供了多种方式来查看和分析死锁信息:

1. 使用SHOW ENGINE INNODB STATUS命令

1
SHOW ENGINE INNODB STATUS;

该命令会显示InnoDB的状态信息,包括最近一次的死锁信息。在输出结果中,可以找到LATEST DETECTED DEADLOCK部分,其中包含了死锁的详细信息。

2. 开启InnoDB监控

MySQL提供了一套InnoDB的监控机制,可以周期性地输出InnoDB的运行状态:

1
2
3
4
5
6
7
8
-- 开启标准监控
SET GLOBAL innodb_status_output = ON;

-- 开启锁监控
SET GLOBAL innodb_status_output_locks = ON;

-- 记录所有死锁信息到错误日志
SET GLOBAL innodb_print_all_deadlocks = ON;

3. 使用性能模式(Performance Schema)

MySQL 5.7及以上版本提供了性能模式,可以用来监控数据库的各种性能指标,包括锁信息:

1
2
3
4
5
-- 查看当前的锁信息
SELECT * FROM performance_schema.data_locks;

-- 查看当前的锁等待信息
SELECT * FROM performance_schema.data_lock_waits;

(二)死锁日志分析

下面是一个典型的死锁日志示例及其分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-07-24 10:58:16 0x7f8a9c0fa700
*** (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_db update
INSERT INTO task (order_id, ...) VALUES (...)
*** (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 `db`.`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_db update
INSERT INTO task (order_id, ...) VALUES (...)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `db`.`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 `db`.`task` trx id 182335756 lock_mode X insert intention waiting
*** WE ROLL BACK TRANSACTION (2)

分析:

  1. 有两个事务(1和2)都在尝试向task表插入数据
  2. 事务1正在等待获取X锁(排他锁)的插入意向锁
  3. 事务2持有X锁,同时也在等待获取插入意向锁
  4. 由于两个事务互相等待对方释放锁,形成了死锁
  5. MySQL检测到死锁后,选择回滚事务2

这种情况通常是由于两个事务在同一个索引范围内进行插入操作,而且使用了类似SELECT … FOR UPDATE然后INSERT的模式,导致了死锁。

四、死锁的预防与解决策略

(一)应用层面的预防策略

1. 合理设计数据库结构

  • 使用合适的索引:为经常查询的字段创建索引,避免全表扫描
  • 避免过度范式化:适当的反范式化可以减少表之间的关联,降低死锁风险
  • 分表分库:将大表拆分为小表,减少锁的竞争

2. 优化事务设计

  • 减少事务范围:事务越小,持有锁的时间越短,死锁的可能性越低
  • 避免长事务:长事务会长时间持有锁,增加死锁的风险
  • 合理设置事务隔离级别:根据业务需求选择合适的事务隔离级别

3. 统一资源访问顺序

  • 按照固定的顺序访问表:例如,总是按照表名的字母顺序访问表
  • 按照固定的顺序访问行:例如,总是按照主键的升序访问行

(二)数据库层面的解决策略

1. 锁超时设置

设置锁等待超时时间,当事务等待锁的时间超过设定值时,自动回滚事务:

1
SET innodb_lock_wait_timeout = 50; -- 默认值为50秒

2. 死锁检测

InnoDB默认启用了死锁检测,当检测到死锁时,会自动回滚一个事务:

1
SET innodb_deadlock_detect = ON; -- 默认值为ON

3. 使用乐观锁

乐观锁是一种不使用数据库锁机制的并发控制方法,它假设多用户并发的事务在处理时不会彼此互相影响,所以不会产生锁。乐观锁通常使用版本号或时间戳来实现:

1
2
3
4
5
6
7
8
9
-- 创建表时添加版本号字段
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
version INT
);

-- 更新数据时检查版本号
UPDATE users SET name = 'newName', version = version + 1 WHERE id = 1 AND version = 1;

在Java代码中使用乐观锁:

1
2
3
4
5
6
7
8
9
10
11
12
public boolean updateUser(User user) {
try {
int result = jdbcTemplate.update(
"UPDATE users SET name = ?, version = version + 1 WHERE id = ? AND version = ?",
user.getName(), user.getId(), user.getVersion()
);

return result > 0; // 如果更新成功,返回true
} catch (Exception e) {
return false; // 更新失败,返回false
}
}

(三)实际案例解决方案

1. 订单系统的幂等性校验死锁

问题描述:在订单系统中,为了保证订单号的唯一性,在插入订单前先使用SELECT … FOR UPDATE查询订单是否存在,如果不存在则插入。当并发量大时,容易发生死锁。

解决方案

  • 使用唯一索引约束订单号的唯一性
  • 使用INSERT IGNORE或INSERT ON DUPLICATE KEY UPDATE语句
  • 使用乐观锁机制进行控制
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
// 修改前
public void createOrder(Order order) {
try {
connection.setAutoCommit(false);

// 查询订单是否存在
ResultSet rs = statement.executeQuery("SELECT * FROM orders WHERE order_no = '" + order.getOrderNo() + "' FOR UPDATE");

if (!rs.next()) {
// 订单不存在,插入新订单
statement.executeUpdate("INSERT INTO orders (order_no, amount) VALUES ('" + order.getOrderNo() + "', " + order.getAmount() + ")");
}

connection.commit();
} catch (Exception e) {
connection.rollback();
}
}

// 修改后
public void createOrder(Order order) {
try {
// 使用INSERT IGNORE语句
statement.executeUpdate("INSERT IGNORE INTO orders (order_no, amount) VALUES ('" + order.getOrderNo() + "', " + order.getAmount() + ")");
} catch (Exception e) {
// 处理异常
}
}

2. 库存系统的扣减死锁

问题描述:在库存系统中,多个线程同时扣减同一个商品的库存,容易发生死锁。

解决方案

  • 使用乐观锁机制进行控制
  • 使用分布式锁(如Redis锁)控制并发
  • 使用队列将并发请求串行化处理
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 修改前
public boolean reduceStock(long productId, int quantity) {
try {
connection.setAutoCommit(false);

// 查询库存
ResultSet rs = statement.executeQuery("SELECT stock FROM products WHERE id = " + productId + " FOR UPDATE");

if (rs.next()) {
int stock = rs.getInt("stock");
if (stock >= quantity) {
// 库存足够,扣减库存
statement.executeUpdate("UPDATE products SET stock = stock - " + quantity + " WHERE id = " + productId);
connection.commit();
return true;
}
}

connection.rollback();
return false;
} catch (Exception e) {
connection.rollback();
return false;
}
}

// 修改后(使用乐观锁)
public boolean reduceStock(long productId, int quantity) {
try {
// 使用乐观锁机制
int result = jdbcTemplate.update(
"UPDATE products SET stock = stock - ?, version = version + 1 WHERE id = ? AND stock >= ? AND version = ?",
quantity, productId, quantity, getProductVersion(productId)
);

return result > 0; // 如果更新成功,返回true
} catch (Exception e) {
return false; // 更新失败,返回false
}
}

private int getProductVersion(long productId) {
return jdbcTemplate.queryForObject("SELECT version FROM products WHERE id = ?", Integer.class, productId);
}

五、总结与最佳实践

(一)死锁预防的最佳实践

  1. 设计合理的数据库结构:使用合适的索引,避免过度范式化
  2. 优化事务设计:减少事务范围,避免长事务,合理设置事务隔离级别
  3. 统一资源访问顺序:按照固定的顺序访问表和行
  4. 使用乐观锁:在适当的场景下使用乐观锁代替悲观锁
  5. 避免复杂的关联查询:复杂的关联查询容易导致锁的竞争
  6. 定期分析和优化:定期分析死锁日志,优化数据库和应用程序

(二)多线程环境下的数据库操作建议

  1. 使用连接池:使用连接池管理数据库连接,避免频繁创建和关闭连接
  2. 使用事务管理器:使用Spring等框架提供的事务管理器,简化事务管理
  3. 使用ORM框架:使用Hibernate、MyBatis等ORM框架,减少直接操作JDBC的复杂性
  4. 使用分布式锁:在分布式环境下,使用Redis、ZooKeeper等实现分布式锁
  5. 使用队列:将并发请求通过队列串行化处理,避免并发冲突

(三)死锁监控与告警

  1. 设置死锁监控:开启InnoDB的死锁监控,记录死锁日志
  2. 设置告警机制:当发生死锁时,及时发送告警通知
  3. 定期分析死锁日志:定期分析死锁日志,找出死锁的原因并优化

通过合理的设计和优化,我们可以有效地减少多线程环境下数据库死锁的发生,提高系统的并发性能和稳定性。

参考资料

  1. MySQL官方文档:InnoDB Locking
  2. 《高性能MySQL》(第4版)
  3. 《MySQL技术内幕:InnoDB存储引擎》(第2版)
  4. 解决死锁之路(终结篇)- 再见死锁
  5. MySQL 死锁了,怎么办?