数据库死锁(Deadlock)分析与解决方案
数据库死锁是两个或多个事务相互等待对方释放锁而导致的永久阻塞。它像一场公路上的车辆僵局,每个司机都在等别人先让路。解决死锁的关键在于快速诊断、预防发生、发生后备选方案。
bG9pajNqLmNvbQ== # jo.wrwi2m.cn#gjasp?gsgjop-kk#asd
🔍 第一步:死锁分析(当死锁发生时)
当应用收到 Deadlock found when trying to get lock 或 1213 错误时,按以下步骤分析。
bG9pajNqLmNvbQ== # za.yspr6r.cn#gjasp?gsgjop-kk#asd
1. 查看数据库死锁日志
bG9pajNqLmNvbQ== # tp.wrwi2m.cn#gjasp?gsgjop-kk#asd
不同数据库查看方式不同,但核心都是获取事务和锁的详细信息。
- MySQL (InnoDB):
-- 查看最近的死锁信息 SHOW ENGINE INNODB STATUS\G;
bG9pajNqLmNvbQ== # ho.wrwi2m.cn#gjasp?gsgjop-kk#asd
在输出中找到 LATEST DETECTED DEADLOCK 部分,它会详细记录:
* 事务1和事务2 正在执行的SQL语句。
* 每个事务已持有的锁(holds lock)。
* 每个事务正在等待的锁(waits for)。
* 数据库选择回滚哪个事务作为牺牲品(WE ROLL BACK TRANSACTION)。
- PostgreSQL:检查日志文件(需确保
log_lock_waits和deadlock_timeout已配置)。 - SQL Server:使用系统视图或SQL Profiler跟踪死锁图。
bG9pajNqLmNvbQ== # pr.yspr6r.cn#gjasp?gsgjop-kk#asd
bG9pajNqLmNvbQ== # ds.yspr6r.cn#gjasp?gsgjop-kk#asd
2. 解读死锁日志(以MySQL为例)
一个典型的死锁日志会包含类似以下信息:
bG9pajNqLmNvbQ== # ol.wrwi2m.cn#gjasp?gsgjop-kk#asd
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 100, OS thread handle 0x..., query id 2000 localhost root updating
UPDATE account SET balance = balance - 100 WHERE id = 1 -- 事务A正在执行的SQL
*** (1) HOLDS THE LOCK(S): ... -- 事务A已经持有了哪些锁
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: ... -- 事务A在等待哪个锁
*** (2) TRANSACTION:
TRANSACTION 67890, ACTIVE 3 sec starting index read
UPDATE account SET balance = balance + 100 WHERE id = 2 -- 事务B正在执行的SQL
*** (2) HOLDS THE LOCK(S): ... -- 事务B已经持有了哪些锁
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: ... -- 事务B在等待哪个锁
*** WE ROLL BACK TRANSACTION (2) -- 数据库决定回滚事务(2)
关键分析:对比 HOLDS THE LOCK(S) 和 WAITING FOR THIS LOCK,找出循环等待的锁资源(例如,事务A持有id=1的锁等待id=2,事务B持有id=2的锁等待id=1)。
⚙️ 第二步:解决方案与最佳实践
bG9pajNqLmNvbQ== # ne.wrwi2m.cn#gjasp?gsgjop-kk#asd
死锁无法完全避免,但可以通过以下策略大幅降低其发生概率和影响。
1. 应用层设计:打破死锁产生的必要条件
bG9pajNqLmNvbQ== # xw.yspr6r.cn#gjasp?gsgjop-kk#asd
| 策略 | 具体做法 | 原理与效果 |
|---|---|---|
| 固定访问顺序 | 约定所有业务逻辑中,总是按相同的顺序访问多个资源(例如,总是先操作id小的记录,再操作id大的)。 | 这是最有效的预防策略,从根源上消除“循环等待”条件。 |
| 减少事务持有锁的时间 | - 将非必要的查询移出事务(如查询校验)。 - 尽快提交或回滚事务,避免在事务内进行远程调用或长时间计算。 - 将大事务拆分为多个小事务。 |
减少锁的竞争窗口,降低死锁概率。 |
| 使用低隔离级别 | 在业务允许的情况下,使用 READ COMMITTED 而非 REPEATABLE READ。 |
减少锁的范围和数量(在MySQL中,可减少间隙锁的使用)。 |
| 使用乐观锁 | 为数据表增加 version 字段,更新时检查版本号。更新失败时由应用层重试。 |
完全避免使用数据库悲观锁,从而杜绝死锁。 |
| 设置锁等待超时 | 设置 innodb_lock_wait_timeout(MySQL),超时后自动回滚。 |
避免线程永久阻塞,但无法解决死锁本身,需配合重试。 |
bG9pajNqLmNvbQ== # hg.wrwi2m.cn#gjasp?gsgjop-kk#asd
2. 数据库层优化
| 策略 | 具体做法 |
|---|---|
| 为查询添加合适的索引 | 确保 UPDATE、DELETE 语句的 WHERE 条件都走索引,否则会锁表,极易死锁。使用 EXPLAIN 检查执行计划。 |
| 避免全表扫描 | 和上一条同理,没有索引的写操作是死锁的温床。 |
| 控制事务隔离级别 | 理解不同隔离级别的锁行为(特别是MySQL的间隙锁),根据业务场景选择最低可用级别。 |
bG9pajNqLmNvbQ== # jm.wrwi2m.cn#gjasp?gsgjop-kk#asd
3. 降级方案:当死锁不可避免时
- 优雅的重试机制:
关键:重试前等待一个随机时间,避免多个事务同时重试再次死锁。int maxRetries = 3; for (int i = 0; i < maxRetries; i++) { try { // 执行业务事务 return executeBusinessTransaction(); } catch (DeadlockException e) { // 捕获死锁异常 if (i == maxRetries - 1) throw e; // 重试次数用尽,向上抛出 Thread.sleep((int) (Math.random() * 100)); // 随机退避等待 // 记录日志,进行重试 logger.warn("检测到死锁,开始第 {} 次重试", i + 1); } } - 监控与告警:
- 监控数据库死锁次数(
SHOW STATUS LIKE 'innodb_row_lock%')。 - 设置阈值告警,当死锁频率超过正常范围时,需要人工介入分析根本原因。
- 监控数据库死锁次数(
bG9pajNqLmNvbQ== # ou.wrwi2m.cn#gjasp?gsgjop-kk#asd
📋 死锁排查与解决清单
当发生死锁时,按此清单操作:
bG9pajNqLmNvbQ== # bh.wrwi2m.cn#gjasp?gsgjop-kk#asd
- 立即响应:从应用日志中捕获死锁错误信息和事务上下文。
bG9pajNqLmNvbQ== # cz.wrwi2m.cn#gjasp?gsgjop-kk#asd - 分析原因:查看数据库死锁日志,定位冲突的SQL和资源。
bG9pajNqLmNvbQ== # tk.wrwi2m.cn#gjasp?gsgjop-kk#asd - 短期修复:
bG9pajNqLmNvbQ== # xm.wrwi2m.cn#gjasp?gsgjop-kk#asd- 是否为相关SQL添加了必要索引?
- 是否可以调整事务边界,缩短持有锁的时间?
- 在应用层为特定业务操作添加临时重试逻辑。
bG9pajNqLmNvbQ== # mj.wrwi2m.cn#gjasp?gsgjop-kk#asd
- 长期根治:
bG9pajNqLmNvbQ== # iu.wrwi2m.cn#gjasp?gsgjop-kk#asd- 审查代码,统一相同数据的访问顺序。
- 评估并引入乐观锁机制。
- 优化事务设计,避免大事务。
bG9pajNqLmNvbQ== # cb.wrwi2m.cn#gjasp?gsgjop-kk#asd
- 建立监控:将死锁指标纳入监控,持续观察优化效果。
bG9pajNqLmNvbQ== # av.yspr6r.cn#gjasp?gsgjop-kk#asd
处理死锁的哲学是:预防优于检测,检测优于处理。在系统设计阶段就通过固定访问顺序、使用乐观锁、优化索引来预防;在运行时通过监控和日志快速检测;发生时通过数据库自动回滚和应用层重试来最小化影响。
浙公网安备 33010602011771号