💻CS 数据库
锁与并发控制
难度:⭐⭐ | 高频指数:🔥🔥
面试回答
常见问法
- MySQL 有哪些锁?
- 行锁和表锁有什么区别?
- 什么是间隙锁?什么是临键锁?
- 乐观锁和悲观锁有什么区别?
- MySQL 怎么检测和处理死锁?
回答
MySQL InnoDB 的锁分类:
按粒度分:
- 表锁:锁整张表,开销小但并发低
- 行锁:锁单行记录,开销大但并发高(InnoDB 支持)
按类型分:
- 共享锁(S 锁):读锁,多个事务可以同时持有
- 排他锁(X 锁):写锁,独占,和任何锁互斥
按范围分:
- 记录锁(Record Lock):锁定单条记录
- 间隙锁(Gap Lock):锁定记录之间的间隙,防止插入
- 临键锁(Next-Key Lock):记录锁 + 间隙锁,InnoDB 默认的行锁方式
乐观锁 vs 悲观锁:
| 对比项 | 悲观锁 | 乐观锁 |
|---|---|---|
| 思想 | 假设会冲突,先加锁 | 假设不冲突,提交时检查 |
| 实现 | SELECT … FOR UPDATE | 版本号/CAS |
| 适用 | 写多读少、冲突频繁 | 读多写少、冲突少 |
| 开销 | 加锁开销 | 重试开销 |
追问
1. 间隙锁解决什么问题?
间隙锁防止其他事务在间隙中插入新记录,从而解决幻读问题。只在 RR 隔离级别下存在。
-- 假设 id 有值 1, 5, 10
SELECT * FROM t WHERE id > 5 FOR UPDATE;
-- 会锁住 (5, 10] 和 (10, +∞) 的间隙
-- 其他事务不能在这些间隙中插入新记录
2. MySQL 怎么检测死锁?
InnoDB 使用等待图(wait-for graph)检测死锁:
- 节点是事务,边表示等待关系
- 如果图中出现环,说明存在死锁
- 检测到死锁后,选择代价最小的事务回滚(通常是 undo log 最少的)
3. 行锁是怎么实现的?
InnoDB 的行锁是通过锁索引记录实现的。如果 SQL 没有用到索引,会退化为表锁(因为要扫描全表)。
原理展开
1. 共享锁和排他锁
-- 共享锁(S 锁):允许其他事务也加 S 锁,但不允许加 X 锁
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE; -- MySQL 5.x
SELECT * FROM users WHERE id = 1 FOR SHARE; -- MySQL 8.0
-- 排他锁(X 锁):不允许其他事务加任何锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- INSERT/UPDATE/DELETE 自动加 X 锁
锁兼容矩阵:
| S 锁 | X 锁 | |
|---|---|---|
| S 锁 | 兼容 | 冲突 |
| X 锁 | 冲突 | 冲突 |
2. 意向锁
意向锁是表级锁,用于快速判断表中是否有行锁:
- IS(意向共享锁):事务准备给某行加 S 锁前,先给表加 IS
- IX(意向排他锁):事务准备给某行加 X 锁前,先给表加 IX
作用: 当需要加表锁时,不需要逐行检查是否有行锁,只需检查意向锁。
3. 记录锁、间隙锁、临键锁
假设索引中有记录:1, 5, 10, 15
记录锁(Record Lock):锁定某条具体记录
锁住 id=5 这条记录
间隙锁(Gap Lock):锁定两条记录之间的间隙
锁住 (5, 10) 这个区间,防止插入
临键锁(Next-Key Lock):记录锁 + 前面的间隙锁
锁住 (5, 10],即间隙 + 记录本身
InnoDB 在 RR 下的加锁规则:
- 默认使用临键锁(Next-Key Lock)
- 等值查询命中唯一索引时,退化为记录锁
- 等值查询未命中时,退化为间隙锁
- 范围查询使用临键锁
4. 加锁分析示例
-- 表结构:id(PK), name, age(INDEX)
-- 数据:(1,'A',10), (5,'B',20), (10,'C',30)
-- 示例 1:唯一索引等值查询(命中)
SELECT * FROM t WHERE id = 5 FOR UPDATE;
-- 加锁:id=5 的记录锁(退化,不需要间隙锁)
-- 示例 2:唯一索引等值查询(未命中)
SELECT * FROM t WHERE id = 7 FOR UPDATE;
-- 加锁:(5, 10) 的间隙锁
-- 示例 3:普通索引等值查询
SELECT * FROM t WHERE age = 20 FOR UPDATE;
-- 加锁:age 索引上 (10, 20] 的临键锁 + (20, 30) 的间隙锁
-- + 对应主键的记录锁
-- 示例 4:范围查询
SELECT * FROM t WHERE id > 5 FOR UPDATE;
-- 加锁:(5, 10], (10, +∞) 的临键锁
5. 乐观锁实现
-- 方式 1:版本号
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 如果 affected_rows = 0,说明被其他事务修改了,需要重试
-- 方式 2:CAS(Compare And Swap)
UPDATE products
SET stock = stock - 1
WHERE id = 1 AND stock = 10; -- 用旧值做条件
乐观锁的适用场景:
- 读多写少
- 冲突概率低
- 不想长时间持有锁
乐观锁的问题:
- 冲突频繁时大量重试,性能反而差
- ABA 问题(版本号方案可以解决)
6. 悲观锁实现
-- 悲观锁:先锁再操作
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 加 X 锁
-- 其他事务对 id=1 的写操作会阻塞
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT; -- 释放锁
悲观锁的注意事项:
- 必须在事务中使用
- 持锁时间要短,避免阻塞其他事务
- 可能导致死锁
7. 死锁的产生和处理
死锁示例:
-- 事务 A 事务 B
BEGIN; BEGIN;
UPDATE t SET v=1 WHERE id=1; UPDATE t SET v=1 WHERE id=2;
-- 持有 id=1 的 X 锁 -- 持有 id=2 的 X 锁
UPDATE t SET v=1 WHERE id=2; UPDATE t SET v=1 WHERE id=1;
-- 等待 id=2 的 X 锁 -- 等待 id=1 的 X 锁
-- 死锁!
InnoDB 死锁处理:
- 等待超时(
innodb_lock_wait_timeout,默认 50 秒) - 死锁检测(
innodb_deadlock_detect,默认开启) - 检测到死锁后,回滚代价最小的事务
减少死锁的方法:
- 按固定顺序访问表和行
- 事务尽量短小
- 降低隔离级别(RC 没有间隙锁,死锁概率低)
- 合理使用索引(避免行锁退化为表锁)
8. 锁监控
-- 查看当前锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
-- 查看当前持有的锁
SELECT * FROM information_schema.INNODB_LOCKS;
-- 查看最近的死锁信息
SHOW ENGINE INNODB STATUS; -- 找 LATEST DETECTED DEADLOCK 部分
-- MySQL 8.0
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
易错点
- 说”InnoDB 行锁锁的是行”——实际锁的是索引记录,没有索引就退化为表锁。
- 混淆间隙锁和临键锁——间隙锁是开区间 (a,b),临键锁是左开右闭 (a,b]。
- 说”乐观锁不加锁”——乐观锁在 UPDATE 时仍然会加行锁,只是不提前加锁。
- 忘记间隙锁只在 RR 下存在——RC 隔离级别没有间隙锁。
- 说”死锁只能等超时”——InnoDB 有主动死锁检测,通常秒级发现并回滚。
- 不知道行锁退化为表锁的条件——没有用到索引时,行锁退化为表锁。
记忆技巧
- 锁粒度:表锁(开销小并发低)→ 行锁(开销大并发高)
- 三种行锁:记录锁(锁一条)、间隙锁(锁间隙)、临键锁(锁记录+间隙)
- 乐观 vs 悲观一句话:乐观先做后检查,悲观先锁后操作
- 死锁处理:检测(等待图有环)→ 回滚(代价最小的事务)
- 行锁本质:锁索引,无索引则锁表
面试速答版
InnoDB 支持行锁和表锁。行锁通过锁索引记录实现,没有索引会退化为表锁。行锁分为记录锁(锁单条记录)、间隙锁(锁记录间隙防插入)、临键锁(记录+间隙,默认方式)。间隙锁只在 RR 下存在,用于解决幻读。共享锁允许并发读,排他锁独占。乐观锁通过版本号在提交时检查冲突,适合读多写少;悲观锁通过 FOR UPDATE 提前加锁,适合写多冲突频繁。InnoDB 通过等待图检测死锁,发现后回滚代价最小的事务。减少死锁:固定访问顺序、事务尽量短、合理使用索引。
Related · 数据库