一、开篇故事:两个人的相互等待 🚪
想象两个人在两间房间里:
经典死锁场景
房间A(锁着):里面有钥匙B
房间B(锁着):里面有钥匙A
张三:
1. 拿到钥匙A,进入房间A
2. 想进房间B,需要钥匙B
3. 但钥匙B在房间B里...
4. 等待李四释放房间B ⏳
李四:
1. 拿到钥匙B,进入房间B
2. 想进房间A,需要钥匙A
3. 但钥匙A在房间A里...
4. 等待张三释放房间A ⏳
结果:
→ 张三等李四
→ 李四等张三
→ 互相等待,永远无法完成!
→ 这就是死锁!💀
二、什么是死锁? 💀
2.1 死锁定义
两个或多个事务相互持有对方需要的锁,导致所有事务都无法继续执行。
2.2 死锁四个必要条件
1. 互斥条件(Mutual Exclusion)
→ 资源只能被一个事务占用
2. 持有并等待(Hold and Wait)
→ 事务持有资源,同时等待其他资源
3. 不可剥夺(No Preemption)
→ 资源不能被强制剥夺,只能主动释放
4. 循环等待(Circular Wait)
→ 形成环形等待链
四个条件同时满足 → 死锁!
2.3 经典死锁案例
案例1:两个事务互相等待
-- 初始数据
CREATE TABLE account (
id INT PRIMARY KEY,
balance DECIMAL(10,2)
);
INSERT INTO account VALUES (1, 100), (2, 200);
-- 时间线:
时刻 事务A 事务B
------------------------------------------------------------
T1 START TRANSACTION; START TRANSACTION;
T2 UPDATE account UPDATE account
SET balance = balance - 10 SET balance = balance - 20
WHERE id = 1; WHERE id = 2;
✅ 获得id=1的锁 ✅ 获得id=2的锁
T3 UPDATE account UPDATE account
SET balance = balance + 10 SET balance = balance + 20
WHERE id = 2; WHERE id = 1;
⏳ 等待id=2的锁(被B持有) ⏳ 等待id=1的锁(被A持有)
T4 💀 死锁!MySQL检测到死锁,回滚其中一个事务
死锁分析:
事务A:持有锁1,等待锁2
事务B:持有锁2,等待锁1
→ 循环等待
→ 死锁!
案例2:索引导致的死锁
-- 表结构
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
amount DECIMAL(10,2),
KEY idx_user (user_id)
);
-- 初始数据
INSERT INTO orders VALUES
(1, 100, 50),
(2, 100, 80),
(3, 200, 100);
-- 事务A
START TRANSACTION;
DELETE FROM orders WHERE user_id = 100;
-- 1. 在idx_user索引上加锁:锁住user_id=100的范围
-- 2. 回表加锁:锁住id=1, id=2的记录
-- 事务B(同时执行)
START TRANSACTION;
DELETE FROM orders WHERE id = 2;
-- ⏳ 等待id=2的锁(被A持有)
-- 事务A继续
DELETE FROM orders WHERE id = 1;
-- ⏳ 等待id=1的锁
-- 但此时B也在等待...
-- 💀 死锁!
案例3:Gap Lock导致的死锁
-- 表结构(id不连续)
CREATE TABLE test (
id INT PRIMARY KEY,
value VARCHAR(50)
);
INSERT INTO test VALUES (1, 'a'), (5, 'b'), (10, 'c');
-- 隔离级别:RR(可重复读)
-- 事务A
START TRANSACTION;
SELECT * FROM test WHERE id = 3 FOR UPDATE;
-- id=3不存在,加Gap Lock:(1, 5)
-- 事务B
START TRANSACTION;
SELECT * FROM test WHERE id = 4 FOR UPDATE;
-- id=4不存在,加Gap Lock:(1, 5)
-- 事务A继续
INSERT INTO test VALUES (3, 'd');
-- ⏳ 等待Gap Lock释放(被B持有)
-- 事务B继续
INSERT INTO test VALUES (4, 'e');
-- ⏳ 等待Gap Lock释放(被A持有)
-- 💀 死锁!
三、MySQL如何检测死锁?🔍
3.1 死锁检测算法:等待图(Wait-for Graph)
原理: 构建事务等待关系的有向图,如果存在环,则存在死锁。
事务A → 事务B → 事务C → 事务A
↑ ↓
+←←←←←←←←←←←←←←←←+
这是一个环 → 死锁!
3.2 检测过程
1. MySQL定期检测(每次加锁时检测)
2. 构建等待图:
- 节点:事务
- 边:A等待B → A→B
3. 检测环:
- 深度优先搜索(DFS)
- 发现环 → 死锁
4. 选择牺牲者:
- 回滚代价最小的事务(undo量最少)
- 返回错误:ERROR 1213 (40001): Deadlock found when trying to get lock
3.3 死锁检测配置
-- 查看死锁检测是否开启(默认开启)
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- innodb_deadlock_detect = ON
-- 关闭死锁检测(不推荐)
SET GLOBAL innodb_deadlock_detect = OFF;
-- 如果关闭,死锁会等到锁超时
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- innodb_lock_wait_timeout = 50(秒)
四、如何查看死锁信息?📊
4.1 查看最近一次死锁
SHOW ENGINE INNODB STATUS;
输出示例:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-01-15 10:30:45 0x7f8c
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 10, OS thread handle 140241234567, query id 100 localhost root updating
UPDATE account SET balance = balance + 10 WHERE id = 2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`account` trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 00000002; asc ;; ← id = 2
1: len 6; hex 000000003039; asc 09;;
2: len 7; hex 27000001410110; asc ' A ;;
3: len 8; hex 00000000000000c8; asc ;;
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 11, OS thread handle 140241234568, query id 101 localhost root updating
UPDATE account SET balance = balance + 20 WHERE id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`account` trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 00000002; asc ;; ← 持有id=2的锁
1: len 6; hex 000000003039; asc 09;;
2: len 7; hex 27000001410110; asc ' A ;;
3: len 8; hex 00000000000000c8; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`account` trx id 12346 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 00000001; asc ;; ← 等待id=1的锁
1: len 6; hex 000000003038; asc 08;;
2: len 7; hex 1f000001400110; asc @ ;;
3: len 8; hex 0000000000000064; asc d;;
*** WE ROLL BACK TRANSACTION (2) ← MySQL选择回滚事务2
分析:
1. 事务1(12345):
- 持有id=1的锁
- 等待id=2的锁
2. 事务2(12346):
- 持有id=2的锁
- 等待id=1的锁
3. 结果:
- MySQL检测到死锁
- 选择回滚事务2(代价较小)
4.2 死锁日志记录
-- 开启死锁日志
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 死锁信息会记录在MySQL错误日志中
-- 位置:/var/log/mysql/error.log
五、如何避免死锁?🛡️
5.1 按相同顺序访问资源 ⭐⭐⭐⭐⭐
问题
-- 事务A
UPDATE account SET balance = balance - 10 WHERE id = 1; -- 先锁1
UPDATE account SET balance = balance + 10 WHERE id = 2; -- 后锁2
-- 事务B
UPDATE account SET balance = balance - 20 WHERE id = 2; -- 先锁2
UPDATE account SET balance = balance + 20 WHERE id = 1; -- 后锁1
-- 💀 死锁!
解决方案
-- 统一顺序:总是按id从小到大的顺序加锁
-- 事务A
UPDATE account SET balance = balance - 10 WHERE id = 1; -- 先锁1
UPDATE account SET balance = balance + 10 WHERE id = 2; -- 后锁2
-- 事务B
UPDATE account SET balance = balance + 20 WHERE id = 1; -- 先锁1
UPDATE account SET balance = balance - 20 WHERE id = 2; -- 后锁2
-- ✅ 不会死锁!
代码实现:
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 按id大小排序,确保加锁顺序一致
Long firstId = fromId < toId ? fromId : toId;
Long secondId = fromId < toId ? toId : fromId;
// 先锁小id,后锁大id
Account first = accountMapper.selectForUpdate(firstId);
Account second = accountMapper.selectForUpdate(secondId);
// 执行转账逻辑
if (fromId.equals(firstId)) {
first.setBalance(first.getBalance().subtract(amount));
second.setBalance(second.getBalance().add(amount));
} else {
first.setBalance(first.getBalance().add(amount));
second.setBalance(second.getBalance().subtract(amount));
}
accountMapper.updateById(first);
accountMapper.updateById(second);
}
5.2 减少事务持有锁的时间 ⭐⭐⭐⭐
问题
@Transactional
public void processOrder(Long orderId) {
// 1. 查询订单(加锁)
Order order = orderMapper.selectForUpdate(orderId);
// 2. 复杂业务逻辑(耗时操作)
calculateDiscount(order); // 100ms
checkInventory(order); // 200ms
sendNotification(order); // 500ms ← 持有锁的时间太长!
// 3. 更新订单
orderMapper.updateById(order);
}
解决方案
public void processOrder(Long orderId) {
// 1. 复杂业务逻辑放在事务外
Order order = orderMapper.selectById(orderId); // 不加锁
calculateDiscount(order);
checkInventory(order);
// 2. 只在更新时加锁
updateOrderInTransaction(order);
// 3. 耗时操作放在事务外
sendNotification(order); // 异步发送
}
@Transactional
public void updateOrderInTransaction(Order order) {
// 只在这里加锁,持有锁的时间很短
orderMapper.updateById(order);
}
5.3 使用较低的隔离级别 ⭐⭐⭐
-- 问题:RR级别有Gap Lock,容易死锁
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM test WHERE id = 3 FOR UPDATE; -- Gap Lock: (1, 5)
-- 解决方案:改为RC级别(无Gap Lock)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM test WHERE id = 3 FOR UPDATE; -- 只锁id=3(如果存在)
注意: RC级别无法解决幻读问题,需要权衡。
5.4 使用乐观锁代替悲观锁 ⭐⭐⭐⭐
悲观锁(易死锁)
@Transactional
public void updateStock(Long productId, int quantity) {
// SELECT ... FOR UPDATE(加锁)
Product product = productMapper.selectForUpdate(productId);
if (product.getStock() >= quantity) {
product.setStock(product.getStock() - quantity);
productMapper.updateById(product);
}
}
乐观锁(不易死锁)
@Transactional
public void updateStock(Long productId, int quantity) {
Product product = productMapper.selectById(productId); // 不加锁
if (product.getStock() >= quantity) {
// UPDATE ... WHERE id = ? AND version = ?(乐观锁)
int rows = productMapper.updateWithVersion(
productId,
product.getStock() - quantity,
product.getVersion()
);
if (rows == 0) {
throw new OptimisticLockException("版本冲突,请重试");
}
}
}
-- SQL
UPDATE products
SET stock = stock - #{quantity},
version = version + 1
WHERE id = #{id}
AND version = #{version}
AND stock >= #{quantity};
5.5 大事务拆分小事务 ⭐⭐⭐⭐
问题
@Transactional
public void batchProcess(List<Long> orderIds) {
// 一次性处理1000个订单
for (Long orderId : orderIds) {
Order order = orderMapper.selectForUpdate(orderId);
// 处理逻辑...
orderMapper.updateById(order);
}
// ❌ 事务太大,持有锁太多,容易死锁
}
解决方案
public void batchProcess(List<Long> orderIds) {
// 分批处理,每批100个
int batchSize = 100;
for (int i = 0; i < orderIds.size(); i += batchSize) {
int end = Math.min(i + batchSize, orderIds.size());
List<Long> batch = orderIds.subList(i, end);
processBatch(batch); // 每批一个事务
}
}
@Transactional
public void processBatch(List<Long> batch) {
for (Long orderId : batch) {
Order order = orderMapper.selectForUpdate(orderId);
// 处理逻辑...
orderMapper.updateById(order);
}
}
5.6 添加合适的索引 ⭐⭐⭐⭐⭐
问题
-- 表结构
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT, -- 无索引
status INT
);
-- 事务A
UPDATE orders SET status = 1 WHERE user_id = 100;
-- 没有user_id索引,锁住整个表!
-- 事务B
UPDATE orders SET status = 2 WHERE user_id = 200;
-- 也锁整个表
-- 💀 容易死锁!
解决方案
-- 添加索引
CREATE INDEX idx_user_id ON orders(user_id);
-- 现在只锁相关行,不会死锁 ✅
六、死锁处理策略 🚑
6.1 自动处理(MySQL默认)
try {
// 执行数据库操作
transferMoney(fromId, toId, amount);
} catch (DeadlockLoserDataAccessException e) {
// MySQL检测到死锁,自动回滚了事务
log.error("死锁发生,事务已回滚", e);
// 重试(最多3次)
for (int i = 0; i < 3; i++) {
try {
Thread.sleep(100); // 等待一下
transferMoney(fromId, toId, amount);
return; // 成功
} catch (DeadlockLoserDataAccessException retry) {
if (i == 2) {
throw new BusinessException("操作失败,请稍后重试");
}
}
}
}
6.2 超时处理
-- 设置锁等待超时(默认50秒)
SET innodb_lock_wait_timeout = 10; -- 改为10秒
-- 如果10秒还拿不到锁,自动超时
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
@Transactional(timeout = 10) // Spring事务超时
public void processOrder(Long orderId) {
// 如果10秒未完成,自动回滚
}
6.3 监控和告警
@Component
public class DeadlockMonitor {
@Scheduled(fixedRate = 60000) // 每分钟检查一次
public void checkDeadlock() {
// 查询死锁信息
String status = jdbcTemplate.queryForObject(
"SHOW ENGINE INNODB STATUS",
String.class
);
if (status.contains("LATEST DETECTED DEADLOCK")) {
// 解析死锁信息
DeadlockInfo info = parseDeadlock(status);
// 发送告警
alertService.sendAlert(
"检测到死锁!",
"表:" + info.getTable() +
",SQL:" + info.getSql()
);
// 记录日志
log.error("死锁详情:{}", info);
}
}
}
七、实战案例:电商秒杀死锁 💼
问题
// 秒杀场景
@Transactional
public void seckill(Long productId, Long userId) {
// 1. 锁定商品(for update)
Product product = productMapper.selectForUpdate(productId);
if (product.getStock() > 0) {
// 2. 减库存
product.setStock(product.getStock() - 1);
productMapper.updateById(product);
// 3. 创建订单(可能锁订单号生成器)
String orderNo = generateOrderNo(); // 锁
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setProductId(productId);
orderMapper.insert(order);
}
}
// 并发场景:
// 用户A秒杀商品1
// 用户B秒杀商品2
// 两个事务可能在生成订单号时死锁!
解决方案
// 方案1:使用Redis分布式锁
public void seckill(Long productId, Long userId) {
String lockKey = "seckill:" + productId;
// Redis分布式锁(串行化,不会死锁)
boolean locked = redisLock.tryLock(lockKey, 5, TimeUnit.SECONDS);
if (locked) {
try {
seckillInTransaction(productId, userId);
} finally {
redisLock.unlock(lockKey);
}
} else {
throw new BusinessException("系统繁忙,请稍后重试");
}
}
@Transactional
public void seckillInTransaction(Long productId, Long userId) {
Product product = productMapper.selectForUpdate(productId);
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
productMapper.updateById(product);
// 订单号生成放在事务外
String orderNo = snowflakeIdGenerator.nextId(); // 雪花算法,不需要锁
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setProductId(productId);
orderMapper.insert(order);
}
}
// 方案2:使用消息队列(异步处理)
public void seckill(Long productId, Long userId) {
// 发送到MQ
SeckillMessage msg = new SeckillMessage(productId, userId);
mqProducer.send("seckill_topic", msg);
return "排队中,请稍后查询结果";
}
@RabbitListener(queues = "seckill_queue")
public void processSeckill(SeckillMessage msg) {
// 消费者串行处理,不会死锁
seckillInTransaction(msg.getProductId(), msg.getUserId());
}
八、面试高频问题 🎤
Q1: 什么是死锁?如何产生?
答: 死锁是两个或多个事务相互持有对方需要的锁,导致都无法继续执行。产生条件:
- 互斥条件:资源只能被一个事务占用
- 持有并等待:持有资源,同时等待其他资源
- 不可剥夺:资源不能被强制剥夺
- 循环等待:形成环形等待链
Q2: MySQL如何检测死锁?
答: 使用等待图(Wait-for Graph)算法:
- 构建有向图:节点是事务,边表示等待关系
- 检测环:使用深度优先搜索(DFS)
- 发现环即死锁,选择回滚代价最小的事务
Q3: 如何避免死锁?
答:
- 按相同顺序访问资源(最重要)
- 减少事务持有锁的时间
- 使用较低的隔离级别(RC代替RR)
- 使用乐观锁代替悲观锁
- 大事务拆分小事务
- 添加合适的索引(减少锁范围)
Q4: 死锁发生后如何处理?
答:
- 自动处理:MySQL自动回滚代价小的事务,应用层捕获异常后重试
- 超时处理:设置
innodb_lock_wait_timeout,超时自动回滚 - 监控告警:定期检查
SHOW ENGINE INNODB STATUS,发现死锁及时告警 - 业务优化:分析死锁日志,优化SQL和业务逻辑
Q5: 为什么按相同顺序访问资源可以避免死锁?
答: 因为破坏了循环等待条件。例如所有事务都按id从小到大加锁:
- 事务A:锁1 → 锁2
- 事务B:锁1 → 锁2
事务B等待锁1时,不会同时持有锁2,不会形成循环等待,所以不会死锁。
九、总结口诀 📝
死锁本质相互等,
四个条件缺一不可。
互斥持有不剥夺,
循环等待是关键。
检测使用等待图,
发现环路即死锁。
回滚代价小的事务,
保证系统能运行。
避免死锁有妙招,
统一顺序最重要。
减少持锁时间短,
乐观锁代替悲观锁。
大事务拆成小事务,
索引优化锁范围。
监控告警不能少,
出现问题快定位!
参考资料 📚
下期预告: 150-如何设计一个高性能的订单表结构?📋
编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0
愿你的事务永不死锁! 🔓✨