生产环境 MySQL 死锁:定位思路与根治方案
MySQL死锁是数据库运维和后端开发中最棘手的问题之一。与普通查询超时不同,死锁意味着两个或多个事务相互持有对方需要的锁,形成循环依赖,导致涉及的表或行无法被任何事务继续修改。业务系统一旦出现死锁,轻则部分请求报错,重则整个业务链路的写操作集体阻塞。
本文从死锁的形成原理出发,系统讲解如何排查、分析和解决MySQL死锁问题。内容适用于MySQL 5.7/8.0及兼容版本(MySQL 8.0在锁机制上有部分改进)。
1. 死锁的形成原理
1.1 事务与锁的基本概念
MySQL的InnoDB引擎采用行级锁(Row Lock)实现并发控制。事务在对某行数据进行修改时,会对该行加锁,直到事务提交(COMMIT)或回滚(ROLLBACK)时才释放锁。
-- 事务A:先锁定id=1的行 BEGIN; SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 对id=1加排他锁 -- 此时事务A持有id=1的锁,等待事务B释放id=2的锁 -- 事务B:先锁定id=2的行 BEGIN; SELECT * FROM accounts WHERE id = 2 FOR UPDATE; -- 对id=2加排他锁 -- 此时事务B持有id=2的锁,等待事务A释放id=1的锁 -- 循环等待形成:事务A等事务B,事务B等事务A → 死锁
1.2 死锁的必要条件
数据库理论中,死锁的形成必须满足以下四个条件(Carl-RoadConditions):
| 条件 | 含义 | 在MySQL中的体现 |
|---|---|---|
| 互斥条件 | 资源不能被共享 | 一行数据同一时刻只能被一个事务持有排他锁 |
| 持有并等待 | 事务持有资源的同时请求其他资源 | 事务A持有id=1锁,等待id=2锁 |
| 不可抢占条件 | 资源不能被强制释放 | 锁只能被持有事务显式释放,不能被其他事务抢走 |
| 循环等待条件 | 形成事务间的等待循环 | 事务A等事务B,事务B等事务A |
MySQL的InnoDB引擎通过死锁检测(Deadlock Detection)来打破循环等待:当检测到死锁后,会主动回滚代价最小的事务(通常是持有最少行锁的事务),让其他事务继续执行。
1.3 锁的类型与兼容性
InnoDB的锁类型远比表面上复杂:
| 锁类型 | 模式 | 兼容性 | 说明 |
|---|---|---|---|
| 共享锁(S) | SELECT ... LOCK IN SHARE MODE | 与S锁兼容,与X锁互斥 | 读取时不阻止其他读 |
| 排他锁(X) | SELECT ... FOR UPDATE | 与S锁、X锁均互斥 | 写入时锁定整行 |
| 记录锁(Record Lock) | 索引记录 | 锁定单个索引记录 | 最常见的行锁 |
| 间隙锁(Gap Lock) | 范围查询时 | 锁定区间而非记录 | 防止幻读 |
| Next-Key Lock | 记录锁+间隙锁 | 锁定记录及其区间 | InnoDB默认的RR隔离级别锁 |
| 意向锁(Intention Lock) | 表级锁 | 表上的IX/IS锁 | 表示事务将在表上加行级锁 |
Next-Key Lock是死锁的高发区:当执行范围查询(如WHERE id > 10 AND id < 20)时,Next-Key Lock会锁定(10, 20)这个间隙,如果另一个事务试图插入这个范围内的记录,会被阻塞,长期积累可能导致死锁。
-- 事务A:锁定id > 10的所有行(实际锁定区间10到正无穷) BEGIN; SELECT * FROM orders WHERE user_id > 100 FOR UPDATE; -- Next-Key Lock锁定区间 (100, +∞) -- 事务B:插入id=101的新订单(尝试获取插入意向锁) BEGIN; INSERT INTO orders (id, user_id, amount) VALUES (NULL, 101, 100); -- 被事务A的Next-Key Lock阻塞:Gap Lock冲突 -- 事务A再执行:插入id=102的新订单 INSERT INTO orders (id, user_id, amount) VALUES (NULL, 102, 200); -- 尝试获取插入意向锁,但事务B已经持有id=102的Gap锁 -- 死锁形成
2. 死锁的排查方法
2.1 开启死锁日志
MySQL默认将死锁信息记录到错误日志,但不会记录每次死锁的完整锁等待图。可以通过以下方式增强日志:
-- 查看当前死锁日志配置
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks'; -- 默认OFF
-- 开启所有死锁信息输出到错误日志(需要SUPER权限)
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 查看死锁日志(MySQL错误日志文件)
-- Linux: /var/log/mysql/error.log
-- macOS Homebrew: /usr/local/var/mysql/{hostname}.err
-- Windows: {数据目录}mysql*.err
innodb_print_all_deadlocks = ON 会将每次死锁的完整信息输出到错误日志,包括涉及的事务、SQL语句、持有的锁和等待的锁。
2.2 使用information_schema获取锁信息
-- 查看当前所有事务持有的锁 SELECT t.trx_id, t.trx_state, t.trx_started, t.trx_rows_locked, t.trx_query, l.lock_id, l.lock_mode, l.lock_type, l.lock_table, l.lock_index, l.lock_space, l.lock_page, l.lock_rec, l.lock_data FROM information_schema.INNODB_TRX t JOIN information_schema.INNODB_LOCKS l ON t.trx_id = l.lock_trx_id ORDER BY t.trx_started; -- 查看锁等待关系 SELECT requesting_trx.trx_id AS requesting_trx_id, requesting_trx.trx_query AS requesting_query, blocking_trx.trx_id AS blocking_trx_id, blocking_trx.trx_query AS blocking_query, blocking_locks.lock_id AS blocking_lock_id, blocking_locks.lock_mode AS blocking_lock_mode, blocking_locks.lock_type AS blocking_lock_type, blocking_locks.lock_table AS blocking_lock_table FROM information_schema.INNODB_LOCK_WAITS lw JOIN information_schema.INNODB_TRX requesting_trx ON lw.requesting_trx_id = requesting_trx.trx_id JOIN information_schema.INNODB_TRX blocking_trx ON lw.blocking_trx_id = blocking_trx.trx_id JOIN information_schema.INNODB_LOCKS blocking_locks ON lw.blocking_lock_id = blocking_locks.lock_id;
2.3 使用performance_schema监控锁事件
MySQL 8.0引入了更强大的performance_schema锁监控:
-- 开启锁监控(需要重启或重新配置) UPDATE performance_schema.setup_instruments SET ENABLED = 'YES', TIMED = 'YES' WHERE NAME LIKE 'wait/lock%'; UPDATE performance_schema.setup_consumers SET ENABLED = 'YES' WHERE NAME LIKE '%events_transactions%'; -- 查看最近的锁等待事件 SELECT * FROM performance_schema.events_waits_history_long WHERE event_name LIKE '%lock%' ORDER BY TIMER_END DESC LIMIT 20;
2.4 解读死锁日志
开启innodb_print_all_deadlocks后,错误日志会输出类似以下内容的死锁报告:
2025-04-27 1045 0x7f8c9a4c8700 INNODB MONITOR OUTPUT ======================== LATEST DETECTED DEADLOCK ------------------------ 2025-04-27 1042 0x7f8c9a4c8700 *** (1) TRANSACTION: TRANSACTION 12345, ACTIVE 5 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 99, OS thread handle 0x7f8c9a4c8700, query id 10001 localhost root updating -- 事务1正在执行的SQL UPDATE orders SET status = 'shipped' WHERE user_id > 100 *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 123 page no 5 n bits 200 index idx_user_id of table `shop`.`orders` trx id 12345 lock_mode X locks rec but not gap -- 事务1持有orders表中idx_user_id索引上的记录锁 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 123 page no 5 n bits 200 index idx_user_id of table `shop`.`orders` trx id 12345 lock_mode X locks rec but not gap waiting -- 事务1正在等待另一个记录锁(可能是Gap锁冲突) *** (2) TRANSACTION: TRANSACTION 12346, ACTIVE 3 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 100, OS thread handle 0x7f8c9a4c8800, query id 10002 localhost root updating -- 事务2正在执行的SQL INSERT INTO orders (id, user_id, amount) VALUES (NULL, 105, 299.00) *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 123 page no 5 n bits 200 index idx_user_id of table `shop`.`orders` trx id 12346 lock_mode X locks gap before rec -- 事务2持有Gap锁(锁定区间) *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 123 page no 5 n bits 200 index idx_user_id of table `shop`.`orders` trx id 12346 lock_mode X locks rec but not gap waiting -- 事务2正在等待记录锁 -- MySQL决定回滚事务12346(较晚开始,持有锁较少) *** WE ROLL BACK TRANSACTION 12346
关键解读点:
LOCK WAIT 表示当前正在等待锁
HOLDS THE LOCK(S) 表示事务已持有的锁
WE ROLL BACK TRANSACTION 后面是MySQL决定回滚的事务ID
lock_mode X locks rec but not gap 是记录锁,不锁定间隙
lock_mode X locks gap before rec 是间隙锁,锁定记录前的区间
3. 常见死锁场景与解决方案
3.1 场景一:不同事务以不同顺序访问多行
问题:事务A先锁定行1再锁定行2,事务B先锁定行2再锁定行1,形成循环等待。
解决:确保所有事务以相同顺序访问资源。
# 错误的并发写入(死锁高发)
def transfer_funds_wrong(from_id, to_id, amount):
with connection.cursor() as cursor:
# 事务1: A->B, 事务2: B->A → 死锁
cursor.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE", (from_id,))
cursor.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE", (to_id,))
# 正确的并发写入(顺序加锁)
def transfer_funds_correct(from_id, to_id, amount):
with connection.cursor() as cursor:
# 按ID顺序加锁,避免循环等待
first_id, second_id = (from_id, to_id) if from_id < to_id else (to_id, from_id)
cursor.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE", (first_id,))
cursor.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE", (second_id,))
3.2 场景二:索引导致的间隙锁冲突
问题:范围查询或使用索引范围扫描时,Next-Key Lock锁定较宽区间,导致插入操作被阻塞。
解决:
使用覆盖索引减少锁范围:覆盖索引(Covering Index)可以让查询只需扫描索引,不必回表,减少锁定的记录数。
-- 创建覆盖索引:查询只需扫描idx_user_id,无需回表锁定主键 CREATE INDEX idx_user_id_covering ON orders(user_id, status, amount); -- 改写查询使用覆盖索引 SELECT status, amount FROM orders WHERE user_id = 100;
调整隔离级别:将隔离级别从REPEATABLE READ降为READ COMMITTED,可以减少Gap Lock的使用。
-- 方法1:会话级别调整 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 方法2:配置文件永久调整(my.cnf / my.ini) -- [mysqld] -- transaction-isolation = READ-COMMITTED
3.3 场景三:主从延迟导致的锁等待升级
问题:主从架构中,从库应用事件存在延迟,主库上的长事务持有锁时间延长,增加死锁概率。
解决:
-- 检查从库延迟 SHOW SLAVE STATUSG -- 关注 Seconds_Behind_Master 字段 -- 优化从库应用速率 STOP SLAVE; CHANGE MASTER TO MASTER_RETRY_COUNT = 3; START SLAVE;
3.4 场景四:大事务拆分
问题:单个事务中处理过多数据,持有锁的时间过长,死锁窗口扩大。
解决:将大事务拆分为小批量事务,减少单次持有的锁数量。
# 错误:单事务处理10万条记录
def batch_update_wrong(ids):
with connection.cursor() as cursor:
cursor.execute("BEGIN")
for id in ids: # 10万次循环,锁持有时间长
cursor.execute(
"UPDATE orders SET status = 'processed' WHERE id = %s",
(id,)
)
cursor.execute("COMMIT")
# 正确:分批处理,每批500条
def batch_update_correct(ids, batch_size=500):
with connection.cursor() as cursor:
for i in range(0, len(ids), batch_size):
batch = ids[i:i + batch_size]
cursor.execute("BEGIN")
cursor.execute(
"UPDATE orders SET status = 'processed' WHERE id IN (%s)" %
",".join(["%s"] * len(batch)),
batch
)
cursor.execute("COMMIT")
connection.commit() # 每批后立即释放锁
4. 代码层面的防死锁设计
4.1 应用层锁顺序控制
在应用层维护一个全局锁顺序规则:
import threading
# 定义全局锁顺序:按资源ID排序
# 所有需要同时锁定多个资源的代码,必须按此顺序获取锁
LOCK_ORDER = {}
class AccountService:
def __init__(self, db_connection):
self.conn = db_connection
def transfer(self, from_id: int, to_id: int, amount: decimal.Decimal):
# 按ID顺序确定加锁顺序
first_id, second_id = sorted([from_id, to_id])
# 获取应用层逻辑锁(防止代码层面的并发问题)
with self._get_lock(first_id):
with self._get_lock(second_id):
self._do_transfer(first_id, second_id, amount)
def _get_lock(self, account_id: int):
"""获取指定账户的应用层锁"""
if account_id not in LOCK_ORDER:
LOCK_ORDER[account_id] = threading.Lock()
return LOCK_ORDER[account_id]
4.2 锁超时机制
设置合理的锁等待超时时间,避免无限等待:
-- 查看当前锁等待超时(默认50秒) SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; -- 默认50 -- 设置锁等待超时为10秒 SET GLOBAL innodb_lock_wait_timeout = 10; -- 在应用程序中捕获锁等待超时异常
import pymysql
from pymysql import OperationalError
try:
with connection.cursor() as cursor:
cursor.execute("SELECT ... FOR UPDATE")
except OperationalError as e:
if e.args[0] == 1205: # Lock wait timeout error
logger.error(f"Lock wait timeout exceeded for transaction")
raise RetryableError("Lock timeout, should retry") from e
raise
4.3 重试机制
锁等待超时不等同于死锁,被超时的其他事务可能已经完成。应用层应实现有限重试:
import time
from pymysql import OperationalError
MAX_RETRIES = 3
RETRY_DELAY = 0.5 # 秒
def transfer_with_retry(from_id, to_id, amount):
for attempt in range(MAX_RETRIES):
try:
with connection.cursor() as cursor:
cursor.execute("BEGIN")
# 锁定逻辑...
cursor.execute("COMMIT")
return True
except OperationalError as e:
if e.args[0] == 1205: # Lock wait timeout
connection.rollback()
logger.warning(f"Attempt {attempt + 1} failed, retrying...")
time.sleep(RETRY_DELAY * (attempt + 1))
continue
raise
logger.error(f"Transfer failed after {MAX_RETRIES} attempts")
return False
5. 监控与预防
5.1 持续监控指标
建议在数据库监控系统中追踪以下指标:
| 指标 | 阈值建议 | 告警策略 |
|---|---|---|
| Innodb_row_lock_waits | > 100/min | 超过基线2倍告警 |
| Innodb_row_lock_time_avg | > 500ms | 超过基线3倍告警 |
| Threads_connected | > max_connections * 0.7 | 接近连接上限告警 |
| Lock_wait_timeout | 出现任何 | 必须告警 |
-- 查看InnoDB行锁统计 SHOW STATUS LIKE 'Innodb_row_lock%'; -- +-------------------------------+-------+ -- | Variable_name | Value | -- +-------------------------------+-------+ -- | Innodb_row_lock_current_waits | 0 | -- | Innodb_row_lock_time | 12345 | -- | Innodb_row_lock_time_avg | 123 | -- | Innodb_row_lock_time_max | 5000 | -- | Innodb_row_lock_waits | 100 | -- +-------------------------------+-------+
5.2 慢查询与死锁的关联分析
长时间运行的查询是死锁的主要诱因。定期分析慢查询日志:
# 查看慢查询配置 mysql -e "SHOW VARIABLES LIKE 'slow_query%';" mysql -e "SHOW VARIABLES LIKE 'long_query_time';" # 常用分析命令 mysqldumpslow -s t -t 20 /var/log/mysql/slow.log # 按时间排序top 20 mysqldumpslow -s c -t 20 /var/log/mysql/slow.log # 按次数排序top 20
6. 排障清单
| 问题现象 | 排查步骤 | 解决方案 |
|---|---|---|
| 事务报错"Deadlock found" | 1. 查看错误日志 2. 分析锁等待图 3. 找出循环等待的SQL | 调整SQL顺序或加锁范围 |
| Lock wait timeout exceeded | 1. 检查innodb_lock_wait_timeout 2. 查看哪个事务长时间持有锁 | 优化长事务,拆分批次 |
| 某表频繁死锁 | 1. 分析该表的访问模式 2. 检查索引设计 3. 评估隔离级别 | 优化索引或降级隔离级别 |
| 从库延迟导致主库死锁 | 1. SHOW SLAVE STATUS 2. 检查从库IO/SQL线程 | 优化从库应用或增加从库数量 |
| 批量更新时偶发死锁 | 1. 分析批量SQL的锁范围 2. 检查是否跨表操作 | 按主键顺序处理,减少锁冲突 |
死锁排查的核心能力在于正确解读INNODB_TRX、INNODB_LOCKS、INNODB_LOCK_WAITS三个系统表联合查询出的锁等待关系图。运维和开发人员应建立肌肉记忆:在生产环境出现死锁时,第一时间导出这三个表的数据快照,同时开启innodb_print_all_deadlocks抓取完整的死锁上下文。事后的日志分析比现场排查更有价值,因为死锁发生时相关事务可能已经回滚。
全部0条评论
快来发表一下你的评论吧 !