数据库系列(六):锁与并发控制 — 从行锁到死锁检测

写在前面

承接前一篇 MVCC,本文继续讨论并发控制——但聚焦。MVCC 解决"读写并发",锁解决"写写并发"。

本文要回答:

行锁到底是怎么实现的?SQL Server 为什么会"锁升级"?InnoDB 的 Gap Lock 到底锁了什么?PostgreSQL 怎么用 Advisory Lock 做分布式锁?


一、锁粒度层级

1.1 多层级的锁

1
2
3
4
5
6
7
8
9
数据库 → 表 → 页 / Extent → 行

层级关系:
  上层加意向锁(IS/IX),下层加实际锁(S/X)
  → 加行锁前,先在表上加意向锁

为什么不直接锁行?
  - 防止"表级操作(DROP TABLE)"看不到正在进行的行锁
  - 意向锁是一种"快速检测冲突"的机制

1.2 锁模式矩阵

锁模式 含义 兼容
S(Shared) 共享锁,读用 与 S、IS、IX 兼容
X(Exclusive) 排他锁,写用 与所有锁互斥
IS(Intent Shared) 意向共享 与 IS、IX、S 兼容
IX(Intent Exclusive) 意向排他 与 IS、IX 兼容
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
兼容性矩阵(行级 / 表级):
        S    X    IS   IX
   S    ✅   ❌   ✅   ❌
   X    ❌   ❌   ❌   ❌
   IS   ✅   ❌   ✅   ✅
   IX   ❌   ❌   ✅   ✅

关键观察:
  - S 和 X 互斥(读和写不能同时)
  - X 和任何锁互斥(写需要独占)
  - IS 和 IX 互相兼容(意向只是"标记",不实际锁定)
  - S 和 IX 不兼容(表级 S 锁阻止 IX,因为 IX 暗示有行级 X 锁)

二、SQL Server 的锁体系

SQL Server 是四家里锁体系最复杂的,因为它不依赖 MVCC(默认情况下),用锁解决所有并发。

2.1 多粒度锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
SQL Server 锁粒度:
  - RID(行 ID,堆表)
  - KEY(索引键,clustered/nonclustered)
  - PAGE(页)
  - EXTENT(区,8 页)
  - HoBT(堆或 B 树)
  - TABLE(表)
  - FILE(文件)
  - DATABASE(数据库)

→ SQL Server 会自动选择锁粒度(行/页/表),称为"动态锁"

2.2 锁升级(Lock Escalation)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
SQL Server 的"特点":
  每个事务持有过多锁时,自动升级粒度
  - 行锁 → 页锁
  - 页锁 → 表锁

阈值:单语句持锁超过 5000 个

问题:
  - 升级后锁范围变大,并发变差
  - "我的 SELECT 突然阻塞了所有写入" 这种诡异问题
1
2
3
4
5
6
7
8
9
-- 查看锁
SELECT * FROM sys.dm_tran_locks
WHERE resource_database_id = DB_ID();

-- 禁用表锁升级
ALTER TABLE t SET (LOCK_ESCALATION = DISABLE);

-- 升级到 partition 而非 table
ALTER TABLE t SET (LOCK_ESCALATION = AUTO);

2.3 其他三家的对比

1
2
3
4
5
6
7
8
Oracle / MySQL / PostgreSQL:不锁升级
  - 一旦行锁就锁到底(即使持 100 万行锁也不升表锁)
  - 锁管理器设计成可以处理海量锁
  - SQL Server 因为历史原因(早期内存紧张)做了升级

代价:
  - Oracle / PG 内存占用高(每行锁一个 entry)
  - 但并发性更好

三、InnoDB 的行锁三档

InnoDB 的锁是四家里最精巧的,因为它要同时支持 RR(防幻读)和高并发。

3.1 三种行锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Record Lock(记录锁):
  锁定索引上的一条记录
  WHERE id = 5 → 锁 id=5 的索引节点

Gap Lock(间隙锁):
  锁定索引上的"区间",但不含端点
  WHERE id > 5 AND id < 10 → 锁 (5, 10) 间隙
  防止其他事务在这个区间 INSERT

Next-Key Lock(Next-Key = Record + Gap):
  锁定"一条记录 + 该记录之前的间隙"
  WHERE id BETWEEN 5 AND 10 → 锁 (3, 5], (5, 8], (8, 10] 等多个 Next-Key

3.2 加锁规则(RR 隔离级别下)

1
2
3
4
5
6
7
InnoDB RR 下的加锁规则(基于《MySQL 实战 45 讲》):

1. 原则 1:基本单位是 Next-Key Lock
2. 原则 2:查找过程中访问到的对象才加锁
3. 优化 1:唯一索引等值查询,命中记录 → 退化为 Record Lock
4. 优化 2:等值查询,向右遍历到最后一个不满足条件的值 → Next-Key 退化为 Gap Lock
5. 唯一索引范围查询:会访问到不满足条件的第一个值
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
示例表:id (1, 5, 10, 15, 20),主键

场景 1:等值命中唯一索引
  SELECT * FROM t WHERE id = 10 FOR UPDATE;
  → 锁 id=10 的 Record Lock(不锁间隙)

场景 2:等值未命中唯一索引
  SELECT * FROM t WHERE id = 7 FOR UPDATE;
  → 锁 (5, 10) 的 Gap Lock(防止插入 7)

场景 3:范围查询
  SELECT * FROM t WHERE id >= 10 AND id < 15 FOR UPDATE;
  → 锁 [10, 15) 的 Next-Key + 锁 (10, 15) 的 Gap

场景 4:SELECT * FROM t WHERE id > 10 FOR UPDATE;
  → 锁 (10, 15], (15, 20], (20, +∞]

3.3 唯一索引 vs 非唯一索引的差异

1
2
3
4
5
6
唯一索引等值命中 → Record Lock(窄)
非唯一索引等值命中 → Next-Key(多个 Gap)

为什么?
  - 唯一索引:相同值只能有一个,不需要防止"再插一个相同值"
  - 非唯一索引:可能有多条相同值记录,需要锁定间隙防止幻读

3.4 RC 级别下

1
2
3
4
5
RC 级别下,InnoDB 退化为只加 Record Lock:
  - 没有 Gap Lock
  - 没有 Next-Key Lock
  - 不防幻读
  - 并发性更好

四、PostgreSQL 的锁

4.1 表级锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
LOCK TABLE t IN {MODE} MODE;

模式(强度递增):
  ACCESS SHARE         SELECT 自动加
  ROW SHARE            SELECT FOR UPDATE/SHARE 自动加
  ROW EXCLUSIVE        INSERT/UPDATE/DELETE 自动加
  SHARE UPDATE EXCL    VACUUM 自动加
  SHARE                CREATE INDEX 自动加
  SHARE ROW EXCL       手动
  EXCLUSIVE            手动
  ACCESS EXCLUSIVE     ALTER/DROP/TRUNCATE 自动加

4.2 行级锁

1
2
3
4
5
6
7
8
9
PG 行级锁类型:
  FOR UPDATE       ← 排他,允许其他事务读,阻止写
  FOR NO KEY UPDATE ← 排他(弱),允许其他 FOR KEY SHARE
  FOR SHARE        ← 共享
  FOR KEY SHARE    ← 共享(弱)

PG 行锁不存独立结构,记录在行的 xmax:
  - 优点:节省内存(不限制锁数量)
  - 缺点:检查冲突需要读行(多版本查询)

4.3 Advisory Lock(应用层分布式锁)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 会话级 Advisory Lock
SELECT pg_advisory_lock(12345);     -- 申请锁
SELECT pg_advisory_unlock(12345);   -- 释放

-- 事务级 Advisory Lock(事务结束自动释放)
SELECT pg_advisory_xact_lock(12345);

-- 非阻塞尝试
SELECT pg_try_advisory_lock(12345);  -- 返回 true/false

-- 双 int 参数版本(支持 8 字节 key)
SELECT pg_advisory_lock(id1, id2);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
应用场景:
  - 应用层分布式锁(避免引入 Redis / ZooKeeper)
  - 跨服务协调(多个服务连同一 PG)
  - 任务调度(多个 worker 抢任务)

优势:
  - 与业务事务原子性一致(事务级)
  - 不需要额外基础设施
  - 自动释放(连接断开 / 事务结束)

劣势:
  - 只能在 PG 内有效
  - 不适合跨数据库场景

五、Oracle 的锁

5.1 DML 锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Oracle 行锁:
  - 通过 ITL(块的事务槽)实现
  - 锁信息存在数据块头部,不存独立结构
  - 不会"锁升级"

锁模式:
  - RX(Row Exclusive):INSERT/UPDATE/DELETE
  - RS(Row Shared):SELECT FOR SHARE
  - SRX(Shared Row Exclusive):手工声明
  - S(Shared):手工声明
  - X(Exclusive):手工声明

5.2 DDL 锁

1
2
3
4
DDL 操作时锁定 schema 对象:
  - Exclusive DDL Lock:CREATE/ALTER/DROP 时,完全独占
  - Share DDL Lock:CREATE PROCEDURE 等,允许其他 DDL 但不允许 DROP
  - Breakable Parse Lock:缓存依赖关系

5.3 Oracle 没有锁升级

1
2
3
4
5
Oracle 设计哲学:
  - 锁信息存在数据块里,不占专门内存
  - 一行锁 = 块头部 ITL 的一项(几字节)
  - 100 万行锁也只是 100 万个 ITL entry
  - 永不升级

六、SELECT FOR UPDATE 家族

6.1 标准语法

1
2
3
4
5
6
-- 标准
SELECT * FROM t WHERE id = 1 FOR UPDATE;
-- 加 X 锁,其他事务读可(MVCC),写不行

SELECT * FROM t WHERE id = 1 FOR SHARE;
-- 加 S 锁,其他事务可以读,但不能写

6.2 NOWAIT / SKIP LOCKED

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- NOWAIT:拿不到锁立即报错
SELECT * FROM t WHERE id = 1 FOR UPDATE NOWAIT;
-- 错误:ORA-00054 / Lock wait timeout exceeded

-- SKIP LOCKED:跳过被锁的行(不阻塞)
SELECT * FROM t WHERE id IN (1, 2, 3) FOR UPDATE SKIP LOCKED;
-- 假设 id=2 被锁,返回 id=1 和 id=3

-- 用途:任务队列
SELECT * FROM task_queue WHERE status = 'pending'
FOR UPDATE SKIP LOCKED LIMIT 10;
-- 多个 worker 同时取任务,互不阻塞
1
2
3
4
5
支持情况:
  - Oracle:NOWAIT ✅ / SKIP LOCKED ✅
  - SQL Server:NOWAIT ✅(WITH NOWAIT)/ SKIP LOCKED ✅(READPAST)
  - MySQL:NOWAIT ✅(8.0+)/ SKIP LOCKED ✅(8.0+)
  - PostgreSQL:NOWAIT ✅ / SKIP LOCKED ✅

6.3 锁列(FOR UPDATE OF)

1
2
3
-- 联合查询时只锁特定表
SELECT * FROM orders o JOIN users u ON o.uid = u.id
FOR UPDATE OF o;  -- 只锁 orders 行,不锁 users

七、死锁

7.1 死锁场景

1
2
3
4
5
6
7
8
9
事务A                          事务B
BEGIN;                         BEGIN;
UPDATE t SET v=1 WHERE id=1;   UPDATE t SET v=1 WHERE id=2;
                               -- 持有 id=2 锁
UPDATE t SET v=1 WHERE id=2;
-- 等待 id=2 锁                  UPDATE t SET v=1 WHERE id=1;
                                -- 等待 id=1 锁

→ 死锁!双方互相等待

7.2 检测机制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Wait-For Graph(等待图):
  - 数据库维护一个"事务等待关系图"
  - 检测图中是否有环
  - 有环 → 死锁
  - 选择"代价最小"的事务回滚(victim)

各家实现:
  - Oracle:自动检测,回滚代价最小的事务(错误 ORA-00060)
  - SQL Server:自动检测,默认 5 秒间隔
  - MySQL InnoDB:自动检测,立即回滚(默认 deadlock_detect=ON)
  - PostgreSQL:自动检测,默认 1 秒间隔

7.3 如何避免死锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
1. 固定加锁顺序
   - 所有事务按相同顺序加锁(如按主键升序)
   - 避免环

2. 缩短事务
   - 持锁时间短,降低死锁概率
   - 不要在事务中调用外部 API

3. 用 NOWAIT + 重试
   - 拿不到锁立即失败
   - 应用层捕获错误后重试

4. 用 SKIP LOCKED
   - 任务队列场景,跳过被锁任务

5. 控制并发度
   - 同一资源并发不要太高

八、悲观锁 vs 乐观锁

8.1 悲观锁(Pessimistic)

1
2
3
4
5
6
7
8
假设冲突必然发生,提前加锁
  SELECT * FROM t WHERE id = 1 FOR UPDATE;
  -- 加锁后才修改
  UPDATE t SET v = ... WHERE id = 1;
  COMMIT;

适合:写冲突频繁
代价:持锁期间其他事务阻塞

8.2 乐观锁(Optimistic)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
假设冲突很少,提交时检测

实现:版本号 / 时间戳字段
  CREATE TABLE t (id INT, v INT, version INT);

  -- 读
  SELECT id, v, version FROM t WHERE id = 1;
  -- 假设读到 version = 10

  -- 写(带版本检查)
  UPDATE t SET v = v + 1, version = version + 1
  WHERE id = 1 AND version = 10;
  -- 影响行数 = 0 → 说明版本变了 → 冲突
  -- 应用层重试

适合:读多写少、冲突少
代价:失败时需要重试

8.3 选型建议

1
2
3
4
5
6
7
8
冲突率高(>20%)→ 悲观锁(减少重试开销)
冲突率低 → 乐观锁(避免锁开销)

业务场景:
  - 库存扣减:悲观(必然冲突)
  - 用户更新资料:乐观(极少冲突)
  - 抢购:悲观 + 队列
  - 投票计数:乐观 + 重试

九、常见锁问题排查

9.1 MySQL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 看当前事务
SELECT * FROM information_schema.innodb_trx;

-- 看锁(8.0+)
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;

-- 死锁日志
SHOW ENGINE INNODB STATUS\G

-- 锁等待超时
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

9.2 PostgreSQL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- 看锁
SELECT l.locktype, l.relation::regclass, l.pid, l.mode, l.granted, a.query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE NOT l.granted;

-- 看等待链
SELECT * FROM pg_stat_activity WHERE wait_event_type = 'Lock';

-- 杀会话
SELECT pg_terminate_backend(pid);

9.3 Oracle

1
2
3
4
5
6
7
8
9
-- 看锁
SELECT s.sid, s.serial#, l.type, l.mode_held, o.object_name
FROM v$lock l
JOIN v$session s ON l.sid = s.sid
JOIN dba_objects o ON l.id1 = o.object_id
WHERE l.type = 'TM';

-- 杀会话
ALTER SYSTEM KILL SESSION 'sid,serial#';

9.4 SQL Server

1
2
3
4
5
-- 看阻塞
SELECT * FROM sys.dm_exec_requests WHERE blocking_session_id <> 0;

-- 看锁
SELECT * FROM sys.dm_tran_locks WHERE request_status = 'WAIT';

十、小结

本文学习了锁与并发控制:

  • 锁粒度层级:DB → 表 → 页 → 行
  • 锁模式矩阵:S/X/IS/IX 的兼容性
  • SQL Server 的锁升级机制(5000 行阈值)
  • InnoDB 的三档锁:Record / Gap / Next-Key
  • InnoDB RR 下的加锁规则
  • PostgreSQL 的 Advisory Lock(应用层分布式锁)
  • Oracle 的 ITL 实现行锁
  • SELECT FOR UPDATE 家族(NOWAIT / SKIP LOCKED)
  • 死锁的 Wait-For Graph 检测
  • 悲观锁 vs 乐观锁
1
2
3
4
记住三句话:
  1. MVCC 解决读写并发,锁解决写写并发
  2. SQL Server 唯一会锁升级,其他三家锁到底
  3. InnoDB 的 Next-Key Lock 是"为了 RR 防幻读"的代价

下一篇进入日志与崩溃恢复:ARIES 协议、WAL、Checkpoint、崩溃恢复流程,以及四大数据库的日志实现差异。