核心概念
死锁(Deadlock)是指两个或多个事务在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进。
事务 A 持有资源 1,请求资源 2
事务 B 持有资源 2,请求资源 1
→ 循环等待,形成死锁
死锁产生的四个必要条件
- 互斥条件:资源一次只能被一个事务占用
- 请求与保持:事务持有资源的同时请求新资源
- 不剥夺条件:已获得的资源不能被强制剥夺
- 循环等待:存在事务等待的循环链
死锁底层原理
InnoDB 死锁检测机制
InnoDB 使用**等待图(Wait-For Graph)**检测死锁:
等待图结构:
- 节点:事务
- 边:事务 A → 事务 B 表示 A 等待 B 持有的锁
检测时机:
- 每次事务请求锁被阻塞时
- 遍历等待图,检测是否存在环
- 发现环则选择牺牲一个事务(回滚)
死锁检测配置
-- 开启死锁检测(默认开启)
SET GLOBAL innodb_deadlock_detect = ON;
-- 死锁检测超时(秒)
SET GLOBAL innodb_lock_wait_timeout = 50;
-- 查看当前配置
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
牺牲者选择策略
当检测到死锁时,InnoDB 会选择回滚一个事务(牺牲者),选择标准:
- 回滚代价最小的事务
- 持有锁数量较少的事务
- 修改数据量较少的事务
- 如果代价相同,随机选择
常见死锁场景
场景 1:交叉更新(最典型)
-- 表结构
CREATE TABLE accounts (
id INT PRIMARY KEY,
balance DECIMAL(10, 2)
);
INSERT INTO accounts VALUES (1, 1000), (2, 2000);
-- 事务 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 持有 id=1 的锁
-- 此时事务 B 启动...
UPDATE accounts SET balance = balance - 200 WHERE id = 2; -- 请求 id=2 的锁
-- 事务 B
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- 持有 id=2 的锁
-- 此时事务 A 已持有 id=1 的锁...
UPDATE accounts SET balance = balance - 30 WHERE id = 1; -- 请求 id=1 的锁
-- 死锁!
-- 事务 A:持有 id=1,等待 id=2
-- 事务 B:持有 id=2,等待 id=1
解决方案:固定访问顺序
-- 所有事务都按 id 升序访问
-- 事务 A 和 事务 B 都执行:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance - 200 WHERE id = 2;
COMMIT;
场景 2:间隙锁导致的死锁
-- 表结构
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
status INT,
INDEX idx_user_id (user_id)
);
INSERT INTO orders VALUES
(1, 100, 1), (5, 100, 1), (10, 100, 1);
-- 事务 A
BEGIN;
SELECT * FROM orders WHERE user_id = 100 AND id < 5 FOR UPDATE;
-- 锁定:user_id=100 的间隙 (1, 5)
-- 事务 B
BEGIN;
SELECT * FROM orders WHERE user_id = 100 AND id > 5 FOR UPDATE;
-- 锁定:user_id=100 的间隙 (5, 10)
-- 事务 A:尝试插入 id=6
INSERT INTO orders VALUES (6, 100, 1); -- 等待事务 B 的间隙锁
-- 事务 B:尝试插入 id=4
INSERT INTO orders VALUES (4, 100, 1); -- 等待事务 A 的间隙锁
-- 死锁!
解决方案:
-- 方案 1:使用 READ COMMITTED 隔离级别
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 方案 2:优化为等值查询
SELECT * FROM orders WHERE user_id = 100 AND id = 5 FOR UPDATE;
-- 方案 3:添加唯一索引,避免间隙锁
ALTER TABLE orders ADD UNIQUE INDEX idx_user_unique (user_id, id);
场景 3:外键约束导致的死锁
-- 父表
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50)
);
-- 子表(有外键)
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 事务 A:删除父记录
BEGIN;
DELETE FROM users WHERE id = 1; -- 需要等待子表锁
-- 事务 B:插入子记录
BEGIN;
INSERT INTO orders (id, user_id) VALUES (100, 1); -- 检查外键,等待父表锁
-- 死锁!
-- 事务 A:等待子表锁以检查外键
-- 事务 B:等待父表锁以检查外键
解决方案:
-- 方案 1:先删除子记录,再删除父记录
BEGIN;
DELETE FROM orders WHERE user_id = 1;
DELETE FROM users WHERE id = 1;
COMMIT;
-- 方案 2:调整外键约束
ALTER TABLE orders
DROP FOREIGN KEY orders_ibfk_1,
ADD CONSTRAINT orders_ibfk_1
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
场景 4:唯一索引冲突导致的死锁
-- 表结构
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(100) UNIQUE,
name VARCHAR(50)
);
-- 事务 A
BEGIN;
INSERT INTO users (email, name) VALUES ('test@example.com', 'Alice');
-- 持有 email='test@example.com' 的排他锁
-- 事务 B
BEGIN;
INSERT INTO users (email, name) VALUES ('test@example.com', 'Bob');
-- 等待唯一索引锁
-- 事务 A:尝试插入另一条(可能因唯一键冲突或其他原因等待)
INSERT INTO users (email, name) VALUES ('other@example.com', 'Charlie');
-- 事务 B:也在等待...
-- 死锁!
解决方案:
-- 方案 1:使用 INSERT ... ON DUPLICATE KEY UPDATE
INSERT INTO users (email, name)
VALUES ('test@example.com', 'Alice')
ON DUPLICATE KEY UPDATE name = VALUES(name);
-- 方案 2:应用层先检查是否存在
SELECT id FROM users WHERE email = 'test@example.com';
-- 如果不存在再插入
-- 方案 3:捕获死锁异常,重试机制
场景 5:多表更新顺序不一致
-- 事务 A
BEGIN;
UPDATE users SET status = 1 WHERE id = 1; -- 持有 users 表 id=1 的锁
UPDATE orders SET status = 1 WHERE user_id = 1; -- 请求 orders 表的锁
-- 事务 B
BEGIN;
UPDATE orders SET status = 1 WHERE user_id = 1; -- 持有 orders 表的锁
UPDATE users SET status = 1 WHERE id = 1; -- 请求 users 表 id=1 的锁
-- 死锁!
解决方案:固定多表访问顺序
-- 所有事务都按相同顺序访问表
BEGIN;
UPDATE users SET status = 1 WHERE id = 1;
UPDATE orders SET status = 1 WHERE user_id = 1;
COMMIT;
死锁排查方法
1. 查看死锁日志
-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS\G
-- 输出示例
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136 bytes
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 1 page no 3 n bits 72 index PRIMARY of table `test`.`accounts`
trx id 12345 lock_mode X
rec bit mask 1
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1 page no 5 n bits 72 index PRIMARY of table `test`.`accounts`
trx id 12345 lock_mode X
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 8 sec starting index read
mysql tables in use 1, locked 1
2 lock struct(s), heap size 1136 bytes
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 1 page no 5 n bits 72 index PRIMARY of table `test`.`accounts`
trx id 12346 lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1 page no 3 n bits 72 index PRIMARY of table `test`.`accounts`
trx id 12346 lock_mode X
*** WE ROLL BACK TRANSACTION (1)
2. 查看锁等待信息
-- MySQL 5.7+ 查看锁等待
SELECT
request_trx_id,
requested_lock_id,
blocking_trx_id,
blocking_lock_id
FROM performance_schema.data_lock_waits;
-- MySQL 8.0+ 使用 sys 库
SELECT * FROM sys.innodb_lock_waits;
-- 查看完整的事务和锁信息
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM information_schema.INNODB_LOCK_WAITS w
INNER JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id;
3. 查看当前持有的锁
-- 查看当前所有锁
SELECT
engine_transaction_id,
lock_table,
lock_index,
lock_type,
lock_mode,
lock_data
FROM performance_schema.data_locks;
-- 查看特定事务的锁
SELECT * FROM performance_schema.data_locks
WHERE engine_transaction_id = 12345;
4. 监控死锁频率
-- 查看死锁统计
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
-- 持续监控(每 5 秒记录一次)
DELIMITER $$
CREATE PROCEDURE monitor_deadlocks()
BEGIN
DECLARE prev_count INT DEFAULT 0;
DECLARE curr_count INT;
SELECT VARIABLE_VALUE INTO prev_count
FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'Innodb_deadlocks';
WHILE TRUE DO
DO SLEEP(5);
SELECT VARIABLE_VALUE INTO curr_count
FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'Innodb_deadlocks';
IF curr_count > prev_count THEN
SELECT CONCAT(NOW(), ': Deadlock detected! Count: ', curr_count) AS alert;
SET prev_count = curr_count;
END IF;
END WHILE;
END$$
DELIMITER ;
死锁解决方案
1. 应用层优化
固定访问顺序
// 错误示例:顺序不一致
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 不同调用可能顺序不同
updateAccount(fromId, amount.negate());
updateAccount(toId, amount);
}
// 正确示例:固定按 id 升序
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Long first = Math.min(fromId, toId);
Long second = Math.max(fromId, toId);
updateAccount(first, first.equals(fromId) ? amount.negate() : amount);
updateAccount(second, second.equals(fromId) ? amount.negate() : amount);
}
重试机制
@Transactional
public void updateWithRetry(Account account, int maxRetries) {
int retryCount = 0;
while (retryCount < maxRetries) {
try {
accountRepository.update(account);
return;
} catch (DeadlockLoserDataAccessException e) {
retryCount++;
if (retryCount >= maxRetries) {
throw e;
}
// 指数退避
Thread.sleep(100 * (1 << retryCount));
}
}
}
批量操作分批处理
// 错误示例:大批量更新
public void batchUpdate(List<Long> ids) {
// 一次性更新所有,锁持有时间长
accountRepository.updateAll(ids);
}
// 正确示例:分批处理
public void batchUpdate(List<Long> ids) {
int batchSize = 100;
for (int i = 0; i < ids.size(); i += batchSize) {
List<Long> batch = ids.subList(i, Math.min(i + batchSize, ids.size()));
accountRepository.updateAll(batch);
// 每批之间提交,释放锁
}
}
2. 数据库层优化
调整隔离级别
-- 使用 READ COMMITTED 减少间隙锁
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 全局设置
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
调整锁等待超时
-- 缩短锁等待时间,快速失败
SET SESSION innodb_lock_wait_timeout = 5;
-- 全局设置
SET GLOBAL innodb_lock_wait_timeout = 50;
添加必要索引
-- 无索引导致全表锁,容易死锁
UPDATE orders SET status = 1 WHERE user_id = 100;
-- 添加索引,使用行锁
CREATE INDEX idx_user_id ON orders(user_id);
3. 架构层优化
队列化串行处理
// 使用消息队列串行处理同一资源的更新
@Component
public class AccountUpdateService {
@Resource
private SendMessageTemplate messageQueueTemplate;
public void updateAccount(Long accountId, BigDecimal amount) {
// 同一账户的更新进入同一队列,串行执行
messageQueueTemplate.send(
"account_update_" + accountId,
new AccountUpdateMessage(accountId, amount)
);
}
}
分布式锁
// 使用 Redis 分布式锁
@Component
public class AccountService {
@Resource
private RedisTemplate<String, String> redisTemplate;
public void transfer(Long fromId, Long toId, BigDecimal amount) {
String lockKey = "lock:account:" + Math.min(fromId, toId);
RLock lock = redisson.getLock(lockKey);
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
try {
// 执行业务逻辑
doTransfer(fromId, toId, amount);
} finally {
lock.unlock();
}
}
}
}
生产环境死锁案例
案例 1:电商库存扣减死锁
问题描述: 大促期间,库存扣减频繁死锁,导致订单创建失败。
原始代码:
-- 事务 A:扣减商品 A 库存
UPDATE stock SET quantity = quantity - 1 WHERE product_id = 100;
UPDATE stock SET quantity = quantity - 1 WHERE product_id = 101;
-- 事务 B:扣减商品 B 库存
UPDATE stock SET quantity = quantity - 1 WHERE product_id = 101;
UPDATE stock SET quantity = quantity - 1 WHERE product_id = 100;
解决方案:
-- 方案 1:按 product_id 排序后更新
-- 方案 2:使用队列串行处理同一商品
-- 方案 3:Redis 预扣减 + 异步同步到 MySQL
案例 2:账户余额更新死锁
问题描述: 转账业务频繁死锁,尤其在并发高峰期。
原始代码:
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
Account fromAccount = accountMapper.selectById(from);
Account toAccount = accountMapper.selectById(to);
fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
toAccount.setBalance(toAccount.getBalance().add(amount));
accountMapper.update(fromAccount);
accountMapper.update(toAccount);
}
解决方案:
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
// 固定按 id 升序更新
Long first = Math.min(from, to);
Long second = Math.max(from, to);
Account firstAccount = accountMapper.selectById(first);
Account secondAccount = accountMapper.selectById(second);
if (first.equals(from)) {
firstAccount.setBalance(firstAccount.getBalance().subtract(amount));
secondAccount.setBalance(secondAccount.getBalance().add(amount));
} else {
firstAccount.setBalance(firstAccount.getBalance().add(amount));
secondAccount.setBalance(secondAccount.getBalance().subtract(amount));
}
accountMapper.update(firstAccount);
accountMapper.update(secondAccount);
}
案例 3:唯一索引插入死锁
问题描述: 用户注册时,并发插入同一邮箱导致死锁。
原始代码:
try {
userMapper.insert(new User(email, name));
} catch (DuplicateKeyException e) {
throw new BusinessException("邮箱已存在");
}
解决方案:
// 使用 INSERT ... ON DUPLICATE KEY UPDATE
userMapper.insertOrUpdate(email, name);
// 或者先查询再插入(加锁)
User existing = userMapper.selectByEmail(email);
if (existing != null) {
throw new BusinessException("邮箱已存在");
}
userMapper.insert(new User(email, name));
死锁预防最佳实践
开发规范
- 固定访问顺序:多表/多行更新时,按固定顺序(如主键升序)
- 缩短事务:事务中只包含必要的数据库操作
- 避免大事务:不在事务中执行 RPC、HTTP 请求
- 批量分批:大批量操作分批提交
- 索引优化:确保 WHERE 条件使用索引
监控告警
-- 创建死锁监控表
CREATE TABLE deadlock_monitor (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
occur_time DATETIME,
trx1_id VARCHAR(50),
trx2_id VARCHAR(50),
query1 TEXT,
query2 TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 定期采集死锁信息
INSERT INTO deadlock_monitor (occur_time, trx1_id, trx2_id, query1, query2)
SELECT NOW(), ... FROM information_schema.INNODB_TRX;
配置建议
# my.cnf 配置建议
[mysqld]
# 开启死锁检测
innodb_deadlock_detect = ON
# 锁等待超时(秒)
innodb_lock_wait_timeout = 50
# 自增锁模式(高并发场景)
innodb_autoinc_lock_mode = 2
# 隔离级别(根据业务选择)
transaction-isolation = READ-COMMITTED
总结
核心要点
- 死锁本质:循环等待资源
- 检测机制:等待图算法,发现环即回滚
- 常见场景:
- 交叉更新(最常见)
- 间隙锁冲突
- 外键约束
- 唯一索引冲突
- 多表顺序不一致
- 排查方法:
SHOW ENGINE INNODB STATUSperformance_schema.data_lock_waitssys.innodb_lock_waits
- 解决方案:
- 应用层:固定顺序、重试机制、分批处理
- 数据库层:调整隔离级别、添加索引
- 架构层:队列化、分布式锁
预防策略
- 设计阶段:考虑并发场景,设计合理的访问顺序
- 开发阶段:遵循规范,缩短事务,避免大事务
- 测试阶段:压力测试,模拟并发场景
- 运维阶段:监控告警,及时响应
参考资料
- MySQL 官方文档 - 死锁
- MySQL 官方文档 - 锁设置
- 《高性能 MySQL》第 7 章:MySQL 高级特性
- 《MySQL 技术内幕:InnoDB 存储引擎》第 6 章:锁