Skip to content

MySQL 死锁故障处理

死锁是 MySQL 数据库中常见的并发问题,会导致事务无法继续执行,影响系统的可用性和性能。本文将详细介绍 MySQL 死锁的定义、产生条件、常见原因、排查步骤、解决方案和最佳实践,帮助 DBA 快速定位和解决死锁问题,兼顾不同 MySQL 版本的特性差异。

死锁概述

什么是死锁

死锁是指两个或多个事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。当两个事务互相持有对方需要的锁,并且都不释放自己的锁时,就会发生死锁。

死锁的产生条件

死锁的产生必须同时满足以下四个条件(称为死锁的必要条件):

  1. 互斥条件:资源(锁)只能被一个事务占用
  2. 请求和保持条件:事务已经持有至少一个资源,同时又请求其他资源
  3. 不剥夺条件:事务已获得的资源(锁)在未使用完之前,不能被其他事务强行剥夺
  4. 循环等待条件:存在一个事务等待链,每个事务都在等待下一个事务持有的资源

死锁的影响

  • 事务回滚:MySQL 会自动检测死锁,并回滚其中一个事务,导致该事务失败
  • 性能下降:死锁会导致事务执行时间延长,影响系统吞吐量
  • 应用异常:应用程序需要处理死锁异常,增加了开发复杂度
  • 资源浪费:死锁会导致锁资源被长时间占用,无法释放

死锁的常见表现

  • 应用程序收到 "Deadlock found when trying to get lock; try restarting transaction" 错误
  • 事务执行时间突然延长
  • 数据库 CPU 使用率升高
  • 锁等待时间增加

死锁的常见原因

类别常见原因
事务设计问题事务顺序不一致、事务范围过大、长时间持有锁
SQL 语句问题全表扫描、大量并发更新同一行、非索引列更新
索引问题缺少索引、索引失效、索引设计不合理
隔离级别问题隔离级别过高(如 SERIALIZABLE)、MVCC 实现问题
并发问题高并发写入、热点数据竞争、连接池配置不合理
锁策略问题锁粒度不当、锁类型选择错误、锁超时设置不合理

死锁的排查步骤

查看死锁日志

使用 SHOW ENGINE INNODB STATUS

SHOW ENGINE INNODB STATUS 是查看死锁信息的最常用命令,可以显示最近一次死锁的详细信息。

sql
-- 查看 Innodb 状态,包含死锁信息
SHOW ENGINE INNODB STATUS\G

死锁信息通常包含在 LATEST DETECTED DEADLOCK 部分,包括:

  • 死锁发生的时间
  • 参与死锁的事务
  • 每个事务持有和等待的锁
  • 每个事务执行的 SQL 语句
  • 被回滚的事务

开启死锁日志记录

可以通过配置参数开启死锁日志记录,便于后续分析:

ini
[mysqld]
# 开启 Innodb 死锁日志
innodb_print_all_deadlocks = 1
# 日志输出格式
log_output = FILE
# 错误日志路径
log_error = /var/log/mysql/error.log

使用 Performance Schema

MySQL 5.6+ 引入了 Performance Schema,可以更详细地监控死锁情况:

sql
-- 查看死锁事件配置
SELECT * FROM performance_schema.setup_instruments WHERE name LIKE '%deadlock%';

-- 启用死锁事件监控
UPDATE performance_schema.setup_instruments SET enabled = 'YES' WHERE name LIKE '%deadlock%';

-- 查看死锁事件
SELECT * FROM performance_schema.events_deadlocks ORDER BY event_id DESC LIMIT 10;

-- 查看锁等待情况
SELECT * FROM performance_schema.data_locks WHERE lock_type = 'RECORD';
SELECT * FROM performance_schema.data_lock_waits;

使用 Sys Schema

Sys Schema 是基于 Performance Schema 和 Information Schema 的高级监控视图,可以更方便地查看死锁相关信息:

sql
-- 查看当前锁等待情况
SELECT * FROM sys.innodb_lock_waits;

-- 查看最近的死锁
SELECT * FROM sys.innodb_deadlocks ORDER BY latest_detected DESC LIMIT 5;

-- 查看锁等待最长的事务
SELECT * FROM sys.innodb_lock_waits ORDER BY wait_age_secs DESC LIMIT 5;

死锁自动诊断脚本

bash
#!/bin/bash

# MySQL 死锁自动诊断脚本

MYSQL_HOST="localhost"
MYSQL_USER="root"
MYSQL_PASS="password"
LOG_FILE="/var/log/mysql/deadlock_diagnosis.log"
DATE=$(date +"%Y-%m-%d %H:%M:%S")

# 初始化日志
echo "[$DATE] MySQL 死锁诊断开始" >> $LOG_FILE
echo "=================================" >> $LOG_FILE
echo "" >> $LOG_FILE

# 1. 死锁基本信息
echo "[$DATE] 1. 死锁基本信息" >> $LOG_FILE
echo "-------------------" >> $LOG_FILE
echo "死锁日志配置:" >> $LOG_FILE
mysql -u $MYSQL_USER -p$MYSQL_PASS -h $MYSQL_HOST -e "SHOW GLOBAL VARIABLES LIKE 'innodb_print_all_deadlocks'; SHOW GLOBAL VARIABLES LIKE 'innodb_deadlock_detect';" >> $LOG_FILE
echo "" >> $LOG_FILE

# 2. 最近死锁信息
echo "[$DATE] 2. 最近死锁信息" >> $LOG_FILE
echo "-------------------" >> $LOG_FILE
echo "InnoDB 最近死锁:" >> $LOG_FILE
mysql -u $MYSQL_USER -p$MYSQL_PASS -h $MYSQL_HOST -e "SHOW ENGINE INNODB STATUS\G" | grep -A 50 "LATEST DETECTED DEADLOCK" >> $LOG_FILE
echo "" >> $LOG_FILE

# 3. 当前锁等待情况
echo "[$DATE] 3. 当前锁等待情况" >> $LOG_FILE
echo "-------------------" >> $LOG_FILE
echo "当前锁等待事务:" >> $LOG_FILE
mysql -u $MYSQL_USER -p$MYSQL_PASS -h $MYSQL_HOST -e "SELECT * FROM sys.innodb_lock_waits LIMIT 10;" >> $LOG_FILE
echo "" >> $LOG_FILE

# 4. 锁统计信息
echo "[$DATE] 4. 锁统计信息" >> $LOG_FILE
echo "-------------------" >> $LOG_FILE
echo "锁等待事件统计:" >> $LOG_FILE
mysql -u $MYSQL_USER -p$MYSQL_PASS -h $MYSQL_HOST -e "SELECT * FROM performance_schema.events_waits_summary_global_by_event_name WHERE event_name LIKE '%lock%' ORDER BY count_star DESC LIMIT 10;" >> $LOG_FILE
echo "" >> $LOG_FILE

# 5. 事务统计信息
echo "[$DATE] 5. 事务统计信息" >> $LOG_FILE
echo "-------------------" >> $LOG_FILE
echo "长时间运行的事务:" >> $LOG_FILE
mysql -u $MYSQL_USER -p$MYSQL_PASS -h $MYSQL_HOST -e "SELECT * FROM sys.processlist WHERE command = 'Query' AND time > 60 ORDER BY time DESC;" >> $LOG_FILE
echo "" >> $LOG_FILE

echo "[$DATE] MySQL 死锁诊断结束" >> $LOG_FILE
echo "=================================" >> $LOG_FILE
echo "" >> $LOG_FILE

# 发送邮件通知(可选)
# if grep -q "LATEST DETECTED DEADLOCK" $LOG_FILE; then
#   cat $LOG_FILE | mail -s "MySQL 死锁告警" admin@example.com
# fi

echo "死锁诊断完成,日志已保存到 $LOG_FILE"

分析死锁日志

分析死锁日志时,需要关注以下几个方面:

  1. 参与死锁的事务:事务 ID、事务状态、事务执行时间
  2. 持有和等待的锁:锁类型(行锁、表锁)、锁模式(共享锁、排他锁)、锁对象(表名、索引名、行记录)
  3. 执行的 SQL 语句:导致死锁的具体 SQL
  4. 事务隔离级别:事务使用的隔离级别
  5. 被回滚的事务:MySQL 选择回滚的事务

定位死锁原因

根据死锁日志分析,定位死锁的具体原因:

  1. 事务顺序问题:检查事务是否以不同顺序访问相同的资源
  2. 锁粒度问题:检查是否使用了过大的锁粒度
  3. 索引问题:检查是否缺少索引或索引失效
  4. 事务范围问题:检查事务是否包含过多操作
  5. 并发问题:检查是否存在高并发写入热点

死锁的解决方案

优化事务设计

保持事务短小

  • 减少事务包含的操作数量
  • 避免在事务中执行耗时操作(如网络请求、文件 I/O)
  • 及时提交或回滚事务
sql
-- 不推荐:长事务
START TRANSACTION;
UPDATE table1 SET column1 = value1 WHERE id = 1;
-- 执行耗时操作
UPDATE table2 SET column2 = value2 WHERE id = 2;
COMMIT;

-- 推荐:短事务
START TRANSACTION;
UPDATE table1 SET column1 = value1 WHERE id = 1;
COMMIT;

-- 执行耗时操作

START TRANSACTION;
UPDATE table2 SET column2 = value2 WHERE id = 2;
COMMIT;

保持事务访问顺序一致

确保所有事务以相同的顺序访问资源,避免循环等待。

sql
-- 事务 1(推荐顺序)
START TRANSACTION;
UPDATE table1 SET column1 = value1 WHERE id = 1;
UPDATE table2 SET column2 = value2 WHERE id = 2;
COMMIT;

-- 事务 2(推荐顺序,与事务 1 一致)
START TRANSACTION;
UPDATE table1 SET column1 = value3 WHERE id = 1;
UPDATE table2 SET column2 = value4 WHERE id = 2;
COMMIT;

-- 不推荐:与事务 1 顺序不一致
START TRANSACTION;
UPDATE table2 SET column2 = value4 WHERE id = 2;
UPDATE table1 SET column1 = value3 WHERE id = 1;
COMMIT;

避免在事务中使用 SELECT ... FOR UPDATE

除非必要,否则避免使用 SELECT ... FOR UPDATE,因为它会持有排他锁,增加死锁风险。

sql
-- 不推荐:无必要的 FOR UPDATE
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 其他操作
COMMIT;

-- 推荐:使用普通 SELECT
START TRANSACTION;
SELECT * FROM orders WHERE id = 1;
-- 其他操作
COMMIT;

优化 SQL 语句

确保所有更新操作都使用索引

缺少索引会导致全表扫描,增加锁的范围,提高死锁风险。

sql
-- 不推荐:非索引列更新,导致全表扫描
UPDATE orders SET status = 'completed' WHERE order_date > '2023-01-01';

-- 推荐:为 order_date 添加索引
CREATE INDEX idx_orders_order_date ON orders(order_date);
UPDATE orders SET status = 'completed' WHERE order_date > '2023-01-01';

避免在同一事务中更新大量行

更新大量行会持有大量锁,增加死锁风险。

sql
-- 不推荐:同一事务更新大量行
START TRANSACTION;
UPDATE orders SET status = 'completed' WHERE order_date < '2022-01-01';
COMMIT;

-- 推荐:分批更新
DELIMITER //
CREATE PROCEDURE batch_update_orders()
BEGIN
    DECLARE done INT DEFAULT FALSE;
    DECLARE v_id INT;
    DECLARE cur CURSOR FOR SELECT id FROM orders WHERE order_date < '2022-01-01' ORDER BY id;
    DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
    
    OPEN cur;
    
    read_loop: LOOP
        FETCH cur INTO v_id;
        IF done THEN
            LEAVE read_loop;
        END IF;
        
        -- 每次只更新一行或少量行
        UPDATE orders SET status = 'completed' WHERE id = v_id;
        
        -- 每处理 100 行提交一次事务
        IF MOD(v_id, 100) = 0 THEN
            COMMIT;
            START TRANSACTION;
        END IF;
    END LOOP;
    
    CLOSE cur;
    COMMIT;
END //
DELIMITER ;

-- 调用存储过程
CALL batch_update_orders();

调整锁策略

选择合适的隔离级别

不同的隔离级别会影响锁的行为,选择合适的隔离级别可以减少死锁风险。

隔离级别死锁风险数据一致性适用场景
READ UNCOMMITTED只读查询,对一致性要求不高
READ COMMITTED大多数 OLTP 应用
REPEATABLE READ对一致性要求较高的应用
SERIALIZABLE最高最高对一致性要求极高的应用

MySQL 默认的隔离级别是 REPEATABLE READ,可以根据业务需求调整为 READ COMMITTED,减少死锁风险。

sql
-- 临时调整隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 永久调整隔离级别(在配置文件中)
[mysqld]
transaction-isolation = READ-COMMITTED

调整锁等待超时

合理设置锁等待超时时间,可以避免事务长时间等待锁资源。

sql
-- 查看当前锁等待超时设置
SHOW GLOBAL VARIABLES LIKE 'innodb_lock_wait_timeout';

-- 临时调整锁等待超时
SET GLOBAL innodb_lock_wait_timeout = 30;

-- 永久调整锁等待超时(在配置文件中)
[mysqld]
innodb_lock_wait_timeout = 30

优化索引设计

为常用查询条件添加索引

缺少索引会导致全表扫描,增加锁的范围,提高死锁风险。

优化复合索引顺序

将选择性高的列放在复合索引的前面,可以减少锁的范围。

避免冗余索引

冗余索引会增加锁的持有时间和范围,提高死锁风险。

应用层优化

实现重试机制

应用程序可以捕获死锁异常,并实现重试机制,提高系统的容错能力。

java
// Java 示例:死锁重试机制
public void executeWithRetry(Runnable task, int maxRetries) {
    int retries = 0;
    while (retries < maxRetries) {
        try {
            task.run();
            return;
        } catch (SQLException e) {
            if (e.getMessage().contains("Deadlock found") || e.getErrorCode() == 1213) {
                retries++;
                if (retries >= maxRetries) {
                    throw e;
                }
                // 等待一段时间后重试,使用指数退避策略
                Thread.sleep(100 * (int) Math.pow(2, retries));
            } else {
                throw e;
            }
        }
    }
}

版本差异与特性

MySQL 5.6

  • 基础的死锁检测和处理机制
  • 支持 SHOW ENGINE INNODB STATUS 查看死锁信息
  • 支持 innodb_lock_wait_timeout 配置
  • 不支持 innodb_print_all_deadlocks 配置(需手动开启)
  • 有限的 Performance Schema 支持,死锁监控能力较弱
  • 不支持 sys schema

MySQL 5.7

  • 增强的死锁检测机制
  • 支持 innodb_print_all_deadlocks 配置,自动记录所有死锁
  • 增强的 Performance Schema,支持更详细的死锁监控
  • 引入 sys schema,提供更简单的死锁分析视图
  • 支持 innodb_deadlock_detect 配置,可禁用死锁检测
  • 支持更多的锁等待监控指标

MySQL 8.0

  • 增强的死锁检测和处理能力
  • 支持 innodb_deadlock_detect 动态配置
  • 增强的 Performance Schema,提供更详细的死锁事件监控
  • 增强的 sys schema,提供更多死锁诊断视图
  • 支持 innodb_lock_wait_timeout 会话级配置
  • 支持 SELECT ... FOR UPDATE SKIP LOCKED 语法,避免锁等待
  • 支持 SELECT ... FOR UPDATE NOWAIT 语法,立即返回锁等待错误

死锁的监控和管理

建立死锁监控机制

  • 使用内置监控工具:Performance Schema、Sys Schema
  • 使用第三方监控工具
    • Prometheus + Grafana:提供死锁监控指标和可视化仪表盘
    • Zabbix:通过插件监控死锁
    • Nagios:通过插件监控死锁
    • MySQL Enterprise Monitor:企业级监控解决方案

设置死锁告警

  • 监控死锁发生的频率
  • 当死锁频率超过阈值时触发告警
  • 配置告警通知方式(邮件、短信、微信等)
  • 建立告警处理流程

定期分析死锁日志

  • 定期分析死锁日志,找出死锁的根本原因
  • 制定相应的优化措施
  • 跟踪优化效果,持续改进

死锁处理的最佳实践

预防死锁

  1. 保持事务短小:减少事务持有锁的时间
  2. 保持事务访问顺序一致:避免循环等待
  3. 使用合适的隔离级别:根据业务需求选择隔离级别
  4. 确保所有更新操作都使用索引:减少锁的范围
  5. 避免在事务中执行耗时操作:减少锁的持有时间
  6. 使用行级锁而非表级锁:减少锁的范围
  7. 实现重试机制:提高系统容错能力
  8. 避免热点数据竞争:使用分片、缓存等方式分散热点

检测死锁

  1. 开启死锁日志:记录死锁信息
  2. 使用 Performance Schema:监控死锁事件
  3. 使用 Sys Schema:查看锁等待情况
  4. 设置死锁告警:及时发现死锁问题
  5. 定期运行死锁诊断脚本:自动收集和分析死锁信息

解决死锁

  1. 分析死锁日志:找出死锁的根本原因
  2. 优化事务设计:保持事务短小,访问顺序一致
  3. 优化 SQL 语句和索引:确保使用索引,减少锁的范围
  4. 选择合适的锁策略:根据业务需求调整隔离级别
  5. 实现重试机制:提高系统容错能力
  6. 调整应用逻辑:避免并发访问热点数据

案例分析

事务顺序不一致导致的死锁

问题描述: 电商平台的订单系统中,两个事务同时处理订单和库存,导致死锁:

事务 1:

sql
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE id = 1;
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 1;
COMMIT;

事务 2:

sql
START TRANSACTION;
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 1;
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT;

死锁原因: 两个事务以不同的顺序访问 ordersinventory 表,导致循环等待。

解决方案: 修改事务顺序,确保所有事务以相同的顺序访问资源:

sql
-- 所有事务都先更新 orders 表,再更新 inventory 表
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE id = 1;
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 1;
COMMIT;

优化效果

  • 死锁发生率降低 95%
  • 事务执行时间稳定
  • 系统吞吐量提高

缺少索引导致的死锁

问题描述: 某系统中,大量并发更新 logs 表,导致死锁频繁发生。

sql
UPDATE logs SET status = 'processed' WHERE create_time < '2023-01-01';

死锁原因create_time 字段没有索引,导致全表扫描,每个事务都持有大量锁,容易发生死锁。

解决方案: 为 create_time 字段添加索引:

sql
CREATE INDEX idx_logs_create_time ON logs(create_time);

优化效果

  • 死锁发生率降低 90%
  • 事务执行时间从平均 5 秒降低到 0.1 秒
  • 系统吞吐量提高 5 倍

长事务导致的死锁

问题描述: 某报表系统中,长事务持有锁时间过长,导致其他事务无法执行,发生死锁。

sql
START TRANSACTION;
-- 执行复杂查询
SELECT * FROM orders WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31';
-- 生成报表(耗时操作)
UPDATE reports SET status = 'generated' WHERE id = 1;
COMMIT;

死锁原因: 事务包含耗时的报表生成操作,持有锁时间过长,导致其他事务等待。

解决方案: 将长事务拆分为短事务:

sql
-- 短事务 1:执行查询
START TRANSACTION;
SELECT * INTO #temp_orders FROM orders WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31';
COMMIT;

-- 生成报表(耗时操作)

-- 短事务 2:更新报表状态
START TRANSACTION;
UPDATE reports SET status = 'generated' WHERE id = 1;
COMMIT;

总结

死锁是 MySQL 数据库中常见的并发问题,处理死锁需要综合考虑事务设计、SQL 语句、索引设计、锁策略等多个方面。通过本文的介绍,DBA 可以掌握死锁的定义、产生条件、常见原因、排查步骤、解决方案和最佳实践。

在实际运维中,DBA 应该:

  1. 建立完善的死锁监控机制:及时发现和处理死锁
  2. 定期分析死锁日志:找出死锁的根本原因
  3. 优化事务设计:保持事务短小,访问顺序一致
  4. 优化 SQL 语句和索引:确保使用索引,减少锁的范围
  5. 选择合适的锁策略:根据业务需求调整隔离级别
  6. 实现重试机制:提高系统容错能力
  7. 持续改进:根据业务变化调整死锁处理策略

通过不断优化和改进,可以显著降低死锁发生率,提高系统的可用性和性能。不同 MySQL 版本在死锁处理方面有不同的特性和工具,DBA 需要根据实际使用的版本选择合适的死锁处理策略。