44.11 锁 (行锁 / 间隙锁 / 意向锁 / 死锁)
一句话
三种锁各管一件事:行锁防丢失更新、间隙锁防幻读、意向锁让表级操作不必逐行检查;而死锁不是 bug,是“多个事务以不同顺序加锁”的必然结果——只能靠等待图检测并回滚一个事务来打断。
六种锁,一张兼容表
| 锁 | 加在什么上 | 解决什么 | 与谁冲突 |
|---|---|---|---|
| 共享锁 S | 行 | 读时阻止别人改 | X |
| 排他锁 X | 行 | 写时阻止别人读/写 | S、X |
| 意向共享 IS | 表 | 声明“我要在某行加 S” | IX |
| 意向排他 IX | 表 | 声明“我要在某行加 X” | S、X、IS |
| 间隙锁 GAP | 索引区间 | 阻止区间内插入(防幻读) | 插入意向锁 |
| Next-Key | 记录 + 前面的间隙 | 行锁 + 间隙锁的组合 | 同上 |
| S | X | IS | IX | |
|---|---|---|---|---|
| S | ✅ | ❌ | ✅ | ❌ |
| X | ❌ | ❌ | ❌ | ❌ |
| IS | ✅ | ❌ | ✅ | ✅ |
| IX | ❌ | ❌ | ✅ | ✅ |
🔑 意向锁的意义:如果没有它,一个事务要加表锁,必须扫描全表每一行看有没有行锁;有了 IS/IX,只需看表上有没有冲突的意向锁——用一次表级检查替代 O(n) 行扫描。
代码:等待图 + 死锁检测 + 间隙锁
package main
import (
"fmt"
"sort"
)
type lockMgr struct {
holder map[string]string // resource -> txn(简化为单持有者)
waitFor map[string]string // txn -> 它等待的 txn
held map[string][]string // txn -> 持有的资源
}
func newLockMgr() *lockMgr {
return &lockMgr{holder: map[string]string{}, waitFor: map[string]string{}, held: map[string][]string{}}
}
// acquire 返回 "ok" / "wait" / "deadlock"
func (m *lockMgr) acquire(txn, res string) string {
if h, ok := m.holder[res]; ok {
if h == txn {
return "ok(重入)"
}
m.waitFor[txn] = h
if m.detectCycle(txn) {
delete(m.waitFor, txn)
return "deadlock"
}
return fmt.Sprintf("wait(%s)", h)
}
m.holder[res] = txn
m.held[txn] = append(m.held[txn], res)
return "ok"
}
// 从 txn 出发沿 waitFor 走,看能否回到自己
func (m *lockMgr) detectCycle(start string) bool {
seen := map[string]bool{}
cur := start
for {
next, ok := m.waitFor[cur]
if !ok {
return false
}
if next == start {
return true
}
if seen[next] {
return false
}
seen[next] = true
cur = next
}
}
func (m *lockMgr) release(txn string) {
for _, r := range m.held[txn] {
if m.holder[r] == txn {
delete(m.holder, r)
}
}
delete(m.held, txn)
// 清理那些“在等这个事务”的等待关系
for waiter, holder := range m.waitFor {
if holder == txn {
delete(m.waitFor, waiter)
}
}
}
func main() {
m := newLockMgr()
fmt.Println("=== 加锁序列(两个事务以相反顺序加锁)===")
steps := []struct{ txn, res string }{
{"T1", "row:1"}, {"T2", "row:2"},
{"T1", "row:2"}, {"T2", "row:1"},
}
for _, s := range steps {
r := m.acquire(s.txn, s.res)
fmt.Printf("%s 请求 X(%s) -> %s\n", s.txn, s.res, r)
if r == "deadlock" {
fmt.Println(" 检测到环:T1 -> T2 -> T1,选择回滚 T2(新加入等待环的事务)")
m.release("T2")
fmt.Printf(" T2 回滚后,T1 重试 X(row:2) -> %s\n", m.acquire("T1", "row:2"))
}
}
fmt.Println("\n=== 间隙锁:阻止区间内插入 ===")
// 索引上有 10、20、30;事务对 (10,30) 加间隙锁
gaps := []struct {
lo, hi int
holder string
}{{10, 30, "T3"}}
tryInsert := func(v int) {
for _, g := range gaps {
if v > g.lo && v < g.hi {
fmt.Printf("插入 %d -> 阻塞(落在 T3 的间隙锁 (%d,%d) 内)\n", v, g.lo, g.hi)
return
}
}
fmt.Printf("插入 %d -> 成功(不在任何间隙锁内)\n", v)
}
tryInsert(18)
tryInsert(35)
// 统计每个事务持有的锁(按资源名排序,输出稳定)
fmt.Println("\n=== 当前锁持有情况 ===")
for txn, rs := range m.held {
sort.Strings(rs)
fmt.Printf("%s: %v\n", txn, rs)
}
}
本机实测(macOS,Apple M1,Go 1.27):
=== 加锁序列(两个事务以相反顺序加锁)===
T1 请求 X(row:1) -> ok
T2 请求 X(row:2) -> ok
T1 请求 X(row:2) -> wait(T2)
T2 请求 X(row:1) -> deadlock
检测到环:T1 -> T2 -> T1,选择回滚 T2(新加入等待环的事务)
T2 回滚后,T1 重试 X(row:2) -> ok
=== 间隙锁:阻止区间内插入 ===
插入 18 -> 阻塞(落在 T3 的间隙锁 (10,30) 内)
插入 35 -> 成功(不在任何间隙锁内)
=== 当前锁持有情况 ===
T1: [row:1 row:2]
读数:
| 现象 | 含义 |
|---|---|
| T1 与 T2 各持一把锁、再互相请求 | 等待图出现环 T1 → T2 → T1,这就是死锁 |
| 检测到环后回滚 T2 | 数据库不会“等一等就好”,必须选一个牺牲者 |
| 插入 18 被阻塞 | 间隙锁锁的是“区间”,不是“已存在的行”——这正是防幻读的机制 |
| 插入 35 成功 | 落在间隙之外,不受影响 |
⚠️ 间隙锁的代价:它锁住的是区间,会阻塞所有落在区间内的插入(哪怕这些行与查询条件无关)。所以高并发写入的表要谨慎使用
SELECT ... FOR UPDATE+ 范围条件,否则容易“锁住一大片”。
死锁的四个条件与三种解法
| 条件 | 含义 | 解法 |
|---|---|---|
| 互斥 | 资源不能共享 | 无法消除(写操作天然互斥) |
| 持有并等待 | 拿着 A 等 B | 一次性申请所有锁(或按固定顺序) |
| 不可抢占 | 不能强行拿走 | 超时回滚(innodb_lock_wait_timeout) |
| 循环等待 | 形成环 | 按固定顺序加锁(最有效)、检测到环后回滚 |
🔑 工程上最有效的一条是“固定加锁顺序”:所有事务都按
min(id), max(id)的顺序锁行,环就不可能形成。其次是“小事务”(锁持有时间短,碰撞概率低)与“走索引”(避免锁范围扩大到全表)。
反模式
| 反模式 | 后果 |
|---|---|
| 两个事务以不同顺序更新同一批行 | 死锁,靠数据库回滚一个 |
| 长事务持锁等外部 IO(RPC、用户输入) | 锁持有时间从毫秒变秒级,连锁阻塞 |
范围 FOR UPDATE 不带索引 | 间隙锁/表锁范围扩大,写入大面积阻塞 |
用 SELECT ... FOR UPDATE 做“读” | 读也排队,吞吐骤降 |
| 死锁后无限重试不加重试次数上限 | 两个事务反复互相回滚,CPU 打满 |
忽略 innodb_lock_wait_timeout 默认值 | 等待时间过长,连接池被占满(44.12) |
自检
Q1 为什么“间隙锁”能防幻读,而“行锁”不能?
▸ 答案:幻读是“同一个事务两次范围查询,第二次多出了别的事务插入的行”。行锁只能锁住已经存在的行,对新插入的行无能为力;间隙锁锁的是索引区间(如 (10,30)),任何试图插入该区间的操作都会被阻塞,于是第二次范围查询不会多出新行。代价是并发插入能力下降,且间隙锁只在 RR 及以上、且走索引时生效。
Q2 意向锁(IS/IX)到底解决了什么问题?
▸ 答案:解决了“表级锁如何快速判断行级锁冲突”的问题。没有意向锁时,事务要给表加 S/X 锁,必须逐行检查是否有行锁(O(n));有了 IS/IX,行级加锁前先在表上登记“我要在某行加 S/X”,表级操作只需检查表上的意向锁就能判断冲突(O(1))。它不改变行级并发的语义,只是把“检查成本”从行数降到了常数。
Q3 两个事务按 id 升序更新同一批行,为什么不会死锁?还有什么情况会死锁?
▸ 答案:固定顺序消除了“循环等待”:所有事务都先锁小 id 再锁大 id,等待关系只能单向(小 id 持有者 → 大 id 持有者),不可能形成环。仍可能死锁的情况:① 其中一个事务用了不同的访问路径(例如一个走二级索引、一个走主键,加锁顺序不同);② 间隙锁与插入意向锁交叉(插入不同行但落在同一间隙);③ 涉及多张表时,表之间的加锁顺序不一致。所以“固定顺序”要覆盖所有资源(行、表、间隙)。
Q4 为什么 “没有索引的 UPDATE” 会锁住大量行甚至全表?
▸ 答案:因为 InnoDB 的锁加在索引记录上:没有可用索引时,只能全表扫描并给扫到的每一行加锁(RC 下还会释放不匹配的,RR 下范围更大)。表现是 “更新一行却锁住整表”,并发立刻崩。解法是给 WHERE 条件建索引。
Q5 定位死锁时,SHOW ENGINE INNODB STATUS 里最该看什么?
▸ 答案:看 LATEST DETECTED DEADLOCK 段:两个事务各自的 SQL、持有的锁、等待的锁。重点判断 “加锁顺序是否相反” 与 “是否用了二级索引导致锁范围过大”。修复通常不是调参数,而是改 SQL/加索引/统一顺序。