一、死锁概述 (一)什么是数据库死锁 在多线程并发环境下,当多个事务同时操作数据库时,如果事务之间相互等待对方释放资源,而又不主动释放自己持有的资源,就会导致所有事务都无法继续执行,这种现象称为数据库死锁 。
死锁是数据库并发控制中的一个经典问题,尤其在高并发系统中更为常见。当死锁发生时,数据库系统通常会自动检测并解决死锁(通常是回滚其中一个事务),但这会导致应用程序出现异常,影响用户体验和系统性能。
(二)死锁产生的必要条件 死锁的发生必须同时满足以下四个条件:
互斥条件 :资源是排他性的,一次只能被一个事务使用
请求与保持条件 :事务已经持有了某些资源,同时又请求新的资源
不剥夺条件 :事务获得的资源在未使用完之前,不能被强行剥夺
循环等待条件 :存在一个事务资源的循环等待链,形成环路
只有同时满足这四个条件,死锁才会发生。因此,解决死锁问题的关键是破坏这四个条件中的至少一个。
(三)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 public void methodA () { try { connection.setAutoCommit(false ); statement.executeUpdate("UPDATE tableA SET column1 = 'value1' WHERE id = 1" ); statement.executeUpdate("UPDATE tableB SET column1 = 'value1' WHERE id = 1" ); connection.commit(); } catch (Exception e) { connection.rollback(); } } public void methodB () { try { connection.setAutoCommit(false ); statement.executeUpdate("UPDATE tableB SET column1 = 'value2' WHERE id = 1" ); 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 public void methodImproved () { try { connection.setAutoCommit(false ); statement.executeUpdate("UPDATE tableA SET column1 = 'value' WHERE id = 1" ); 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 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(); } } 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 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(); } } 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 public void methodImproved () { try { connection.setAutoCommit(false ); 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 ;
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和2)都在尝试向task表插入数据
事务1正在等待获取X锁(排他锁)的插入意向锁
事务2持有X锁,同时也在等待获取插入意向锁
由于两个事务互相等待对方释放锁,形成了死锁
MySQL检测到死锁后,选择回滚事务2
这种情况通常是由于两个事务在同一个索引范围内进行插入操作,而且使用了类似SELECT … FOR UPDATE然后INSERT的模式,导致了死锁。
四、死锁的预防与解决策略 (一)应用层面的预防策略 1. 合理设计数据库结构
使用合适的索引 :为经常查询的字段创建索引,避免全表扫描
避免过度范式化 :适当的反范式化可以减少表之间的关联,降低死锁风险
分表分库 :将大表拆分为小表,减少锁的竞争
2. 优化事务设计
减少事务范围 :事务越小,持有锁的时间越短,死锁的可能性越低
避免长事务 :长事务会长时间持有锁,增加死锁的风险
合理设置事务隔离级别 :根据业务需求选择合适的事务隔离级别
3. 统一资源访问顺序
按照固定的顺序访问表 :例如,总是按照表名的字母顺序访问表
按照固定的顺序访问行 :例如,总是按照主键的升序访问行
(二)数据库层面的解决策略 1. 锁超时设置 设置锁等待超时时间,当事务等待锁的时间超过设定值时,自动回滚事务:
1 SET innodb_lock_wait_timeout = 50 ;
2. 死锁检测 InnoDB默认启用了死锁检测,当检测到死锁时,会自动回滚一个事务:
1 SET innodb_deadlock_detect = 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 ; } catch (Exception e) { return 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 { 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 ; } catch (Exception e) { return false ; } } private int getProductVersion (long productId) { return jdbcTemplate.queryForObject("SELECT version FROM products WHERE id = ?" , Integer.class, productId); }
五、总结与最佳实践 (一)死锁预防的最佳实践
设计合理的数据库结构 :使用合适的索引,避免过度范式化
优化事务设计 :减少事务范围,避免长事务,合理设置事务隔离级别
统一资源访问顺序 :按照固定的顺序访问表和行
使用乐观锁 :在适当的场景下使用乐观锁代替悲观锁
避免复杂的关联查询 :复杂的关联查询容易导致锁的竞争
定期分析和优化 :定期分析死锁日志,优化数据库和应用程序
(二)多线程环境下的数据库操作建议
使用连接池 :使用连接池管理数据库连接,避免频繁创建和关闭连接
使用事务管理器 :使用Spring等框架提供的事务管理器,简化事务管理
使用ORM框架 :使用Hibernate、MyBatis等ORM框架,减少直接操作JDBC的复杂性
使用分布式锁 :在分布式环境下,使用Redis、ZooKeeper等实现分布式锁
使用队列 :将并发请求通过队列串行化处理,避免并发冲突
(三)死锁监控与告警
设置死锁监控 :开启InnoDB的死锁监控,记录死锁日志
设置告警机制 :当发生死锁时,及时发送告警通知
定期分析死锁日志 :定期分析死锁日志,找出死锁的原因并优化
通过合理的设计和优化,我们可以有效地减少多线程环境下数据库死锁的发生,提高系统的并发性能和稳定性。
参考资料
MySQL官方文档:InnoDB Locking
《高性能MySQL》(第4版)
《MySQL技术内幕:InnoDB存储引擎》(第2版)
解决死锁之路(终结篇)- 再见死锁
MySQL 死锁了,怎么办?