it编程 > 数据库 > Mysql

MySQL 锁与死锁深度拆解:行锁、间隙锁、Next-Key Lock 与排查实战

57人参与 • 2026-10-09 • Mysql

“线上突然报 deadlock found when trying to get lock”,
然后日志里一段看不懂的 latest detected deadlock。

本篇讲清楚 innodb 有哪些锁、什么情况下加什么锁、
死锁怎么产生、以及拿到死锁日志后该怎么读。

一、锁的分类

按粒度分:

粒度说明开销冲突概率
表级锁锁整张表小高
行级锁锁单行或范围大低
页级锁折中(bdb 引擎用,基本见不到)中中

innodb 支持行级锁,这是它取代 myisam 的核心原因。
但要注意:行锁是加在索引上的,
如果 sql 没走索引,会退化成锁全表的所有行(甚至表锁)。

按模式分:

-- 共享锁(s 锁):读锁,多个事务可同时持有
select * from t where id = 1 lock in share mode;
-- 排他锁(x 锁):写锁,独占
select * from t where id = 1 for update;
update t set ... where id = 1;    -- 自动加 x 锁
delete from t where id = 1;       -- 自动加 x 锁

兼容性矩阵:

已持有 \ 请求sx
s✅ 兼容❌ 冲突
x❌ 冲突❌ 冲突

二、行锁的三种类型

这才是 innodb 锁的精髓所在。

2.1 record lock(记录锁)

锁住一条具体的索引记录。

select * from t where id = 10 for update;   -- id 是主键,锁 id=10 这一行

2.2 gap lock(间隙锁)

锁住两条记录之间的间隙,防止别的事务往这个间隙里插入数据。

假设表里有 id = 1, 5, 10 三条记录,那么间隙有:
(-∞, 1)、(1, 5)、(5, 10)、(10, +∞)

select * from t where id between 5 and 10 for update;
-- 会锁住 (1,5]、(5,10]、(10,+∞) 这些范围

间隙锁的唯一目的是防止幻读——阻止别的事务在范围内插入新行。

⚠️ 两个关键特性:

  1. 间隙锁之间不冲突:两个事务可以同时对同一个间隙加间隙锁
    (因为它们都是为了防止插入,目标一致)
  2. 间隙锁只在 rr 及以上级别存在。
    rc 级别下没有间隙锁(这是 rc 并发更高的主因)

2.3 next-key lock(临键锁)

record lock + gap lock 的组合,锁住"左开右闭"的区间 (prev, current]。

这是 innodb 在 rr 级别下的默认加锁单位。

-- 表里有 id = 1, 5, 10
select * from t where id = 5 for update;
-- 加的是 next-key lock,锁定范围 (1, 5]

注意:等值查询命中唯一索引时,next-key lock 会退化成 record lock。
这是 innodb 的优化——既然 id 是唯一的,
锁住 5 这一行就够了,不需要间隙。

-- id 是主键(唯一索引)
select * from t where id = 5 for update;   -- 只锁 id=5 这一行(退化)
-- age 是普通索引,可能重复
select * from t where age = 20 for update;
-- 不退化!锁住所有 age=20 的行 + 前后的间隙

三、不同 sql 加什么锁

这是最实用的部分,建议对着看:

sql索引情况加锁
select ... from—不加锁(快照读,走 mvcc)
select ... lock in share mode唯一索引等值s 型 record lock
select ... for update唯一索引等值x 型 record lock
select ... for update唯一索引范围x 型 next-key lock(扫到的范围)
select ... for update普通索引等值x 型 next-key lock(不退化)
select ... for update无索引锁全表所有行 + 所有间隙
update / delete同上逻辑同 for update

最后一行是最危险的情况:

-- name 字段没有索引
update user set status = 1 where name = 'tom';
-- 全表扫描,锁住所有记录和所有间隙 → 整张表实际上不可写

生产事故高发点:一条不走索引的 update,把整张表锁死。
所以 update/delete 的 where 条件必须有索引,这是硬性纪律。

验证加锁范围

-- mysql 8.0+ 可以用 performance_schema 查看
select * from performance_schema.data_locks;
-- 5.7 用
show engine innodb status\g

四、死锁是怎么产生的

死锁的四个必要条件:互斥、占有且等待、不可抢占、循环等待。
innodb 无法破坏前三个(锁的本质),所以只能检测循环等待并回滚。

4.1 最经典的场景:加锁顺序不同

-- 事务 a
begin;
update account set balance = balance - 100 where id = 1;   -- 锁 id=1
update account set balance = balance + 100 where id = 2;   -- 等 id=2
-- 事务 b(同时)
begin;
update account set balance = balance - 50 where id = 2;    -- 锁 id=2
update account set balance = balance + 50 where id = 1;    -- 等 id=1

时间线:

t1  a 锁住 id=1
t2  b 锁住 id=2
t3  a 请求 id=2 → 等待
t4  b 请求 id=1 → 等待
t5  innodb 检测到环路 → 回滚其中一个,报 deadlock

解决方案:统一加锁顺序。比如永远按 id 升序处理:

ids = sorted([1, 2])    # 排序!
for i in ids:
    cursor.execute("update account set ... where id = %s", (i,))

这一行 sorted() 能消除绝大多数死锁。

4.2 间隙锁导致的死锁

这个更隐蔽。rr 级别下,两个事务对同一间隙加间隙锁不冲突,
但后续的插入操作会冲突:

-- 表里有 id = 5
-- 事务 a
begin;
select * from t where id = 5 for update;   -- 加 next-key lock,锁 (?, 5]
-- 事务 b
begin;
select * from t where id = 5 for update;   -- 间隙锁不冲突,也成功
-- 事务 a
insert into t (id) values (4);   -- 等待 b 释放间隙锁
-- 事务 b
insert into t (id) values (4);   -- 等待 a 释放 → 死锁!

这种死锁在 rc 级别下不会发生(没有间隙锁)。
这也是很多团队改用 rc 的原因之一。

4.3 唯一键冲突导致的死锁

并发插入相同的唯一键时,一个成功一个失败,
失败的会加 s 锁等待,如果此时有其他事务持有锁,可能形成环路。

五、死锁日志怎么读

死锁发生后:

show engine innodb status\g

找到 latest detected deadlock 段落:

------------------------
latest detected deadlock
------------------------
2026-10-07 06:20:11 0x7f8b
*** (1) transaction:
transaction 421568, active 2 sec starting index read
mysql tables in use 1, locked 1
lock wait 3 lock struct(s), heap size 1136, 2 row lock(s)
mysql thread id 15, os thread handle ..., query id 88 localhost root updating
update account set balance = balance - 100 where id = 1
*** (1) holds the lock(s):
record locks space id 58 page no 3 n bits 80 index primary of table `test`.`account`
trx id 421568 lock_mode x locks rec but not gap waiting
*** (2) transaction:
transaction 421569, active 1 sec starting index read
update account set balance = balance + 50 where id = 2
*** (2) holds the lock(s):
record locks ... index primary ...
trx id 421569 lock_mode x locks rec but not gap
*** we roll back transaction (2)

读日志的关键四点:

  1. (1) transaction 和 (2) transaction —— 两个互相等待的事务,
    各自下面有它正在执行的 sql
  2. lock_mode x locks rec but not gap —— 锁类型。
    • x = 排他锁
    • locks rec but not gap = record lock(没有间隙锁)
    • locks gap before rec = 间隙锁
    • next-key / 无后缀 = next-key lock
  3. index primary —— 锁在哪个索引上。
    如果显示的是二级索引,说明还回表锁了主键
  4. we roll back transaction (2) —— 谁被回滚了。
    innodb 选择回滚影响行数更少的那个(权重小的)

⚠️ 一个坑:show engine innodb status 只保留最近一次死锁。
要看历史得开参数:

-- mysql 5.6+ 可以把死锁日志写进 error log
set global innodb_print_all_deadlocks = on;

生产环境建议常开,否则死锁信息会被覆盖掉。

六、锁等待与超时

-- 查看锁等待超时时间(默认 50 秒)
show variables like 'innodb_lock_wait_timeout';
-- 查看当前等待锁的事务(mysql 8.0+)
select * from performance_schema.data_lock_waits;
-- 5.7 查锁等待
select * from information_schema.innodb_lock_waits;
select * from information_schema.innodb_locks;   -- 已加锁的
-- 查看活跃事务
select * from information_schema.innodb_trx;

找阻塞源的经典 sql(8.0+):

select
    waiting_pid as 被阻塞的线程,
    waiting_query as 被阻塞的sql,
    blocking_pid as 阻塞者线程,
    blocking_query as 阻塞者的sql,
    wait_age as 已等待时长
from sys.innodb_lock_waits;

sys 库是 mysql 5.7+ 自带的诊断视图集合,
innodb_lock_waits 把上面几张表 join 好了,直接用就行。

处理:确认后 kill <blocking_pid> 杀掉阻塞源。

七、减少死锁的六条实践

1. 统一加锁顺序(最重要)
批量更新前排序,永远按同一顺序访问资源。

2. 缩小事务范围

# ❌ 事务里夹着 rpc 调用
with transaction():
    db.update(...)
    requests.post("https://外部服务")    # 可能耗时几秒,锁一直占着
    db.update(...)
# ✅ 事务里只做数据库操作
with transaction():
    db.update(...)
    db.update(...)
requests.post(...)    # 放到事务外

事务越长,锁持有越久,冲突概率指数上升。

3. 降低隔离级别
rr → rc 可以消除大部分间隙锁死锁。

4. 避免无索引的 update/delete
where 条件必须有索引,否则锁全表。

5. 用 select ... for update 显式预加锁

begin;
select * from t where id = 1 for update;    -- 一开始就锁住
-- 业务逻辑
update t set ... where id = 1;
commit;

先读后写的场景,在事务开始就加锁,避免中途升级锁造成环路。

6. 重试机制
死锁无法完全避免,应用层必须有重试:

from functools import wraps
import pymysql, time, random
def retry_on_deadlock(times=3):
    def deco(fn):
        @wraps(fn)
        def wrapper(*a, **kw):
            for i in range(times):
                try:
                    return fn(*a, **kw)
                except pymysql.err.operationalerror as e:
                    if e.args[0] == 1213:      # 1213 = deadlock
                        time.sleep(0.1 * (2 ** i) + random.random() * 0.1)
                        continue
                    raise
            raise
        return wrapper
    return deco

加了随机抖动,避免多个请求同时重试再次撞车。

八、死锁 vs 锁等待超时

死锁锁等待超时
原因循环等待长时间拿不到锁
检测innodb 主动检测(立即回滚)等 innodb_lock_wait_timeout 秒
错误码12131205
处理重试即可要排查为什么持有这么久

死锁其实"好处理"——innodb 会立刻发现并回滚一个,
应用层重试就行。锁等待超时更麻烦,它说明有长事务,
需要查 innodb_trx 找出是谁。

九、小结

下一篇讲慢查询排查——锁解决"数据正确性",
慢查询解决"为什么这么慢",两者经常一起出现:
一条慢 sql 持有锁太久,就会引发大面积锁等待。

到此这篇关于mysql 锁与死锁深度拆解:行锁、间隙锁、next-key lock 与排查实战的文章就介绍到这了,更多相关mysql锁与死锁内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!

赞 (0)

您想发表意见!!点此发布评论

推荐阅读

从 NULL 到外键:MySQL 表约束体系全解析

10-09

MySQL三大日志redo log、undo log、binlog作用与两阶段提交原理深度解析

10-09

Mysql数据库索引下推完整代码示例

10-09

MySQL用C/C++连接的详细图文教程

10-09

MySQL8.0版本详细安装配置教程(含详细图文)

10-09

MySQL数据库中的数据类型示例详解

10-09

猜你喜欢

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论