在高并发业务场景中,比如电商订单支付、金融转账、秒杀活动,你可能遇到过这样的报错:Deadlock found when trying to get lock; try restarting transaction。这就是数据库死锁------两个或多个事务像"互相卡脖子"一样,都等着对方释放资源,最终陷入无限等待。今天我们从死锁的本质出发,通过实战案例讲解复现方法、全维度解决方案,以及进阶优化技巧,帮你彻底搞定死锁问题。
一、先搞懂:数据库死锁是什么?
死锁的核心是"循环等待",但它的发生需要满足四个必要条件(缺一不可),我们用"两个人抢餐具吃饭"的场景通俗理解:
- 互斥条件:同一资源只能被一个事务占用。比如一双筷子只能被一个人拿,不能两个人同时用。
- 请求与保持条件:事务持有一个资源的同时,又请求另一个被占用的资源。比如甲拿了筷子,还想要乙手里的碗。
- 不剥夺条件:已占用的资源不能被强行抢走。比如甲拿了筷子,乙不能直接抢过来。
- 循环等待条件:多个事务形成"你等我、我等你"的环形依赖。比如甲等乙的碗,乙等甲的筷子。
只要破坏其中一个条件,死锁就不会发生。这也是我们后续解决方案的核心思路。
二、实战:死锁如何复现?(以MySQL为例)
要解决死锁,首先要能复现它。我们用"用户转账"这个经典场景,基于MySQL的InnoDB存储引擎(支持行锁)来复现死锁。
1. 准备测试环境
先创建一张"账户表",用于模拟转账操作:
sql
-- 创建账户表(含version字段,后续用于乐观锁)
CREATE TABLE `account` (
`id` INT PRIMARY KEY COMMENT '账户ID',
`user_name` VARCHAR(50) NOT NULL COMMENT '用户名',
`balance` DECIMAL(10,2) NOT NULL COMMENT '账户余额',
`version` INT DEFAULT 1 COMMENT '版本号(乐观锁用)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 插入测试数据:两个用户,初始余额分别为1000和2000
INSERT INTO `account` (`id`, `user_name`, `balance`)
VALUES (1, '张三', 1000.00), (2, '李四', 2000.00);
2. 模拟死锁:交叉更新数据
打开两个MySQL客户端窗口(分别代表事务A和事务B),按以下步骤执行操作:
| 步骤 | 事务A(张三转100给李四) | 事务B(李四转200给张三) |
|---|---|---|
| 1 | BEGIN;(开启事务) |
BEGIN;(开启事务) |
| 2 | 更新张三账户(扣100): UPDATE account SET balance = balance - 100 WHERE id = 1; |
- |
| 3 | - | 更新李四账户(扣200): UPDATE account SET balance = balance - 200 WHERE id = 2; |
| 4 | 尝试更新李四账户(加100): UPDATE account SET balance = balance + 100 WHERE id = 2; |
- |
| 5 | - | 尝试更新张三账户(加200): UPDATE account SET balance = balance + 200 WHERE id = 1; |
3. 观察死锁现象
当执行到步骤4和5时,MySQL会立即报错(或等待几秒后超时):
-- 常见错误1:死锁检测触发
1213 - Deadlock found when trying to get lock; try restarting transaction
-- 常见错误2:锁等待超时
1205 - Lock wait timeout exceeded; try restarting transaction
此时InnoDB会自动回滚"代价最小"的事务(比如修改行数少的事务),避免系统陷入无限等待。
三、死锁全解决方案:三大核心策略
针对死锁的四个必要条件,我们从"预防""检测与解除""优化设计"三个维度给出解决方案,每个方案都附实战代码。
策略一:预防死锁------从源头减少冲突
预防的核心是"破坏死锁的必要条件",重点解决"循环等待"和"请求与保持"问题。
1. 按固定顺序访问资源(破坏循环等待)
所有事务对资源的访问顺序保持一致(比如按ID升序),避免交叉等待。
优化后的转账代码:无论谁转谁,都先更新ID小的账户,再更新ID大的账户。
| 事务A(张三转100给李四,ID1→ID2) | 事务B(李四转200给张三,ID1→ID2) |
|---|---|
BEGIN; |
BEGIN; |
| -- 先更ID1(张三) | -- 先更ID1(张三) |
UPDATE account SET balance = balance - 100 WHERE id = 1; |
UPDATE account SET balance = balance + 200 WHERE id = 1; |
| -- 再更ID2(李四) | -- 再更ID2(李四) |
UPDATE account SET balance = balance + 100 WHERE id = 2; |
UPDATE account SET balance = balance - 200 WHERE id = 2; |
COMMIT; |
COMMIT; |
原理:事务A和B都先请求ID1的锁,后请求ID2的锁,不会形成环形依赖,彻底避免循环等待。
2. 用乐观锁替代悲观锁(减少锁持有)
乐观锁假设"冲突很少发生",通过"版本号/时间戳"实现无锁更新,避免长时间持有锁(破坏"请求与保持")。
实战代码 :利用之前表中的version字段,更新时校验版本号:
sql
-- 张三转账100给李四:先查版本号,再更新(应用层需处理重试)
-- 1. 查询张三账户当前版本
SELECT id, balance, version FROM account WHERE id = 1;
-- 假设返回:id=1, balance=1000, version=1
-- 2. 更新时校验版本号,匹配则更新并递增版本
UPDATE account
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 1; -- 仅当版本号为1时生效
-- 3. 若更新影响行数为0(说明版本已变),应用层重试(比如重试3次)
适用场景:并发高但冲突少的场景(如电商库存更新),避免悲观锁的性能损耗。
3. 缩短事务时间(减少锁持有时长)
事务越长,锁持有时间越久,冲突概率越高。通过"拆分大事务""避免事务内用户交互"缩短时长。
反例(长事务):事务内包含查询、计算、调用外部接口(如发短信),锁持有时间长。
java
// 错误示例:事务包含外部接口调用,锁持有10秒+
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 1. 查询账户(加锁)
Account from = accountMapper.selectByIdForUpdate(fromId);
// 2. 调用外部接口(发短信,耗时5秒)
smsService.send(from.getUserName(), "转账通知");
// 3. 更新账户(锁已持有5秒)
from.setBalance(from.getBalance().subtract(amount));
accountMapper.updateById(from);
}
优化后(短事务):仅保留核心更新操作,外部逻辑移出事务:
java
// 正确示例:事务仅包含更新操作,锁持有毫秒级
@Transactional(timeout = 5) // 设置超时,5秒未完成自动回滚
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 1. 查账户(加锁)
Account from = accountMapper.selectByIdForUpdate(fromId);
Account to = accountMapper.selectByIdForUpdate(toId);
// 2. 核心更新(快速完成)
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
accountMapper.updateById(from);
accountMapper.updateById(to);
}
// 外部逻辑:事务提交后调用
public void sendTransferNotice(Account account) {
smsService.send(account.getUserName(), "转账通知");
}
策略二:检测与解除------死锁发生后处理
即使做好预防,高并发下仍可能出现死锁,此时需要通过工具检测并手动/自动解除。
1. 数据库自动检测(InnoDB内置机制)
MySQL InnoDB默认开启死锁检测,每秒检查一次死锁 ,发现后自动回滚"代价最小"的事务(比如修改行数少、执行时间短的事务)。
无需额外配置,适合大多数场景,但极端情况下可能误判(需结合手动排查)。
2. 手动排查死锁:查看死锁日志
当死锁频繁发生时,需要定位具体SQL和事务,通过SHOW ENGINE INNODB STATUS查看详细日志:
sql
-- 执行命令,查看最近一次死锁详情
SHOW ENGINE INNODB STATUS;
关键日志片段解析:
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 10795, ACTIVE 2 sec starting index read
-- 事务10795的操作:更新ID=2的账户
UPDATE account SET balance = balance + 100 WHERE id = 2;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 24 page no 4 n bits 72 index PRIMARY of table `test`.`account` trx id 10795 lock_mode X waiting
*** (2) TRANSACTION:
TRANSACTION 10794, ACTIVE 5 sec starting index read
-- 事务10794的操作:更新ID=1的账户
UPDATE account SET balance = balance + 200 WHERE id = 1;
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 24 page no 4 n bits 72 index PRIMARY of table `test`.`account` trx id 10794 lock_mode X locks rec but not gap
从日志中可获取:
- 死锁涉及的事务ID(10795、10794)
- 导致死锁的SQL语句(更新ID=1和ID=2的账户)
- 等待的锁类型(lock_mode X:排他锁)
3. 手动解除死锁:终止冲突事务
通过死锁日志找到冲突的事务ID后,用KILL命令终止事务(优先终止代价小的):
sql
-- 1. 查看所有运行中的事务(找到事务ID)
SELECT * FROM information_schema.INNODB_TRX;
-- 2. 终止事务(比如终止事务10795)
KILL 10795;
注意:终止前需确认事务对应的业务操作,避免误杀核心事务(如支付事务)。
策略三:优化设计------从架构层面降低风险
通过数据库配置、索引设计等优化,减少锁冲突的概率。
1. 合理设计索引(避免锁升级)
InnoDB的行锁是基于索引实现的,如果查询无索引,会触发全表扫描 ,导致行锁升级为表锁,大幅增加冲突。
反例 :如果按user_name更新但无索引,会锁全表:
sql
-- 无索引:全表扫描,锁所有行(表锁效果)
UPDATE account SET balance = balance - 100 WHERE user_name = '张三';
优化:给高频查询条件加索引:
sql
-- 给user_name添加索引,避免全表扫描
ALTER TABLE `account` ADD INDEX `idx_user_name` (`user_name`);
-- 优化后:仅锁user_name='张三'的行(行锁)
UPDATE account SET balance = balance - 100 WHERE user_name = '张三';
2. 降低事务隔离级别(减少锁范围)
MySQL默认隔离级别是REPEATABLE READ(可重复读),会启用间隙锁 和临键锁 (防止幻读),但也增加了锁冲突概率。
如果业务允许(比如不要求"可重复读"),可降低到READ COMMITTED(读已提交),此时间隙锁失效,锁范围缩小。
配置方式:
-
临时生效(当前会话):
sqlSET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -
永久生效(修改my.cnf配置文件):
ini[mysqld] transaction-isolation = READ-COMMITTED
适用场景:报表查询、非核心业务,无需严格的可重复读保证。
四、进阶拓展:应对复杂场景
1. 不同数据库的死锁差异
除了MySQL,其他主流数据库的死锁处理机制也有区别,实际工作中需针对性调整:
| 数据库 | 死锁检测机制 | 回滚策略 | 特殊点 |
|---|---|---|---|
| MySQL InnoDB | 每秒自动检测 | 回滚代价最小的事务 | 行锁基于索引,无索引表锁 |
| Oracle | 定期检测(默认开启) | 回滚消耗资源最少的事务 | 支持行级锁、表级锁 |
| SQL Server | 死锁监视器(后台线程) | 回滚"牺牲品"事务 | 可通过Profiler看死锁图 |
2. ORM框架下的死锁问题(以SQLAlchemy为例)
使用ORM框架时,可能因"自动机制"导致意外死锁,比如SQLAlchemy的autoflush(查询前自动刷新未提交的更新):
问题场景:查询任务时,ORM自动刷新未提交的用户余额更新,导致锁冲突:
python
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=engine)
with Session() as session:
# 1. 未提交的更新(持有用户表锁)
user = session.query(User).get(1)
user.balance -= 100 # 未提交
# 2. 查询任务时,autoflush自动刷新更新,触发锁冲突
task = session.query(Task).get(100) # 报错:死锁
解决方案 :只读查询时禁用autoflush:
python
with Session() as session:
# 禁用autoflush,查询不触发未提交更新
with session.no_autoflush():
task = session.query(Task).get(100)
# 后续手动提交更新
user = session.query(User).get(1)
user.balance -= 100
session.commit()
3. 死锁监控与报警
高并发系统中,需建立监控机制,及时发现死锁:
-
工具1:pt-deadlock-logger(Percona工具) :实时记录死锁事件:
bash# 监控10分钟,记录死锁到控制台 pt-deadlock-logger --user=root --password=123456 --host=localhost --run-time=10m -
工具2:Prometheus + Grafana :采集
innodb_deadlocks指标(MySQL的死锁次数),设置阈值报警(比如5分钟内死锁>3次触发告警)。
五、总结:死锁处理的核心原则
数据库死锁无法"完全避免",但通过"预防+检测+优化"的组合策略,可将其发生率降到极低:
- 优先预防:按固定顺序访问资源、用乐观锁、缩短事务,从源头减少冲突;
- 其次检测 :用
SHOW ENGINE INNODB STATUS排查死锁,定位问题SQL; - 最后优化:合理设计索引、降低隔离级别,从架构层面降低风险。
实际工作中,需结合业务场景选择方案:比如金融转账用"悲观锁+固定顺序"保证数据安全,电商库存用"乐观锁+重试"提升并发性能。记住:没有万能方案,只有适合业务的方案。