什么是数据库锁?行锁、表锁、意向锁一次讲清
一、先解决一个灵魂问题:为什么需要锁?
想象一个银行转账场景:
账户A余额:10000元
账户B余额:10000元
线程1:A 转账 2000 到 B
线程2:同时查询 A 的余额
如果没有锁,可能发生:
线程1:读取 A=10000
线程2:读取 A=10000 ← 此时线程1还没扣款
线程1:A = 10000 - 2000 = 8000
线程1:B = 10000 + 2000 = 12000
线程2读到的 A=10000 是脏数据——它不知道线程1正在修改。
锁的本质就是:在一段时间内,对某个数据的访问权限进行控制,保证数据一致性。
二、锁的粒度:从大到小排排坐
数据库锁
├── 表级锁(Table Lock) -- 锁整张表
├── 页级锁(Page Lock) -- 锁一页数据(InnoDB内部)
└── 行级锁(Row Lock) -- 锁一行数据
粒度越小,并发越高,但锁的开销越大。
📌 MySQL 的 InnoDB 引擎,默认是行级锁,但也会自动升级为表锁。
三、表锁:简单粗暴,但杀伤力大
3.1 什么是表锁?
一次锁住整张表,其他线程对这张表的任何操作都得等。
sql
-- 显式加表锁
LOCK TABLES user READ; -- 读锁:别人只能读,不能写
-- 你的查询操作...
UNLOCK TABLES;
LOCK TABLES user WRITE; -- 写锁:别人读写都不行
-- 你的修改操作...
UNLOCK TABLES;
3.2 表锁的两种模式
| 锁类型 | 别名 | 读 | 写 | 兼容性 |
|---|---|---|---|---|
| 读锁 | 共享锁(S锁) | ✅ | ❌ | 多个读锁可以共存 |
| 写锁 | 排他锁(X锁) | ❌ | ❌ | 写锁独占,谁都不能进 |
兼容性矩阵:
| S锁(读) | X锁(写) | |
|---|---|---|
| S锁 | ✅ 兼容 | ❌ 互斥 |
| X锁 | ❌ 互斥 | ❌ 互斥 |
简单记:读读不冲突,读写冲突,写写冲突。
3.3 谁会加表锁?
MyISAM 引擎:默认就是表锁,不管你查一行还是查一万行,整张表都锁住。
sql
-- MyISAM 下,这两句都会锁表
SELECT * FROM user WHERE id = 1;
UPDATE user SET name = '张三' WHERE id = 1;
InnoDB 引擎:一般不加表锁,但以下情况会自动升级:
| 触发条件 | 原因 |
|---|---|
| 更新没有索引的列 | InnoDB 没法定位到行,只能锁表 |
| 语句影响的行数超过阈值(约5000行) | 行锁太多,不如直接锁表 |
显式加锁 LOCK TABLES | 你自己要求的 |
3.4 表锁的优缺点
| 优点 | 缺点 |
|---|---|
| 加锁快,开销小 | 并发能力极差 |
| 不会出现死锁(一次锁一张表) | 一锁锁全表,其他人全等 |
| 适合读多写少的场景 | 写操作会被严重阻塞 |
四、行锁:精细控制,但有讲究
4.1 什么是行锁?
只锁住被操作的那一行,其他行不受影响。
sql
-- InnoDB 默认行为
UPDATE user SET name = '张三' WHERE id = 1;
-- 只锁 id=1 这一行,其他线程还能改 id=2, id=3...
4.2 行锁的两种实现方式
InnoDB 的行锁,底层其实是通过索引实现的:
| 方式 | 说明 | 举例 |
|---|---|---|
| 记录锁(Record Lock) | 锁住索引记录本身 | WHERE id = 1,锁住 id=1 的索引项 |
| 间隙锁(Gap Lock) | 锁住索引之间的间隙,防止插入 | WHERE id = 1,锁住 (0,1) 和 (1,2) 的间隙 |
| 临键锁(Next-Key Lock) | 记录锁 + 间隙锁,锁住一个左开右闭区间 | 锁住 (0, 1] |
📌 很多人以为行锁就是锁一行,其实 InnoDB 默认用的是临键锁,锁的是一个范围。这就是为什么有时候你明明只改了一行,却发现别人也插不进数据。
4.3 举个例子感受一下
假设表中有 id:1, 3, 5, 7
sql
-- 事务A
BEGIN;
SELECT * FROM user WHERE id = 3 FOR UPDATE; -- 临键锁:锁住 (1, 3]
-- 事务B(此时执行)
INSERT INTO user (id) VALUES (2); -- ❌ 阻塞!间隙 (1,3) 被锁了
INSERT INTO user (id) VALUES (4); -- ✅ 成功!间隙 (3,5) 没被锁
这就是间隙锁的作用:防止幻读。
4.4 行锁的加锁时机
| 操作 | 加锁时机 |
|---|---|
SELECT ... FOR UPDATE | 读取时加锁 |
SELECT ... LOCK IN SHARE MODE | 读取时加共享锁 |
UPDATE / DELETE | 找到记录时加锁 |
INSERT | 插入时加间隙锁(防止幻读) |
普通 SELECT | ❌ 不加锁(MVCC 机制保证一致性) |
📌 普通查询不加锁,靠的是 MVCC(多版本并发控制) ——读的是快照,写的是新版本,读写互不阻塞。这是 InnoDB 并发能力强的核心原因之一。
4.5 行锁的死锁问题
行锁虽然并发高,但因为锁的粒度细,死锁概率反而更高。
sql
-- 事务A
BEGIN;
UPDATE user SET name = 'A' WHERE id = 1; -- 锁住 id=1
UPDATE user SET name = 'B' WHERE id = 2; -- 等 id=2 的锁
-- 事务B(同时执行)
BEGIN;
UPDATE user SET name = 'C' WHERE id = 2; -- 锁住 id=2
UPDATE user SET name = 'D' WHERE id = 1; -- 等 id=1 的锁 → 死锁!
死锁解决:InnoDB 会自动检测死锁,回滚代价较小的那个事务。
📌 避免死锁的技巧:
- 多个表操作时,按固定顺序加锁
- 大事务拆小事务
- 尽量用索引查询,减少锁的范围
五、意向锁:你可能没听过,但它一直在工作
5.1 为什么需要意向锁?
回到表锁和行锁的矛盾:
事务A:想给整张表加表锁
事务B:已经锁了表里的某几行
如果没有意向锁,事务A 加表锁前,必须逐行检查:
"id=1 锁了吗?id=2 锁了吗?id=3 锁了吗?..."
-- 100万行?查到天荒地老
意向锁就是一个"标记":告诉别人"这张表里面有人加了行锁,你想锁表就得等"。
5.2 意向锁的类型
| 锁类型 | 含义 | 谁加的 |
|---|---|---|
| IS(意向共享锁) | "我打算给某些行加共享锁" | 事务在加行级共享锁之前先加 IS |
| IX(意向排他锁) | "我打算给某些行加排他锁" | 事务在加行级排他锁之前先加 IX |
5.3 意向锁的兼容性
| IS | IX | 表级S锁 | 表级X锁 | |
|---|---|---|---|---|
| IS | ✅ | ✅ | ✅ | ❌ |
| IX | ✅ | ✅ | ❌ | ❌ |
| 表S | ✅ | ❌ | ✅ | ❌ |
| 表X | ❌ | ❌ | ❌ | ❌ |
关键点:
- 意向锁之间互相兼容(IS 和 IX 可以共存)
- 表级共享锁和意向锁兼容(因为读锁不冲突)
- 表级排他锁和任何意向锁都互斥(你想锁整张表,但里面已经有行锁了,不行)
5.4 加锁过程图解
事务A:UPDATE user SET name='张三' WHERE id=1;
加锁顺序:
1. 先给 id=1 的记录加 X 锁(行锁)
2. 再给整张 user 表加 IX 锁(意向锁)
事务B:LOCK TABLES user WRITE; -- 想锁整张表
加锁顺序:
1. 试图给整张表加 X 锁
2. 检查到表上已经有 IX 锁 → 冲突,等待!
→ 事务B 不用逐行检查,看到 IX 就知道"表里有人在改"
5.5 一句话理解意向锁
意向锁 = 表级的"预告片":我要锁表里的某些行了,你想锁整张表就先等等。
它本身不阻塞任何操作,只是一个信号,让表锁和行锁之间的冲突检测从"逐行扫描"变成"一眼判断"。
六、三种锁对比总结
| 维度 | 表锁 | 行锁 | 意向锁 |
|---|---|---|---|
| 锁的范围 | 整张表 | 一行或多行 | 整张表(只是标记) |
| 加锁开销 | 小 | 大 | 极小 |
| 并发能力 | 差 | 好 | 不影响并发 |
| 死锁风险 | 几乎没有 | 较高 | 无 |
| 谁用得多 | MyISAM | InnoDB | InnoDB(内部自动加) |
| 典型场景 | 读多写少,全表扫描 | 高并发增删改 | 行锁和表锁之间的协调 |
| 用户能直接控制吗 | ✅ LOCK TABLES | ✅ SELECT ... FOR UPDATE | ❌ 自动加,不可手动控制 |
七、InnoDB 锁的完整体系(一张图搞懂)
InnoDB 锁体系
│
├── 按粒度分
│ ├── 表锁(LOCK TABLES,用户显式加)
│ ├── 行锁(Record Lock / Gap Lock / Next-Key Lock)
│ └── 意向锁(IS / IX,自动加)
│
├── 按模式分
│ ├── 共享锁(S锁):读锁,别人也能读,不能写
│ └── 排他锁(X锁):写锁,别人读写都不行
│
├── 按场景分
│ ├── DML 锁:UPDATE/DELETE/INSERT 自动加的行锁
│ ├── 查询锁:SELECT ... FOR UPDATE / LOCK IN SHARE MODE
│ └── 表级锁:LOCK TABLES 显式加
│
└── MVCC(不是锁,但实现了读写不阻塞)
├── 普通 SELECT 读快照,不加锁
└── 写操作生成新版本,读旧版本
八、实战:你一定会遇到的锁问题
问题1:为什么我的 UPDATE 锁住了整张表?
sql
-- user 表的 name 列没有索引
UPDATE user SET name = '张三' WHERE name = '李四';
原因:name 没索引,InnoDB 找不到具体哪些行要改,只能全表扫描 + 锁所有扫描到的行,等价于锁表。
解决:给 name 加索引。
问题2:为什么并发插入时出现死锁?
sql
-- 两个事务同时插入,且有唯一索引
-- 事务A:INSERT ... ON DUPLICATE KEY UPDATE id=1
-- 事务B:INSERT ... ON DUPLICATE KEY UPDATE id=2
-- 如果唯一键冲突,都会加间隙锁,可能死锁
原因:唯一索引的插入也会加间隙锁,两个事务互相等对方释放间隙锁。
解决:
- 捕获死锁异常,自动重试
- 插入顺序保持一致
问题3:SELECT ... FOR UPDATE 到底锁了什么?
sql
-- 假设 id 是主键,有索引
SELECT * FROM user WHERE id = 1 FOR UPDATE;
-- 锁住:id=1 的记录 + (0,1) 和 (1,2) 的间隙 → 临键锁
-- 假设 age 没有索引
SELECT * FROM user WHERE age = 25 FOR UPDATE;
-- 锁住:全表!因为没索引,只能全表扫描,锁所有行
📌 没有索引的 FOR UPDATE = 锁表,这是最常见的"行锁变表锁"的坑。
九、最后一张速查表
| 场景 | 实际加的锁 | 备注 |
|---|---|---|
SELECT ... FOR UPDATE(有索引) | 行级排他锁(临键锁) | 锁记录+间隙 |
SELECT ... LOCK IN SHARE MODE | 行级共享锁 | 别人可以读,不能改 |
UPDATE/DELETE(有索引) | 行级排他锁 | 找到记录就锁 |
UPDATE/DELETE(无索引) | 表锁 | 全表扫描,锁所有行 |
INSERT | 间隙锁(防止幻读) | 插入前先锁住目标位置的间隙 |
LOCK TABLES ... WRITE | 表级排他锁 | 显式锁表 |
| 事务开始加行锁前 | 自动加意向锁(IX) | 协调表锁和行锁 |
十、一句话总结
表锁简单粗暴,行锁精细但有坑,意向锁是幕后协调员。
InnoDB 默认行锁,但没索引就变表锁;
有索引还得注意间隙锁和临键锁;
意向锁你看不见,但每次行锁都有它在帮忙。理解锁,不是为了背概念,是为了写 SQL 时知道:这句话下去,会锁住什么,会阻塞谁。