数据库死锁了,咋么办?

在高并发业务场景中,比如电商订单支付、金融转账、秒杀活动,你可能遇到过这样的报错:Deadlock found when trying to get lock; try restarting transaction。这就是数据库死锁------两个或多个事务像"互相卡脖子"一样,都等着对方释放资源,最终陷入无限等待。今天我们从死锁的本质出发,通过实战案例讲解复现方法、全维度解决方案,以及进阶优化技巧,帮你彻底搞定死锁问题。

一、先搞懂:数据库死锁是什么?

死锁的核心是"循环等待",但它的发生需要满足四个必要条件(缺一不可),我们用"两个人抢餐具吃饭"的场景通俗理解:

  1. 互斥条件:同一资源只能被一个事务占用。比如一双筷子只能被一个人拿,不能两个人同时用。
  2. 请求与保持条件:事务持有一个资源的同时,又请求另一个被占用的资源。比如甲拿了筷子,还想要乙手里的碗。
  3. 不剥夺条件:已占用的资源不能被强行抢走。比如甲拿了筷子,乙不能直接抢过来。
  4. 循环等待条件:多个事务形成"你等我、我等你"的环形依赖。比如甲等乙的碗,乙等甲的筷子。

只要破坏其中一个条件,死锁就不会发生。这也是我们后续解决方案的核心思路。

二、实战:死锁如何复现?(以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(读已提交),此时间隙锁失效,锁范围缩小。

配置方式

  • 临时生效(当前会话):

    sql 复制代码
    SET 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次触发告警)。

五、总结:死锁处理的核心原则

数据库死锁无法"完全避免",但通过"预防+检测+优化"的组合策略,可将其发生率降到极低:

  1. 优先预防:按固定顺序访问资源、用乐观锁、缩短事务,从源头减少冲突;
  2. 其次检测 :用SHOW ENGINE INNODB STATUS排查死锁,定位问题SQL;
  3. 最后优化:合理设计索引、降低隔离级别,从架构层面降低风险。

实际工作中,需结合业务场景选择方案:比如金融转账用"悲观锁+固定顺序"保证数据安全,电商库存用"乐观锁+重试"提升并发性能。记住:没有万能方案,只有适合业务的方案。

相关推荐
光羽隹衡3 小时前
MySQL的安装
数据库·mysql
脸大是真的好~3 小时前
尚硅谷-mysql专项训练-数据库服务的优化-慢查询-EXPLAIN字段
数据库·mysql·性能优化
Dragon online3 小时前
数据分析师成长之路--从SQL恐惧到数据掌控者的蜕变
数据库·sql
VX:Fegn08954 小时前
计算机毕业设计|基于springboot + vue音乐管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·课程设计
一招定胜负4 小时前
navicat连接数据库&mysql常见语句及操作
数据库·mysql
热心市民蟹不肉4 小时前
黑盒漏洞扫描(三)
数据库·redis·安全·缓存
chian_ocean4 小时前
openEuler集群 Chrony 时间同步实战:从零构建高精度分布式时钟体系
数据库
Databend5 小时前
构建海量记忆:基于 Databend 的 2C Agent 平台 | 沉浸式翻译 @ Databend meetup 上海站回顾及思考
数据库
αSIM0V5 小时前
数据库期末重点
数据库·软件工程
2301_800256115 小时前
【第九章知识点总结1】9.1 Motivation and use cases 9.2 Conceptual model
java·前端·数据库