一、MySQL死锁的概念与定义
1. 死锁的基本定义
MySQL中的死锁是指两个或多个事务在同一资源上相互等待对方释放锁,导致这些事务都无法继续执行的情况。从本质上讲,死锁是多个事务形成了一个等待环路,每个事务都在等待另一个事务所持有的锁资源,而这些事务又都不主动释放自己持有的锁,最终导致所有事务都无法向前推进。
2. 死锁的产生条件
MySQL中死锁的产生需要同时满足以下三个条件:
- 互斥条件:同一时刻只能有一个事务持有某个特定的锁。
- 不可剥夺条件:事务获得的锁在其主动释放之前,不能被其他事务强行剥夺。
- 循环等待条件:当多个事务形成等待环路时,即发生死锁。例如,事务A等待事务B释放锁,而事务B又在等待事务A释放锁。
3. 死锁检测机制
InnoDB存储引擎默认启用了死锁检测机制。当发生死锁时,InnoDB会自动检测到这种情况,并选择回滚其中一个事务来打破死锁。InnoDB通常会选择回滚较小的事务(根据插入、更新或删除的行数来判断事务大小)。
根据MySQL官方文档,InnoDB使用等待图算法(wait-for graph)
来检测死锁。当等待中的事务数量达到200的限制时,超过200个事务的等待列表被视为死锁,并且尝试检查等待列表的事务会被回滚。同样,如果锁定线程必须查看等待列表上的事务所拥有的超过1,000,000个锁,也会发生同样的错误。
4. 死锁与锁等待超时的区别
死锁与锁等待超时是两个不同的概念:
-
死锁:是一种永远无法解决的互相等待状态,除非系统干预(如InnoDB的死锁检测机制自动回滚一个事务)。当检测到死锁时,MySQL会立即回滚其中一个事务,并返回错误信息:
Deadlock found when trying to get lock; try restarting transaction
。 -
锁等待超时:是指事务在等待获取锁时超过了系统设定的最大等待时间(由
innodb_lock_wait_timeout
参数控制,默认为50秒)。当发生锁等待超时时,MySQL会回滚当前事务,并返回错误信息:Lock wait timeout exceeded; try restarting transaction
。
5. 死锁检测的配置
在高并发系统上,当大量线程等待相同的锁时,死锁检测可能会导致系统性能下降。在这种情况下,有时禁用死锁检测并依赖innodb_lock_wait_timeout
设置进行事务回滚可能更有效。可以使用innodb_deadlock_detect
配置选项来禁用死锁检测。
6. 死锁与表锁、行锁的关系
值得注意的是,表锁不会发生死锁,因为表锁的资源都是一次性获取的。死锁主要发生在行锁级别,特别是当多个事务以不同的顺序访问多行数据时。
如果innodb_table_locks = 1
(默认值)且autocommit = 0
,InnoDB能够感知表锁,MySQL层也能感知行级锁。否则,InnoDB无法检测由MySQL LOCK TABLES语句设置的表锁或由InnoDB以外的存储引擎设置的锁所导致的死锁。
二、MySQL死锁的原因和常见场景
1. 死锁的基本原因
MySQL中的死锁产生主要是由于以下几个基本原因:
- 加锁顺序不一致:不同事务以不同的顺序获取锁,形成循环等待。
- 资源竞争:多个事务同时争夺相同的资源。
- 锁粒度和锁类型不合理:使用了过大的锁粒度或不适当的锁类型。
- 事务隔离级别:在RR(可重复读)隔离级别下,间隙锁的使用增加了死锁的可能性。
- 长事务:事务执行时间过长,增加了与其他事务发生锁冲突的概率。
2. 死锁产生的必要条件
死锁的产生需要同时满足以下四个条件:
- 互斥条件:资源不能被多个事务同时使用。
- 占有且等待条件:事务持有至少一个资源,并等待获取其他资源。
- 不可强占用条件:事务获得的资源只能由自己释放,不能被其他事务强行剥夺。
- 循环等待条件:多个事务形成头尾相接的循环等待资源关系。
3. 常见死锁场景
3.1 多个事务加锁顺序不一致
这是最常见的死锁场景。当两个或多个事务以不同的顺序锁定相同的行时,容易发生死锁。
示例:
- 事务A先锁定记录1,再尝试锁定记录2
- 事务B先锁定记录2,再尝试锁定记录1
- 结果:事务A等待事务B释放记录2的锁,事务B等待事务A释放记录1的锁,形成死锁
-- 事务A
BEGIN;
SELECT * FROM table WHERE id = 1 FOR UPDATE;
-- 此时获得了id=1的锁
SELECT * FROM table WHERE id = 2 FOR UPDATE;
-- 等待获取id=2的锁,因为事务B已经持有
-- 事务B
BEGIN;
SELECT * FROM table WHERE id = 2 FOR UPDATE;
-- 此时获得了id=2的锁
SELECT * FROM table WHERE id = 1 FOR UPDATE;
-- 等待获取id=1的锁,因为事务A已经持有
3.2 间隙锁与插入意向锁的冲突
在RR隔离级别下,InnoDB使用间隙锁来防止幻读。间隙锁之间不会互相阻塞,但插入意向锁会与间隙锁冲突。
示例:
- 事务A和B先后在(20, 30)的区间上加了间隙锁
- 事务A尝试在该区间插入数据,需要获取插入意向锁
- 事务B的间隙锁阻止了事务A获取插入意向锁
- 同时,事务B也尝试插入数据,被事务A的间隙锁阻止
- 结果:形成死锁
3.3 范围查询中的行锁加锁顺序问题
在范围查询时,MySQL会一行一行地加锁,如果不同事务的加锁顺序不同,也会导致死锁。
示例:
- 事务A的查询条件为
id < 30
,加锁顺序为:id = 15 -> 18 -> 20 - 事务B走的是二级索引age,加锁顺序为:id = 18 -> 20 -> 15 -> 49
- 结果:事务A先锁15,再锁18,而事务B先锁18,再锁15,形成死锁
3.4 主键索引和唯一二级索引的特殊加锁机制
在插入操作时,主键索引和唯一二级索引会先生成S锁来判断是否唯一,然后再升级为X锁。这种特殊的加锁机制也可能导致死锁。
示例:
- 事务A插入一条记录,先获取S锁检查唯一性
- 事务B持有相关记录的X锁
- 事务A的S锁与事务B的X锁冲突,事务A等待
- 同时,事务B也需要获取事务A持有的某些锁
- 结果:形成死锁
3.5 不存在记录的锁定与插入操作冲突
当对不存在的记录进行锁定时,MySQL会锁定一个范围(间隙锁),这可能与其他事务的插入操作冲突。
示例:
-- 事务A
SELECT * FROM table WHERE id = 22 FOR UPDATE;
-- 表中不存在id=22的记录,会加间隙锁
-- 事务B
SELECT * FROM table WHERE id = 23 FOR UPDATE;
-- 表中不存在id=23的记录,也会加间隙锁
-- 事务A尝试插入id=22的记录
INSERT INTO table VALUES(22, 'value');
-- 等待事务B释放间隙锁
-- 事务B尝试插入id=23的记录
INSERT INTO table VALUES(23, 'value');
-- 等待事务A释放间隙锁,形成死锁
3.6 批量操作中的死锁
在批量更新或删除操作中,如果不同事务的操作涉及相同的记录集但顺序不同,也容易发生死锁。
示例:
- 事务A批量更新id为1,2,3的记录
- 事务B批量更新id为3,2,1的记录
- 结果:可能形成死锁
3.7 复杂SQL语句中的隐式锁定
有些复杂的SQL语句(如JOIN、子查询等)可能会隐式地锁定多个表或多行数据,增加了死锁的风险。
3.8 长事务与短事务混合执行
当长事务与多个短事务并发执行时,长事务持有锁的时间较长,增加了与短事务发生锁冲突的概率,容易导致死锁。
4. 死锁与数据库隔离级别的关系
死锁的发生与数据库的隔离级别密切相关:
- 读未提交(Read Uncommitted):最低的隔离级别,不使用共享锁,因此死锁风险较低,但数据一致性问题较多。
- 读已提交(Read Committed):不会出现幻读,不使用间隙锁,死锁风险低于可重复读。
- 可重复读(Repeatable Read):MySQL的默认隔离级别,使用间隙锁防止幻读,死锁风险较高。
- 串行化(Serializable):最高的隔离级别,事务串行执行,死锁风险最低,但并发性能最差。
在实际应用中,可重复读(RR)隔离级别下的死锁问题最为常见,因为InnoDB在此级别下使用了间隙锁来防止幻读,而间隙锁增加了锁的范围,提高了死锁的可能性。
三、MySQL死锁实际案例分析
1. 案例一:交叉更新导致的死锁
1.1 业务背景
在仓储管理系统中,多个事务同时更新库存占用表(stock_occupy)中的不同记录,由于加锁顺序不一致导致死锁。
1.2 SQL示例
-- 事务1
UPDATE stock_occupy
SET update_time = NOW()
,update_user = 'WAPS'
,qty_out_occupy=qty_out_occupy + 12.0000
WHERE map_area_id = 608
AND goods_no='EMG4418433215231'
AND owner_no='0'
AND lot_no='-1'
AND product_level='100'
AND org_no = '10'
AND distribute_no = '10'
AND warehouse_no = '126'
AND map_area_id = 608;
-- 事务2
UPDATE stock_occupy
SET update_time = NOW()
,update_user = 'WAPS'
,qty_out_occupy=qty_out_occupy + 11.0000
WHERE map_area_id = 608
AND goods_no='EMG4418442253742'
AND owner_no='0'
AND lot_no='-1'
AND product_level='100'
AND org_no = '10'
AND distribute_no = '10'
AND warehouse_no = '126'
AND map_area_id = 608;
1.3 死锁日志片段
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-04-14 08:07:05 0x7fb6d39a6700
*** (1) TRANSACTION:
TRANSACTION 13020605130, ACTIVE 25 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 33 lock struct(s), heap size 3520, 33 row lock(s), undo log entries 34
MySQL thread id 2343498932, OS thread handle 140424015394560, query id 28769967039 x.x.x.x xwms_rw updating
UPDATE stock_occupy
SET update_time = NOW()
,update_user = 'WAPS'
,qty_out_occupy=qty_out_occupy + 12.0000
WHERE map_area_id = 608
AND goods_no='EMG4418433215231'
...
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 127 page no 5255 n bits 272 index idx_map_goods_product_lot_owner of table `xwms`.`stock_occupy` trx id 13020605130 lock_mode X locks rec but not gap waiting
Record lock, heap no 53 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
...
*** (2) TRANSACTION:
TRANSACTION 13020606128, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
10 lock struct(s), heap size 1136, 7 row lock(s), undo log entries 8
MySQL thread id 2343006037, OS thread handle 140423210886912, query id 28769967052 x.x.x.x xwms_rw updating
UPDATE stock_occupy
SET update_time = NOW()
,update_user = 'WAPS'
,qty_out_occupy=qty_out_occupy + 11.0000
WHERE map_area_id = 608
AND goods_no='EMG4418442253742'
...
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 127 page no 5255 n bits 272 index idx_map_goods_product_lot_owner of table `xwms`.`stock_occupy` trx id 13020606128 lock_mode X locks rec but not gap
...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 127 page no 5276 n bits 240 index idx_map_goods_product_lot_owner of table `xwms`.`stock_occupy` trx id 13020606128 lock_mode X locks rec but not gap waiting
...
*** WE ROLL BACK TRANSACTION (2)
1.4 死锁分析
- 两个事务都在使用二级联合唯一索引
idx_map_goods_product_lot_owner
进行更新操作 - 事务1先锁定了主键为273892的记录,然后尝试锁定主键为279349的记录
- 事务2先锁定了主键为279349的记录,然后尝试锁定主键为273892的记录
- 形成了循环等待,导致死锁
- InnoDB检测到死锁后回滚了事务2
1.5 表结构
CREATE TABLE `stock_occupy` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增id',
`map_area_id` bigint(20) NOT NULL COMMENT '地图区域ID',
`goods_no` varchar(50) NOT NULL COMMENT '商品编号',
`product_level` varchar(50) NOT NULL COMMENT '商品等级',
`lot_no` varchar(50) NOT NULL COMMENT '批次号',
`owner_no` varchar(50) NOT NULL COMMENT '货主编号',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_map_goods_product_lot_owner` (`map_area_id`,`goods_no`,`product_level`,`lot_no`,`owner_no`)
)
2. 案例二:订单表与库存表死锁
2.1 业务背景
在电商系统中,订单处理和库存管理是两个紧密关联的业务。当多个事务同时处理订单和更新库存时,如果加锁顺序不一致,容易导致死锁。
2.2 SQL示例
-- 事务A
BEGIN;
UPDATE orders SET status = 'PROCESSING' WHERE order_id = 12345;
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 101;
COMMIT;
-- 事务B
BEGIN;
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 101;
UPDATE orders SET status = 'PROCESSING' WHERE order_id = 12345;
COMMIT;
2.3 死锁分析
- 事务A先锁定了订单表中的记录,然后尝试锁定库存表中的记录
- 事务B先锁定了库存表中的记录,然后尝试锁定订单表中的记录
- 形成了循环等待,导致死锁
- 解决方案是统一操作顺序,例如所有事务都先更新订单表,再更新库存表
3. 案例三:范围查询与插入操作导致的死锁
3.1 业务背景
在高并发系统中,当一个事务进行范围查询并锁定记录,同时另一个事务尝试在该范围内插入新记录时,可能会导致死锁。
3.2 SQL示例
-- 事务1
BEGIN;
SELECT * FROM products WHERE price BETWEEN 10 AND 20 FOR UPDATE;
-- 其他操作...
COMMIT;
-- 事务2
BEGIN;
INSERT INTO products (name, price) VALUES ('New Product', 15);
COMMIT;
3.3 死锁日志片段
*** (1) TRANSACTION:
TRANSACTION 281479535, ACTIVE 4 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 20, OS thread handle 139783059205888, query id 1205 localhost root updating
INSERT INTO products (name, price) VALUES ('New Product', 15)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 31 page no 3 n bits 72 index `PRIMARY` of table `test`.`products` trx id 281479535 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
...
*** (2) TRANSACTION:
TRANSACTION 281479534, ACTIVE 16 sec fetching rows
mysql tables in use 1, locked 1
5 lock struct(s), heap size 1136, 4 row lock(s)
MySQL thread id 19, OS thread handle 139783058939648, query id 1204 localhost root executing
SELECT * FROM products WHERE price BETWEEN 10 AND 20 FOR UPDATE
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 31 page no 3 n bits 72 index `PRIMARY` of table `test`.`products` trx id 281479534 lock_mode X
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
...
*** WE ROLL BACK TRANSACTION (1)
3.4 死锁分析
- 事务2执行范围查询并获取了X锁,锁定了价格在10到20之间的所有记录以及相应的间隙
- 事务1尝试在该范围内插入新记录,需要获取插入意向锁
- 插入意向锁与间隙锁冲突,导致事务1等待
- 如果事务2还需要获取事务1持有的某些锁,就会形成循环等待,导致死锁
- 解决方案包括:降低隔离级别(从RR降到RC可以避免间隙锁)、缩小锁范围、调整事务逻辑等
4. 案例四:唯一键冲突导致的死锁
4.1 业务背景
当多个事务同时尝试插入具有唯一键约束的记录时,可能会导致死锁。
4.2 SQL示例
-- 事务1
BEGIN;
INSERT INTO users (id, username, email) VALUES (NULL, 'user1', 'user1@example.com');
-- 其他操作...
COMMIT;
-- 事务2
BEGIN;
INSERT INTO users (id, username, email) VALUES (NULL, 'user2', 'user1@example.com');
-- 注意:email与事务1相同,违反唯一约束
COMMIT;
4.3 死锁分析
- 事务1插入记录时,会先获取S锁检查唯一性,然后升级为X锁
- 事务2也尝试插入具有相同email的记录,也需要获取S锁检查唯一性
- 如果事务1已经获取了S锁但还未升级为X锁,事务2也可以获取S锁
- 当事务1尝试升级为X锁时,需要等待事务2释放S锁
- 当事务2尝试升级为X锁时,需要等待事务1释放S锁
- 形成了循环等待,导致死锁
5. 案例五:批量操作中的死锁
5.1 业务背景
在批量处理数据时,如果不同事务以不同的顺序访问相同的记录集,容易导致死锁。
5.2 SQL示例
-- 事务1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id IN (1, 2, 3, 4, 5);
COMMIT;
-- 事务2
BEGIN;
UPDATE accounts SET balance = balance + 200 WHERE id IN (5, 4, 3, 2, 1);
COMMIT;
5.3 死锁分析
- 事务1按照id升序加锁:1, 2, 3, 4, 5
- 事务2按照id降序加锁:5, 4, 3, 2, 1
- 如果两个事务同时执行,事务1可能锁定了id=1,2,3,事务2可能锁定了id=5,4
- 当事务1尝试锁定id=4,5时,需要等待事务2释放锁
- 当事务2尝试锁定id=3,2,1时,需要等待事务1释放锁
- 形成了循环等待,导致死锁
- 解决方案是统一加锁顺序,例如都按照id升序加锁
6. 死锁日志分析方法
6.1 死锁日志格式
简化后的死锁日志格式如下:
InnoDB: *** (1) TRANSACTION:
InnoDB: *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
InnoDB: *** (2) TRANSACTION:
InnoDB: *** (2) HOLDS THE LOCK(S):
InnoDB: *** (2) WAITING FOR THIS LOCK TO BE GRANTED:
InnoDB: *** WE ROLL BACK TRANSACTION (1)
6.2 分析步骤
- 识别事务信息:查看每个事务正在执行的SQL语句
- 分析锁信息:
- 锁类型(X锁、S锁、意向锁等)
- 锁范围(记录锁、间隙锁、Next-Key锁等)
- 锁定的索引和记录
- 分析等待关系:确定哪个事务持有锁,哪个事务等待锁
- 确定死锁原因:通常是由于加锁顺序不一致导致的循环等待
- 制定解决方案:调整加锁顺序、优化事务逻辑、调整隔离级别等
6.3 常见死锁排查工具
SHOW ENGINE INNODB STATUS
:查看最近一次的死锁信息- 查询information_schema中的锁相关表:
SELECT * FROM information_schema.INNODB_TRX; SELECT * FROM information_schema.INNODB_LOCKS; SELECT * FROM information_schema.INNODB_LOCK_WAITS;
- 开启死锁日志记录:
SET GLOBAL innodb_status_output = ON; SET GLOBAL innodb_status_output_locks = ON;
四、MySQL死锁解决方案与最佳实践
1. 死锁预防策略
1.1 保持一致的加锁顺序
死锁的主要原因之一是不同事务以不同的顺序获取锁,形成循环等待。因此,最有效的预防策略是确保所有事务按照相同的顺序获取锁。
最佳实践:
- 对多表操作时,始终按照固定的顺序访问表(如按表名字母顺序)
- 对同一表的多行操作,按照主键或索引的顺序进行访问(如ID升序)
- 使用
SELECT ... FOR UPDATE
时,可以添加ORDER BY
子句确保加锁顺序一致
示例:
-- 推荐:统一按ID升序加锁
UPDATE accounts SET balance = balance - 100 WHERE id IN (1, 2, 3, 4, 5) ORDER BY id;
-- 不推荐:不同事务使用不同的加锁顺序
-- 事务1: UPDATE accounts SET balance = balance - 100 WHERE id IN (1, 2, 3, 4, 5);
-- 事务2: UPDATE accounts SET balance = balance + 200 WHERE id IN (5, 4, 3, 2, 1);
1.2 减小锁粒度
锁粒度越大,发生锁冲突的可能性就越高。使用行级锁而非表级锁,可以显著减少死锁的可能性。
最佳实践:
- 优先使用InnoDB等支持行级锁的存储引擎
- 尽量使用索引条件进行数据检索,避免全表扫描导致的表锁
- 避免锁定过多的行,只锁定必要的数据
示例:
-- 推荐:使用索引列作为条件,只锁定必要的行
SELECT * FROM orders WHERE order_id = 12345 FOR UPDATE;
-- 不推荐:无索引条件导致全表扫描和表锁
SELECT * FROM orders WHERE customer_name = 'John' FOR UPDATE;
1.3 减少事务持续时间
长事务会长时间持有锁,增加与其他事务发生锁冲突的概率。
最佳实践:
- 将大事务拆分为多个小事务
- 只在必要的时候开启事务,尽早提交
- 避免在事务中进行耗时的操作,如复杂计算、网络I/O等
- 避免用户交互式操作在事务内进行
示例:
-- 推荐:小事务,及时提交
BEGIN;
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 101;
COMMIT;
BEGIN;
INSERT INTO order_items (order_id, product_id, quantity) VALUES (12345, 101, 1);
COMMIT;
-- 不推荐:大事务,长时间持有锁
BEGIN;
-- 复杂的业务逻辑和多表操作
-- ...可能持续很长时间的操作
COMMIT;
1.4 合理设置隔离级别
MySQL默认的隔离级别是REPEATABLE READ(可重复读),在此级别下会使用间隙锁来防止幻读,但间隙锁也增加了死锁的可能性。
最佳实践:
- 根据业务需求选择合适的隔离级别
- 如果业务允许,考虑使用READ COMMITTED隔离级别,可以避免间隙锁
- 在特定场景下,可以使用
SELECT ... FOR UPDATE NOWAIT
或SELECT ... FOR UPDATE SKIP LOCKED
来避免长时间的锁等待
示例:
-- 设置会话隔离级别为READ COMMITTED,避免间隙锁
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 使用NOWAIT选项,避免长时间等待锁(MySQL 8.0+)
SELECT * FROM products WHERE id = 10 FOR UPDATE NOWAIT;
2. 死锁检测与处理
2.1 开启死锁检测
MySQL默认开启了死锁检测,但在高并发场景下,死锁检测可能会消耗大量资源。
最佳实践:
- 保持
innodb_deadlock_detect
参数开启(默认值为ON) - 在极高并发且有完善死锁预防措施的场景下,可以考虑关闭死锁检测,依赖
innodb_lock_wait_timeout
进行超时回滚 - 合理设置
innodb_lock_wait_timeout
参数(默认50秒)
示例:
-- 查看死锁检测是否开启
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- 设置锁等待超时时间(单位:秒)
SET GLOBAL innodb_lock_wait_timeout = 20;
2.2 死锁日志分析
当发生死锁时,MySQL会自动检测并回滚其中一个事务。通过分析死锁日志,可以找出死锁的原因并进行优化。
最佳实践:
- 使用
SHOW ENGINE INNODB STATUS
命令查看最近一次的死锁信息 - 开启死锁日志记录,便于后续分析
- 分析死锁日志中的事务信息、锁信息和等待关系
示例:
-- 查看最近一次的死锁信息
SHOW ENGINE INNODB STATUS;
-- 开启死锁日志记录
SET GLOBAL innodb_status_output = ON;
SET GLOBAL innodb_status_output_locks = ON;
-- 查询当前的锁等待情况
SELECT * FROM information_schema.INNODB_TRX;
SELECT * FROM information_schema.INNODB_LOCKS;
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
2.3 应用层重试机制
即使采取了各种预防措施,死锁仍然可能发生。在应用层实现重试机制可以提高系统的健壮性。
最佳实践:
- 捕获死锁异常(MySQL错误码:1213)
- 实现指数退避重试策略,避免立即重试导致的连续死锁
- 设置最大重试次数,避免无限重试
示例(Java代码):
int maxRetries = 3;
int retryCount = 0;
boolean success = false;
while (!success && retryCount < maxRetries) {
try {
// 执行数据库操作
connection.setAutoCommit(false);
// ... SQL操作
connection.commit();
success = true;
} catch (SQLException e) {
if (e.getErrorCode() == 1213) { // MySQL死锁错误码
// 回滚事务
connection.rollback();
// 指数退避
long waitTime = (long) Math.pow(2, retryCount) * 100;
Thread.sleep(waitTime);
retryCount++;
} else {
throw e; // 其他错误直接抛出
}
} finally {
connection.setAutoCommit(true);
}
}
if (!success) {
// 达到最大重试次数仍失败,进行错误处理
}
3. 特定场景的死锁解决方案
3.1 唯一键冲突导致的死锁
当多个事务同时尝试插入具有相同唯一键值的记录时,可能会导致死锁。
解决方案:
- 使用
INSERT ... ON DUPLICATE KEY UPDATE
语句代替先查询后插入的模式 - 使用
REPLACE INTO
语句(注意:会删除并重新插入记录) - 在应用层进行唯一性检查,避免并发插入相同的唯一键值
示例:
-- 推荐:使用INSERT ... ON DUPLICATE KEY UPDATE
INSERT INTO users (username, email)
VALUES ('user1', 'user1@example.com')
ON DUPLICATE KEY UPDATE username = VALUES(username);
-- 或者使用REPLACE INTO
REPLACE INTO users (username, email)
VALUES ('user1', 'user1@example.com');
3.2 范围查询与插入操作的死锁
在RR隔离级别下,范围查询会使用间隙锁,可能与插入操作发生冲突导致死锁。
解决方案:
- 降低隔离级别至READ COMMITTED,避免间隙锁
- 优化查询条件,减少锁定的范围
- 调整业务逻辑,避免在同一事务中既有范围查询又有插入操作
示例:
-- 设置隔离级别为READ COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 优化查询条件,使用精确匹配而非范围查询
SELECT * FROM products WHERE id = 10 FOR UPDATE;
-- 而非:SELECT * FROM products WHERE price BETWEEN 10 AND 20 FOR UPDATE;
3.3 批量操作中的死锁
批量更新或删除操作可能会导致大量的锁竞争,增加死锁的风险。
解决方案:
- 将批量操作拆分为多个小批次,减少单次操作的锁定范围
- 在批量操作中添加排序条件,确保加锁顺序一致
- 考虑使用临时表进行批量操作,减少对原表的锁定时间
示例:
-- 推荐:分批处理,每批100条记录
SET @batch_size = 100;
SET @offset = 0;
REPEAT
UPDATE accounts
SET status = 'inactive'
WHERE last_login_date < '2023-01-01'
ORDER BY id
LIMIT @batch_size;
SET @offset = @offset + ROW_COUNT();
-- 每批次之间短暂休息,减少锁竞争
DO SLEEP(0.1);
UNTIL @offset = 0 END REPEAT;
3.4 多表关联操作的死锁
涉及多个表的操作容易导致复杂的锁依赖关系,增加死锁的风险。
解决方案:
- 统一多表操作的顺序,例如按表名字母顺序
- 减少一个事务中涉及的表数量
- 考虑使用乐观锁替代悲观锁,减少锁竞争
示例:
-- 推荐:统一的表操作顺序
BEGIN;
-- 先操作accounts表
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 再操作orders表
INSERT INTO orders (account_id, amount) VALUES (1, 100);
-- 最后操作transactions表
INSERT INTO transactions (account_id, order_id, amount) VALUES (1, LAST_INSERT_ID(), 100);
COMMIT;
4. 索引优化与死锁预防
4.1 合理设计索引
索引不仅影响查询性能,还直接影响锁的粒度和范围,进而影响死锁的发生概率。
最佳实践:
- 确保查询条件中使用了适当的索引,避免全表扫描
- 为经常在WHERE子句中出现的列创建索引
- 考虑创建复合索引,优化多条件查询
- 避免过度索引,每个索引都会增加写操作的开销
示例:
-- 为经常用于查询和更新的条件列创建索引
CREATE INDEX idx_order_status ON orders (status);
-- 为多条件查询创建复合索引
CREATE INDEX idx_product_category_status ON products (category_id, status);
4.2 避免索引失效
当索引失效时,MySQL可能会从行锁升级为表锁,大大增加死锁的风险。
最佳实践:
- 避免在索引列上使用函数或表达式
- 避免在索引列上进行类型转换
- 避免使用
!=
或<>
操作符 - 避免在索引列上使用
OR
操作符(考虑使用UNION ALL
替代) - 注意
LIKE
语句中的前缀通配符会导致索引失效
示例:
-- 推荐:索引有效
SELECT * FROM orders WHERE order_date = '2023-06-01' FOR UPDATE;
-- 不推荐:函数导致索引失效
SELECT * FROM orders WHERE YEAR(order_date) = 2023 FOR UPDATE;
-- 推荐:使用UNION ALL代替OR
SELECT * FROM products WHERE category_id = 1 FOR UPDATE
UNION ALL
SELECT * FROM products WHERE status = 'active' FOR UPDATE;
-- 不推荐:OR可能导致索引失效
SELECT * FROM products WHERE category_id = 1 OR status = 'active' FOR UPDATE;
5. 业务层面的死锁预防
5.1 乐观并发控制
在读多写少的场景中,使用乐观并发控制可以减少锁的使用,降低死锁的风险。
最佳实践:
- 使用版本号或时间戳实现乐观锁
- 在更新时检查版本号是否变化
- 适用于读多写少且冲突概率低的场景
示例:
-- 表结构包含version字段
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(100),
price DECIMAL(10,2),
version INT
);
-- 读取数据和版本号
SELECT id, name, price, version FROM products WHERE id = 1;
-- 更新时检查版本号
UPDATE products
SET price = 29.99, version = version + 1
WHERE id = 1 AND version = 5;
-- 如果影响行数为0,说明数据已被其他事务修改
5.2 避免用户交互式操作在事务内
用户交互式操作(如等待用户输入)会导致事务长时间持有锁,增加死锁的风险。
最佳实践:
- 在获取用户输入后再开启事务
- 将事务限制在数据库操作的必要范围内
- 避免在事务中进行网络请求、文件I/O等可能阻塞的操作
示例(伪代码):
// 不推荐
beginTransaction();
showUserForm();
waitForUserInput();
updateDatabase();
commitTransaction();
// 推荐
showUserForm();
waitForUserInput();
beginTransaction();
updateDatabase();
commitTransaction();
5.3 使用队列处理高并发写操作
在高并发场景下,使用队列可以将并行操作转为串行操作,从根本上避免死锁。
最佳实践:
- 使用消息队列(如RabbitMQ、Kafka)将高并发写操作转为串行处理
- 对同一资源的操作放入同一队列,确保按顺序处理
- 实现幂等性处理,避免重复操作
示例(伪代码):
// 生产者:将操作发送到队列
function updateInventory(productId, quantity) {
sendToQueue("inventory-updates", {
productId: productId,
quantity: quantity,
operation: "decrease"
});
}
// 消费者:串行处理队列中的操作
function processInventoryUpdates() {
while (true) {
let message = receiveFromQueue("inventory-updates");
beginTransaction();
try {
// 处理库存更新
if (message.operation === "decrease") {
executeUpdate("UPDATE inventory SET quantity = quantity - ? WHERE product_id = ?",
[message.quantity, message.productId]);
}
commitTransaction();
} catch (error) {
rollbackTransaction();
// 处理错误,可能重试或记录失败
}
}
}
6. 监控与预警
6.1 建立死锁监控系统
主动监控死锁的发生可以帮助及时发现和解决问题,防止死锁影响系统稳定性。
最佳实践:
- 定期检查
SHOW ENGINE INNODB STATUS
输出中的死锁信息 - 监控
information_schema.INNODB_METRICS
中的锁相关指标 - 设置死锁告警阈值,当死锁频率超过阈值时触发告警
- 记录和分析死锁日志,找出死锁的模式和根本原因
示例:
-- 查询锁等待和死锁统计信息
SELECT name, count
FROM information_schema.INNODB_METRICS
WHERE name IN ('lock_deadlocks', 'lock_timeouts', 'lock_rec_lock_waits');
-- 重置计数器
SET GLOBAL innodb_monitor_reset = 'lock_deadlocks';
6.2 性能测试与压力测试
在生产环境部署前进行充分的性能测试和压力测试,可以提前发现潜在的死锁问题。
最佳实践:
- 模拟生产环境的并发负载
- 测试各种极端情况下的系统行为
- 使用工具(如JMeter、Gatling)进行并发测试
- 分析测试结果,优化数据库设计和应用代码
五、总结与展望
MySQL死锁是高并发系统中常见的问题,但通过合理的设计和最佳实践,可以大大减少死锁的发生。预防死锁的关键在于:
- 保持一致的加锁顺序:确保所有事务按照相同的顺序获取锁
- 减小锁粒度:使用行级锁而非表级锁,只锁定必要的数据
- 减少事务持续时间:将大事务拆分为小事务,尽早提交
- 合理设置隔离级别:根据业务需求选择合适的隔离级别
- 优化索引设计:确保查询条件使用了适当的索引,避免索引失效
- 实现应用层重试机制:捕获死锁异常并实现智能重试策略
- 监控与分析:建立死锁监控系统,及时发现和解决问题
随着MySQL版本的不断更新和优化,死锁检测和处理机制也在不断完善。在MySQL 8.0及以后的版本中,引入了NOWAIT
和SKIP LOCKED
选项,为处理高并发场景下的锁竞争提供了更多选择。