写在前面
承接前一篇 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、崩溃恢复流程,以及四大数据库的日志实现差异。