云计算百科
云计算领域专业知识百科平台

MySQL 内核实战(4):行锁、间隙锁与死锁定位

问题背景

上一篇结尾说"隔离让读写互不阻塞,代价是写与写排队",本篇就钻到队列里去。InnoDB 的读靠 MVCC 各走各的时间线,写却必须汇到同一个现实:当前读读最新版本、改最新版本,两个事务改同一行时只能一个一个来。于是出现了 OLTP 系统里最三类最吓人的现场:UPDATE 一个不存在的行,另一件事务 INSERT 却被莫名卡到 1205 超时;两个业务模块上线半年相安无事,某天起偶发 1213 死锁且越来越频繁;最惨的一种——一条 WHERE 没走索引的 UPDATE 把整张表锁死,连接池雪崩。这些现象共用一套规则:InnoDB 在 RR 下用"记录锁 + 间隙锁"组合拳防写路径幻读,而间隙锁之间彼此兼容、却围剿插入意向锁。本篇回答四个问题:InnoDB 的锁到底有哪几种、加在什么对象上;不同 SQL 形态对应的加锁集合是什么规律;死锁怎么被检测、牺牲者怎么选;以及生产现场怎么在十分钟内把锁事故定位到语句和事务。

核心原理

第一,锁的"类型 × 强度"两个维度。强度上分 S 锁(共享,LOCK IN SHARE MODE)与 X 锁(排他,UPDATE/DELETE/INSERT/FOR UPDATE),兼容矩阵就一句话:S 与 S 相容,其余皆冲突。类型上按加锁对象分四种:记录锁锁索引记录本身;间隙锁锁两条索引记录之间的开区间,只拦插入不拦读写;next-key 锁=间隙+其后记录的左开右闭组合,RR 下扫描的默认单位;插入意向锁是 INSERT 前的特殊意向锁,与任何间隙锁/next-key 锁冲突。一个反直觉但决定一切行为的性质:间隙锁之间互相兼容——两个事务可以"持有"同一段间隙,但谁也别想往里插;而一旦有插入意向撞进被锁间隙,等待就发生了。此外 InnoDB 用隐式锁省内存:新插入的行不立刻建锁结构,别的事务来碰时才由 trx_id 补建显式锁。

第二,加锁集合跟着访问路径走。等值查询打中唯一索引记录:只加一个记录锁,前后间隙都不锁——这是并发最好的路径。等值查不到(如 WHERE id=25,id 唯一):退化为锁"搜索落点"所在间隙的 gap lock,锁 (20,30) 但不含记录,专职拦 INSERT 25,防止别的事务在结果集里制造幻影。非唯一索引等值命中:next-key 锁命中记录 + 右邻间隙,因为要确认"没有下一行了"必须扫到第一个不满足条件的记录。最坏的:WHERE 不走索引,全表扫描意味着对每个经过的记录加 next-key 锁——等于锁住整张表的所有记录与所有间隙,UPDATE 未命中任何行也照样全锁(优化器没得选,扫描即加锁)。RC 则温和得多:不加间隙锁(外键与重复键检查除外),扫描中不匹配的行还会做半一致读提前释放锁,所以"改 RC 锁纠纷少一半"有坚实的机制依据。

第三,间隙锁为什么非存在不可。上一篇说过:MVCC 只挡得住快照读的幻象,当前读的幻象必须靠"把可能插入的范围先圈起来"。RR 的完整承诺是"同一事务内重复范围查询结果集不变",兑现方式就是 next-key/gap 围出的栅栏。栅栏的代价是插入并发:热点区间的 UPDATE 会阻塞同区间 INSERT,高并发写密集业务宁愿要 RC + ROW binlog 换吞吐,把防幻的责任交给业务层的显式 FOR UPDATE——这是隔离级别选型的下半场。

第四,死锁:等待图、检测与牺牲者。事务 A 持锁等 B 的锁、B 持锁等 A 的锁,等待图成环且无外部干预永不自解——与"单纯的快"(排队会随提交消散)本质不同。InnoDB 在每个锁请求入等待队列时做图检测,找到环即回滚环中 trx_weight 最小的事务(weight≈undo 量+锁数,直觉:让回滚代价小的一方先走),报 1213;被回滚方应用必须捕获并重试整笔事务。检测本身有成本:热点行上排队事务越多,每次入队的全图遍历越贵,极端争用下锁检测能吃掉大量 CPU,此时才有"关 innodb_deadlock_detect、调小 innodb_lock_wait_timeout 靠超时兜底"的讨论空间——那是用"更慢的失败"换"更便宜的排队",不是白捡的优化。

第一次代码实验及输出

用纯 Python 建模 RR/RC 的加锁规则(内存模拟,非真 mysqld):聚簇索引上有记录 10/20/30,locks_for() 按第二节的规律产出扫描区间对应的 next-key 集合,insert_blocked() 判定 T2 的 INSERT(插入意向锁)是否与在途锁冲突。对比两条 SQL:命中 20 的范围 UPDATE,以及目标行根本不存在的等值 UPDATE。

# InnoDB RR 锁模型模拟: 聚簇索引现有记录 10/20/30 (supremum=正无穷哨兵)
# next-key 锁 = 间隙锁 + 记录锁; 插入意向锁落进被锁间隙即冲突
INDEX = [10, 20, 30]

def locks_for(iso, sql_lo, sql_hi):
"""RR 索引扫描的加锁集合: 范围内每个记录加 next-key (prev, k],
再给第一个越界记录补一个 next-key 作为扫描终点; RC 不加间隙锁"""

if iso == "RC":
return set()
locks = set()
def nk(k):
prev = max((x for x in INDEX if x < k), default=1)
return ("NK", prev, k)
for k in INDEX:
if sql_lo <= k <= sql_hi:
locks.add(nk(k))
beyond = min((k for k in INDEX if k > sql_hi), default=None)
locks.add(nk(beyond) if beyond else ("NK", INDEX[1], 10 ** 9))
return locks

def insert_blocked(iso, sql_lo, sql_hi, new_key):
for lk in locks_for(iso, sql_lo, sql_hi):
kind, lo, hi = lk
if lo < new_key <= hi: return lk
return None

print("场景: T1 执行 UPDATE t SET v=v+1 WHERE id BETWEEN 18 AND 26 (命中 20)")
for iso in ("RR", "RC"):
lk = locks_for(iso, 18, 26)
print(" %s 下 T1 持有锁: %s" % (iso, sorted(lk) or "仅命中记录的行锁(模型简化)"))
for ins in (15, 25, 20, 35):
b = insert_blocked(iso, 18, 26, ins)
tag = "INSERT id=%d" % ins
if b:
print(" T2 %s -> 与 %s 冲突, 挂起等待(1205 lock wait)" % (tag, b))
else:
print(" T2 %s -> 放行" % tag)
print()
print("场景二: T1 执行 UPDATE … WHERE id=25 (不存在)")
b_rr = insert_blocked("RR", 25, 25, 25)
print(" RR: T1 锁住 (20,30] next-key; T2 此刻 INSERT 25 -> %s" % ("被间隙锁挡住(防幻读)" if b_rr else "放行"))
print(" RC: 无行可锁亦无间隙锁 -> INSERT 25 放行, 由语句级行锁兜底")

运行输出:

场景: T1 执行 UPDATE t SET v=v+1 WHERE id BETWEEN 18 AND 26 (命中 20)
RR 下 T1 持有锁: [('NK', 10, 20), ('NK', 20, 30)]
T2 INSERT id=15 -> 与 ('NK', 10, 20) 冲突, 挂起等待(1205 lock wait)
T2 INSERT id=25 -> 与 ('NK', 20, 30) 冲突, 挂起等待(1205 lock wait)
T2 INSERT id=20 -> 与 ('NK', 10, 20) 冲突, 挂起等待(1205 lock wait)
T2 INSERT id=35 -> 放行
RC 下 T1 持有锁: 仅命中记录的行锁(模型简化)
T2 INSERT id=15 -> 放行
T2 INSERT id=25 -> 放行
T2 INSERT id=20 -> 放行
T2 INSERT id=35 -> 放行

场景二: T1 执行 UPDATE … WHERE id=25 (不存在)
RR: T1 锁住 (20,30] next-key; T2 此刻 INSERT 25 -> 被间隙锁挡住(防幻读)
RC: 无行可锁亦无间隙锁 -> INSERT 25 放行, 由语句级行锁兜底

输出里三个细节对应三类事故。第一,RR 范围 UPDATE 锁了两段 next-key:(10,20] 罩住命中记录,(20,30] 罩住"扫描停下的地方"——所以 UPDATE 明明只改一行,插入 15 到 25 之间全部排队,这就是"UPDATE 锁了不存在的行"的真相。第二,INSERT id=20 也被拦:记录锁本就含在 next-key 里(且真实场景还会撞主键重复)。第三,场景二 UPDATE 一行没改却照样留下间隙锁:加锁决策发生在扫描时、与是否命中无关,“没更新到数据"不等于"没锁数据”。把 RR 两行与 RC 两行对照,就理解了为什么电商大促前会有团队把热点表事务临时降级 RC:间隙锁没了,插入通道全开,代价是幻读防线交给应用层的 FOR UPDATE。

工程化改进

第一步,让每一次加锁都走索引等值。UPDATE/DELETE 的 WHERE 必须命中主键、唯一键或高选择性二级索引——这决定锁集合是"一个记录锁"还是"全表 next-key"。上线前用 EXPLAIN 审写语句,type=ALL 的 UPDATE 直接打回;应用层保留 sql_safe_updates=1(WHERE 无键列禁止改删)作为最后护栏,DBA 会话可关但要显式声明。事务内每多持有一把锁、每多停留一秒,等待图上的环就多一个乘数——短事务是锁问题的一半答案。

第二步,全库统一加锁顺序,把死锁扼杀在协议层。死锁的构成要件是"交错申请",同一批行的加锁次序只要全局一致就成不了环:批量操作先 ORDER BY id 再逐行处理;跨表事务按固定的表序(如字典序)访问;先锁父行再锁子行的约定写进团队规范。对 8.0.22 后的"自动提交下加锁顺序由执行计划决定"也要盯住:同一段代码两条 SQL 的执行计划因统计信息漂移互换,锁序就变了,死锁"突然复现"常源于此。

第三步,把取证链固化成 SOP。第一现场查 sys.innodb_lock_waits:一张视图直接给出 blocking_pid 与 waiting_pid、双方 SQL,1205 类问题五秒定位堵源头;细粒度锁对象看 performance_schema.data_locks(锁类型、索引名、锁的范围 LOCK_RANGE 边界),8.0 已取代旧的 information_schema.INNODB_LOCKS。死锁则看 SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段——它只留最近一次且随时被覆盖,所以全局开 innodb_print_all_deadlocks=ON 把每次死锁落错误日志,事后按段分析:找 WAITING FOR … HOLDS THE LOCK 两行,锁对象与语句一目了然。

第四步,热点行单独做流量整形。秒杀扣减这类"千军万马过同一把行锁"的场景,争用成本在排队与死锁检测上双重放大:首选把"一卖一退"合并——应用层攒批,一条 UPDATE stock=stock-? WHERE id=? 带聚合增量,把千次加锁合成一次;或把计数前移到 Redis/中间队列,MySQL 落最终值。确有大量瞬时排队且业务接受超时失败时,可评估 innodb_deadlock_detect=OFF + innodb_lock_wait_timeout 压到 1~2 秒,用快速失败替代检测开销——此配置会放大"真死锁靠超时慢慢等",必须配合应用层重试与充分压测,仅限热点争用单一的库。

第二次代码实验及输出

死锁的最小完整机制模型(确定性同步回放):LockMgr 维护资源持有者与等待队列,每次入队在等待图上做 BFS 找环;成环即按"undo 小者优先牺牲"回滚并释放其锁,队列自动推进。场景一是交错加锁(A:1→2,B:2→1),场景二把 B 的申请顺序改成与 A 一致(都升序)。

# 锁等待图 + 死锁检测(同步回放模型): 资源独占 X 锁, 撞锁即排队 (waiter, res)
# 每次入队后全图找环; 成环则回滚 undo 量小的一方(InnoDB trx_weight 取舍的近似)

class LockMgr:
def __init__(self, undo):
self.holder, self.waits, self.undo = {}, [], dict(undo)
self.killed = set() # 被死锁回滚的事务

def request(self, trx, res):
cur = self.holder.get(res)
if cur in (None, trx):
self.holder[res] = trx
print(" %s 拿到 %s 的 X 锁" % (trx, res))
return True
self.waits.append((trx, res))
print(" %s 请求 %s: 被 %s 持有, 入队等待" % (trx, res, cur))
return False

def advance(self):
moved = True
while moved:
moved = False
for pair in list(self.waits):
trx, res = pair
if self.holder.get(res) is None:
self.waits.remove(pair)
self.holder[res] = trx
print(" %s 获锁: %s 已释放" % (trx, res))
moved = True

def release(self, trx, how):
owned = [r for r, h in self.holder.items() if h == trx]
for res in owned:
del self.holder[res]
self.waits = [p for p in self.waits if p[0] != trx]
print(" %s %s, 释放 %d 项锁" % (trx, how, len(owned)))
if "回滚" in how:
self.killed.add(trx)
self.advance()

def detect(self):
adj = {}
for waiter, res in self.waits:
adj.setdefault(waiter, set()).add(self.holder[res])
for start in sorted(adj):
frontier, seen = [(start, [start])], {start}
while frontier:
node, path = frontier.pop(0)
for nxt in sorted(adj.get(node, ())):
if nxt == start:
print(" !! 等待图成环: %s" % " -> ".join(path + [start]))
victim = min(set(path), key=lambda t: self.undo[t])
print(" !! LATEST DETECTED DEADLOCK: 牺牲者 %s (undo=%d 权重小)"
% (victim, self.undo[victim]))
self.release(victim, "回滚(errno 1213)")
return victim
if nxt not in seen:
seen.add(nxt)
frontier.append((nxt, path + [nxt]))
return None

def commit(self, trx):
self.release(trx, "提交")

def run(title, steps, undo):
print(title)
lm = LockMgr(undo)
for trx, res in steps:
lm.request(trx, res)
lm.detect()
for trx in ("A", "B"):
if trx not in lm.killed:
lm.commit(trx)
for trx in sorted(lm.killed):
print(" 应用层捕获 1213, %s 重试整笔事务: 此时无人在途, 顺序拿锁成功" % trx)

run("场景一(交错加锁): A 锁 1 后要 2, B 锁 2 后要 1",
[("A", "id=1"), ("B", "id=2"), ("A", "id=2"), ("B", "id=1")],
{"A": 2, "B": 7})
run("场景二(统一升序): A、B 都按 id 升序申请",
[("A", "id=1"), ("B", "id=1"), ("A", "id=2"), ("B", "id=2")],
{"A": 2, "B": 7})

运行输出:

场景一(交错加锁): A 锁 1 后要 2, B 锁 2 后要 1
A 拿到 id=1 的 X 锁
B 拿到 id=2 的 X 锁
A 请求 id=2: 被 B 持有, 入队等待
B 请求 id=1: 被 A 持有, 入队等待
!! 等待图成环: A -> B -> A
!! LATEST DETECTED DEADLOCK: 牺牲者 A (undo=2 权重小)
A 回滚(errno 1213), 释放 1 项锁
B 获锁: id=1 已释放
B 提交, 释放 2 项锁
应用层捕获 1213, A 重试整笔事务: 此时无人在途, 顺序拿锁成功
场景二(统一升序): A、B 都按 id 升序申请
A 拿到 id=1 的 X 锁
B 请求 id=1: 被 A 持有, 入队等待
A 拿到 id=2 的 X 锁
B 请求 id=2: 被 A 持有, 入队等待
A 提交, 释放 2 项锁
B 获锁: id=1 已释放
B 获锁: id=2 已释放
B 提交, 释放 2 项锁

两个场景的中间态惊人相似——都有事务"入队等待"——但结局完全不同:场景二只是排队(等待图是 B→A 的单向链,无环),提交一来依次放行;场景一在第 4 步闭合成环,检测器立刻介入。等待不是死锁,环才是,这句话决定了排障时你该劝人等一下还是该拆代码。另两个可迁移的细节:牺牲者选择看权重——回滚 undo 少的 A 而不是 B,与 InnoDB"回滚代价最小"的取向一致,这也是为什么生产死锁日志里被杀的不一定是肇事更大的那个;以及回滚后必须整笔重试——A 的两条加锁已全部失效,只重放最后一条 UPDATE 会造成业务状态残缺,重试逻辑要放在事务边界上,而不是语句上。

常见陷阱

其一,无索引 WHERE 的 UPDATE 锁全表:扫描路径决定锁集合,type=ALL 的写语句在 RR 下等于表级排他,一条慢 UPDATE 挂住全站;应急靠 kill 阻塞源(sys.innodb_lock_waits 或 SELECT * FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 1 找最早事务),根治靠补索引。其二,把 1205 当连接池问题:调大 innodb_lock_wait_timeout(默认 50 秒)只是让排队更久、雪崩更慢,正确动作是查谁持有锁、锁范围能不能缩小。其三,INSERT … ON DUPLICATE KEY UPDATE 与 INSERT INTO … SELECT 在 RR 下的间隙锁:重复键检查会留下 next-key/gap,多个并发 upsert 互锁间隙再各自插入意向撞进去,是死锁日志里的常客——热点场景改"先 UPDATE 无果再 INSERT"并容忍 1062,或降级 RC。其四,死锁日志只信 SHOW ENGINE INNODB STATUS:那里只存最近一次,两次死锁间隔短就永远看不到全貌,innodb_print_all_deadlocks 要提前开,事后翻错误日志。其五,以为间隙锁会互相阻塞:gap 与 gap 相容,两个事务的 UPDATE 都能"锁住"同一间隙再各插各的——真正成环的往往是双方随后各持一个 gap、再各要一个插入意向这种四不像组合,读锁日志时按锁类型分开看,别被"锁了同一个地方"的表象骗。

落地清单

  • 写语句上线前强制 EXPLAIN:UPDATE/DELETE 必须走索引等值,应用账号保留 sql_safe_updates
  • 多行事务统一按主键升序加锁,跨表按固定表序;批处理先排序再循环,事务控制在毫秒级
  • 常备三条取证 SQL:sys.innodb_lock_waits、performance_schema.data_locks、SHOW ENGINE INNODB STATUS;全局开 innodb_print_all_deadlocks=ON
  • 死锁牺牲者处理协议:应用层捕获 1213 并整笔重试(限次+退避),禁止只重放单条语句
  • 热点行改原子 UPDATE + 应用层攒批合并;确需关死锁检测时配极短 lock_wait_timeout 并压测验证

锁讲完了,写入的另一半账还没算:为什么 mysqld 被 kill -9 之后重启,已提交的事务一个不丢、未提交的一个不留?innodb_flush_log_at_trx_commit=1 与 =2 差的那一秒到底差在哪?主库好端端的数据,从库为什么会漂?下一篇《MySQL 内核实战(5):redo log、undo log 与崩溃恢复》把 WAL、LSN 与 checkpoint 这条日志主线捋直。

参考来源

  • MySQL 8.0 Reference Manual:InnoDB Locking(记录锁/间隙锁/next-key/插入意向):https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html
  • MySQL 8.0 Reference Manual:Locks Set by Different SQL Statements in InnoDB:https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html
  • MySQL 8.0 Reference Manual:InnoDB Deadlock Detection:https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlock-detection.html
  • MySQL 8.0 Reference Manual:Performance Schema Data Lock Tables:https://dev.mysql.com/doc/refman/8.0/en/performance-schema-data-lock-tables.html
  • Wikipedia:Deadlock:https://en.wikipedia.org/wiki/Deadlock

👍 觉得有用就点个 赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。

🚀 本文属于 《MySQL 内核实战》 系列,持续更新,关注不迷路。

📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到 cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。

赞(0)
未经允许不得转载:网硕互联帮助中心 » MySQL 内核实战(4):行锁、间隙锁与死锁定位
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!