一、引入场景:那个让我彻夜难眠的线上故障
还记得三年前的那个周五晚上,我正准备下班去约会,突然收到告警:订单系统出现大量超时。查日志发现一个诡异现象:
-- 用户A在更新订单状态
UPDATE orders SET status = 'paid' WHERE order_id = 12345;
-- 同时用户B也在更新同一订单的地址
UPDATE orders SET address = '新地址' WHERE order_id = 12345;
结果?第二条SQL直接卡住了30秒!😱
更诡异的是,当我尝试查询这条记录时:
SELECT * FROM orders WHERE order_id = 12345;
查询也被阻塞了! 明明只是读取数据,为什么会被写操作卡住?
那一晚我通过 SHOW ENGINE INNODB STATUS 命令,第一次真正见识到了 MySQL 锁的"威力":行锁、表锁、间隙锁、临键锁...各种锁交织在一起,形成了一个复杂的死锁场景。
这个故障让我意识到:不深入理解 MySQL 的锁机制,就像在雷区蒙眼狂奔 ⚡️
后来在无数次面试中,我发现锁机制是 MySQL 考察的核心点之一。面试官最喜欢问:
- "为什么你的 SELECT 语句会被阻塞?"
- "行锁是怎么升级成表锁的?"
- "间隙锁到底锁的是什么?"
- "如何避免死锁?"
今天这篇文章,我会把这三年踩过的坑和面试官问过的问题,系统性地讲清楚。
二、快速理解:一句话说清 MySQL 锁
通俗版
MySQL 的锁就像停车场的车位管理:有的位置只能停一辆车(排他锁),有的位置可以多人观看但不能动(共享锁),有的甚至会把相邻的位置也锁住(间隙锁)。🅿️
技术定义
MySQL 锁(Lock)是数据库系统用于管理并发访问的同步机制,通过控制多个事务对共享资源的访问顺序,保证数据的一致性和完整性。 它是实现 ACID 特性中隔离性(Isolation)的核心手段。
核心分类维度
graph TB
A[MySQL 锁机制] --> B[按锁粒度分类]
A --> C[按锁模式分类]
A --> D[按锁算法分类]
B --> B1[全局锁 Global Lock]
B --> B2[表级锁 Table Lock]
B --> B3[行级锁 Row Lock]
C --> C1[共享锁 S Lock]
C --> C2[排他锁 X Lock]
C --> C3[意向锁 Intention Lock]
D --> D1[记录锁 Record Lock]
D --> D2[间隙锁 Gap Lock]
D --> D3[临键锁 Next-Key Lock]
D --> D4[插入意向锁 Insert Intention Lock]
三、为什么需要锁机制?🤔
核心痛点:并发访问的三大难题
问题1:脏读(Dirty Read)
-- 时间线场景
-- T1: 事务A修改数据但未提交
UPDATE account SET balance = 500 WHERE id = 1; -- 原值1000
-- T2: 事务B读取到未提交的数据
SELECT balance FROM account WHERE id = 1; -- 读到500 😱
-- T3: 事务A回滚
ROLLBACK; -- 数据恢复到1000
-- 结果:事务B读到了"从未存在"的数据
问题2:不可重复读(Non-Repeatable Read)
-- T1: 事务A第一次读取
SELECT balance FROM account WHERE id = 1; -- 读到1000
-- T2: 事务B修改并提交
UPDATE account SET balance = 500 WHERE id = 1;
COMMIT;
-- T3: 事务A第二次读取同一条记录
SELECT balance FROM account WHERE id = 1; -- 读到500(值变了!)
问题3:幻读(Phantom Read)
-- T1: 事务A统计总金额
SELECT SUM(balance) FROM account WHERE user_id = 100; -- 假设是10000
-- T2: 事务B插入新记录并提交
INSERT INTO account (user_id, balance) VALUES (100, 5000);
COMMIT;
-- T3: 事务A再次统计
SELECT SUM(balance) FROM account WHERE user_id = 100; -- 变成15000(多了一行!)👻
锁机制 vs 其他方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 悲观锁(Lock) | 先加锁再访问 | 数据强一致性 | 并发性能低,可能死锁 | 写多读少,对一致性要求极高 |
| 乐观锁(Version) | 不加锁,提交时检查版本号 | 并发性能高,无死锁 | 高冲突场景下大量重试 | 读多写少,可接受偶尔失败重试 |
| MVCC | 多版本并发控制 | 读写不阻塞 | 需要额外空间存储版本链 | 读多写少,MySQL InnoDB默认 |
| 串行化执行 | 完全串行化 | 实现简单 | 性能极差 | 单用户系统 |
适用与不适用场景
✅ 适合使用锁的场景:
- 金融交易系统(扣款、转账必须强一致)
- 库存扣减(防止超卖)
- 订单状态流转(状态机严格控制)
- 唯一性约束更新(防止重复数据)
❌ 不适合过度依赖锁的场景:
- 高并发读多写少(应该用 MVCC + 乐观锁)
- 统计分析类查询(可以用快照读)
- 缓存更新(用缓存过期机制)
- 日志写入(用消息队列异步化)
四、基础用法:手把手教你加锁 🔧
4.1 全局锁(Global Lock)
-- 场景:全库逻辑备份
-- ⚠️ 注意:会导致整个库只读,线上慎用!
-- 加全局读锁
FLUSH TABLES WITH READ LOCK; -- 🔥 面试必考:flush tables作用
-- 执行备份
mysqldump -uroot -p database_name > backup.sql
-- 释放全局锁
UNLOCK TABLES;
-- 更优雅的方案(推荐):
-- 使用 --single-transaction 参数(基于MVCC,不阻塞写)
mysqldump --single-transaction -uroot -p database_name > backup.sql
🔥 面试高频问题:
Q: FLUSH TABLES WITH READ LOCK 会阻塞哪些操作?
A: 会阻塞所有 DML(INSERT/UPDATE/DELETE)和 DDL(ALTER TABLE)操作,但不阻塞 SELECT。
4.2 表级锁(Table Lock)
-- 显式表锁(很少用,了解即可)
LOCK TABLES orders READ; -- 共享锁:可读不可写
LOCK TABLES orders WRITE; -- 排他锁:独占访问
-- 释放表锁
UNLOCK TABLES;
-- 🔥 重点:元数据锁(MDL Lock)- 自动加锁
-- 场景1:查询时自动加MDL读锁
SELECT * FROM orders WHERE id = 1; -- 自动加MDL读锁
-- 场景2:修改表结构时自动加MDL写锁
ALTER TABLE orders ADD COLUMN remark VARCHAR(255); -- 自动加MDL写锁
-- ⚠️ 此时所有对orders表的查询都会被阻塞!
🔥 面试高频问题:
Q: 为什么 ALTER TABLE 会导致线上故障?
A: 因为 ALTER TABLE 需要获取 MDL 写锁,会阻塞所有对该表的访问。如果有长事务持有 MDL 读锁,ALTER 会一直等待,后续所有请求都会排队,导致雪崩。
4.3 行级锁(Row Lock)- 重点 ⭐⭐⭐
-- InnoDB 引擎默认使用行级锁
-- 1. 共享锁(S锁):允许多个事务同时读
SELECT * FROM orders WHERE id = 1 LOCK IN SHARE MODE; -- 旧语法
SELECT * FROM orders WHERE id = 1 FOR SHARE; -- MySQL 8.0+ 新语法
-- 2. 排他锁(X锁):独占访问
SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- 🔥 面试必问:FOR UPDATE作用
-- 3. 隐式加锁:DML自动加排他锁
UPDATE orders SET status = 'paid' WHERE id = 1; -- 自动对id=1的行加X锁
DELETE FROM orders WHERE id = 1; -- 自动对id=1的行加X锁
INSERT INTO orders VALUES (...); -- 自动对新行加X锁
-- 🔥 重点:锁的范围取决于索引
-- 场景1:主键索引 - 只锁一行
UPDATE orders SET status = 'paid' WHERE id = 1; -- 只锁 id=1 这一行
-- 场景2:普通索引 - 锁索引记录 + 间隙
UPDATE orders SET status = 'paid' WHERE user_id = 100; -- 可能锁多行
-- 场景3:无索引 - 全表锁!😱
UPDATE orders SET status = 'paid' WHERE remark = '备注'; -- 如果remark无索引,锁全表!
4.4 实战示例:秒杀系统库存扣减
-- ❌ 错误写法:存在并发问题
-- 线程A和B同时执行,可能导致库存超卖
SELECT stock FROM product WHERE id = 100; -- 查到库存10
-- 假设此时其他线程也查到库存10
UPDATE product SET stock = stock - 1 WHERE id = 100; -- 两个线程都扣减成功
-- ✅ 正确写法1:FOR UPDATE 悲观锁
START TRANSACTION;
SELECT stock FROM product WHERE id = 100 FOR UPDATE; -- 🔒 加排他锁
-- 此时其他事务的 FOR UPDATE 会阻塞等待
IF (stock > 0) THEN
UPDATE product SET stock = stock - 1 WHERE id = 100;
COMMIT;
ELSE
ROLLBACK;
END IF;
-- ✅ 正确写法2:直接UPDATE(推荐,性能更好)
UPDATE product
SET stock = stock - 1
WHERE id = 100 AND stock > 0; -- 🔥 面试常考:利用数据库原子性
-- 检查影响行数
IF (ROW_COUNT() > 0) THEN
-- 扣减成功
ELSE
-- 库存不足
END IF;
-- ✅ 正确写法3:乐观锁(版本号)
-- 1. 查询当前版本号
SELECT stock, version FROM product WHERE id = 100; -- stock=10, version=5
-- 2. 更新时校验版本号
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5; -- 如果version已改变,更新失败
-- 3. 检查影响行数,失败则重试
IF (ROW_COUNT() == 0) THEN
-- 版本号冲突,需要重试
END IF;
🔥 面试高频问题:
Q: FOR UPDATE 和 直接UPDATE 有什么区别?
A:
- FOR UPDATE:先查询并加锁,适合需要基于查询结果做复杂业务逻辑的场景
- 直接UPDATE:一次性完成查询+加锁+更新,性能更好,适合简单的库存扣减
- 推荐优先使用直接UPDATE,减少锁持有时间
五、⭐ 底层原理深挖:这才是面试的核心战场 🔥
5.1 锁的兼容性矩阵(必须背下来!)
graph TB
subgraph "锁兼容性矩阵"
A[当前持有] -->|IS| B1[意向共享锁]
A -->|IX| B2[意向排他锁]
A -->|S| B3[共享锁]
A -->|X| B4[排他锁]
B1 -->|请求IS| C1[✅ 兼容]
B1 -->|请求IX| C2[✅ 兼容]
B1 -->|请求S| C3[✅ 兼容]
B1 -->|请求X| C4[❌ 冲突]
B2 -->|请求IS| D1[✅ 兼容]
B2 -->|请求IX| D2[✅ 兼容]
B2 -->|请求S| D3[❌ 冲突]
B2 -->|请求X| D4[❌ 冲突]
B3 -->|请求S| E1[✅ 兼容]
B3 -->|请求X| E2[❌ 冲突]
B4 -->|请求任何锁| F1[❌ 全部冲突]
end
兼容性表格(⭐⭐⭐ 面试必考):
| 当前持有 ↓ \ 请求锁 → | IS | IX | S | X |
|---|---|---|---|---|
| IS(意向共享锁) | ✅ | ✅ | ✅ | ❌ |
| IX(意向排他锁) | ✅ | ✅ | ❌ | ❌ |
| S(共享锁) | ✅ | ❌ | ✅ | ❌ |
| X(排他锁) | ❌ | ❌ | ❌ | ❌ |
🔥 记忆口诀:
- X锁霸道总裁:谁都不兼容 👑
- S锁好好先生:可以和其他S锁、IS锁共享 🤝
- 意向锁只在表级:不阻塞行级锁,只用于快速判断表锁冲突
5.2 InnoDB 行锁的三种算法(核心中的核心!)
5.2.1 记录锁(Record Lock)
定义: 锁住索引记录本身,不锁记录间的间隙。
-- 前提条件:id是主键或唯一索引
-- 表数据:id = 1, 5, 10, 15, 20
-- 事务A:锁住 id=10 这一行
SELECT * FROM users WHERE id = 10 FOR UPDATE;
-- 事务B:可以插入 id=9 或 id=11(记录锁不锁间隙)
INSERT INTO users (id, name) VALUES (9, 'Alice'); -- ✅ 成功
-- 事务C:不能修改 id=10(记录被锁)
UPDATE users SET name = 'Bob' WHERE id = 10; -- ❌ 阻塞
底层实现:
// InnoDB源码简化版(lock0lock.cc)
lock_rec_create(
lock_mode, // LOCK_X 或 LOCK_S
block, // 数据页
heap_no, // 记录在页中的槽位号
index, // 索引对象
trx // 事务对象
);
// 锁信息存储在内存的哈希表中
// 结构:lock_sys->rec_hash
// 键:space_id + page_no + heap_no
可视化示意:
索引树(B+树):
[10]
/ \
[1,5] [15,20]
记录锁只锁住节点10本身:
🔒[10] <- 被锁住
/ \
[1,5] [15,20] <- 不受影响
5.2.2 间隙锁(Gap Lock)- 面试重点 🔥🔥🔥
定义: 锁住索引记录之间的间隙,防止其他事务插入数据(解决幻读)。
-- 表数据:id = 1, 5, 10, 15, 20
-- 事务A:范围查询
SELECT * FROM users WHERE id > 5 AND id < 15 FOR UPDATE;
-- 间隙锁会锁住三个区间:
-- (5, 10) <- 间隙1
-- (10, 15) <- 间隙2
-- 注意:不锁 id=10 的记录本身(仅在RC隔离级别)
-- 事务B:不能插入到间隙中
INSERT INTO users (id, name) VALUES (7, 'Alice'); -- ❌ 阻塞(落在间隙(5,10))
INSERT INTO users (id, name) VALUES (12, 'Bob'); -- ❌ 阻塞(落在间隙(10,15))
-- 事务C:可以插入到间隙外
INSERT INTO users (id, name) VALUES (3, 'Charlie'); -- ✅ 成功(不在锁定范围)
INSERT INTO users (id, name) VALUES (16, 'David'); -- ✅ 成功
间隙锁的边界计算规则:
graph LR
A[(-∞, 1)] --> B[(1, 5)]
B --> C[(5, 10)]
C --> D[(10, 15)]
D --> E[(15, 20)]
E --> F[(20, +∞)]
style C fill:#ff6b6b
style D fill:#ff6b6b
G[WHERE id > 5 AND id < 15] -.锁住.-> C
G -.锁住.-> D
🔥 面试高频问题:
Q1: 间隙锁的目的是什么?
A: 防止幻读。在可重复读(RR)隔离级别下,通过锁住间隙防止其他事务插入新数据。
Q2: 为什么 READ COMMITTED 隔离级别没有间隙锁?
A: RC级别允许不可重复读和幻读,只需要记录锁保证不读脏数据即可。RR级别需要可重复读,必须用间隙锁防止幻读。
5.2.3 临键锁(Next-Key Lock)- 默认锁 🔥🔥🔥
定义: 记录锁 + 间隙锁的组合,锁住记录本身以及记录前的间隙。
-- InnoDB 在 RR 隔离级别下的默认行锁算法
-- 表数据:id = 1, 5, 10, 15, 20
-- 事务A
SELECT * FROM users WHERE id = 10 FOR UPDATE;
-- 临键锁锁定范围:(5, 10]
-- 包含:
-- 1. 间隙锁:(5, 10) - 防止插入id在6-9的记录
-- 2. 记录锁:id=10 - 锁住记录本身
-- 事务B
INSERT INTO users VALUES (7, 'Alice'); -- ❌ 阻塞(落在间隙)
UPDATE users SET name='Bob' WHERE id=10; -- ❌ 阻塞(记录被锁)
INSERT INTO users VALUES (11, 'Charlie');-- ✅ 成功(不在锁定范围)
临键锁的退化机制(⭐⭐⭐ 必考):
-- 场景1:唯一索引等值查询且记录存在 -> 退化为记录锁
SELECT * FROM users WHERE id = 10 FOR UPDATE;
-- 锁范围:只锁 id=10,不锁间隙
-- 场景2:唯一索引等值查询且记录不存在 -> 退化为间隙锁
SELECT * FROM users WHERE id = 7 FOR UPDATE; -- 记录不存在
-- 锁范围:(5, 10) 间隙
-- 场景3:非唯一索引 -> 临键锁 + 间隙锁
-- 假设 age 是普通索引,数据:age = 10, 10, 20
SELECT * FROM users WHERE age = 10 FOR UPDATE;
-- 锁范围:(-∞, 10] + (10, 20) <- 包含所有age=10的记录及下一个间隙
可视化示意(临键锁范围):
索引值: 1 5 10 15 20
区间: (-∞,1] (1,5] (5,10] (10,15] (15,20] (20,+∞)
临键锁 WHERE id = 10:
锁定区间
↓
├─────┤
(5 10]
间隙 记录
5.3 锁的底层数据结构
InnoDB 锁的内存结构:
// lock_sys 全局锁系统(源码:lock0lock.h)
struct lock_sys_t {
hash_table_t* rec_hash; // 行锁哈希表(记录锁)
hash_table_t* prdt_hash; // 谓词锁(空间索引)
UT_LIST_BASE_NODE_T(lock_t) table_locks; // 表锁链表
lock_sys_waits; // 等待队列
};
// 单个锁对象
struct lock_t {
trx_t* trx; // 持有锁的事务
UT_LIST_NODE_T locks; // 链表节点(同一事务的所有锁)
lock_table_t tab_lock; // 表锁信息
lock_rec_t rec_lock; // 行锁信息
ulint type_mode; // 锁类型:LOCK_TABLE | LOCK_REC
// 锁模式:LOCK_S | LOCK_X | LOCK_IS | LOCK_IX
};
// 行锁额外信息
struct lock_rec_t {
ulint space; // 表空间ID
ulint page_no; // 页号
ulint n_bits; // 位图大小
// 位图:每一位代表页中的一条记录是否被锁
// 例如:0001 0100 表示第3和第6条记录被锁
};
锁等待队列示意图:
sequenceDiagram
participant T1 as 事务1
participant Lock as 锁系统
participant T2 as 事务2
participant T3 as 事务3
T1->>Lock: 请求 X 锁(id=10)
Lock->>T1: ✅ 授予锁
T2->>Lock: 请求 X 锁(id=10)
Lock->>T2: ❌ 加入等待队列
T3->>Lock: 请求 S 锁(id=10)
Lock->>T3: ❌ 加入等待队列
Note over Lock: 等待队列:T2(X) -> T3(S)
T1->>Lock: 释放 X 锁
Lock->>T2: ✅ 唤醒T2,授予 X 锁
T2->>Lock: 释放 X 锁
Lock->>T3: ✅ 唤醒T3,授予 S 锁
5.4 死锁的产生与检测(⭐⭐⭐ 超高频考点)
死锁场景1:相反顺序加锁
-- 事务A
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE id = 1; -- 🔒 锁住id=1
-- 等待1秒...
UPDATE orders SET status = 'paid' WHERE id = 2; -- ⏳ 等待id=2的锁
-- 事务B(同时执行)
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE id = 2; -- 🔒 锁住id=2
-- 等待1秒...
UPDATE orders SET status = 'paid' WHERE id = 1; -- ⏳ 等待id=1的锁
-- 结果:死锁!💀
-- MySQL会检测到死锁,回滚其中一个事务
死锁检测算法(Wait-For Graph):
graph LR
T1[事务A] -->|等待| R2[资源id=2]
R2 -->|持有者| T2[事务B]
T2 -->|等待| R1[资源id=1]
R1 -->|持有者| T1
style T1 fill:#ff6b6b
style T2 fill:#ff6b6b
Note1[检测到环路 -> 死锁]
源码实现(简化版):
// lock0lock.cc - 死锁检测
static ulint lock_deadlock_check_and_resolve() {
// 1. 构建等待图(Wait-For Graph)
for (trx in waiting_transactions) {
add_edge(trx, trx->wait_lock->trx); // 从等待者指向持有者
}
// 2. DFS检测环路
if (detect_cycle()) {
// 3. 选择牺牲者(回滚undo记录少的事务)
victim = choose_victim(); // 选择代价最小的事务
rollback(victim);
return DEADLOCK_RESOLVED;
}
}
死锁场景2:间隙锁冲突
-- 表数据:id = 5, 10, 15
-- 事务A
START TRANSACTION;
SELECT * FROM users WHERE id = 7 FOR UPDATE; -- 间隙锁 (5, 10)
-- 事务B
START TRANSACTION;
SELECT * FROM users WHERE id = 8 FOR UPDATE; -- 间隙锁 (5, 10) - 兼容!✅
-- 此时两个事务都持有 (5, 10) 的间隙锁
-- 事务A尝试插入
INSERT INTO users VALUES (7, 'Alice'); -- ⏳ 等待事务B的间隙锁
-- 事务B尝试插入
INSERT INTO users VALUES (8, 'Bob'); -- ⏳ 等待事务A的间隙锁
-- 死锁!💀
🔥 为什么间隙锁之间兼容,但插入会阻塞?
答案:
- 间隙锁之间兼容:多个事务可以同时持有同一间隙的间隙锁(用于防止其他事务插入)
- 插入意向锁与间隙锁冲突:INSERT 需要获取插入意向锁(Insert Intention Lock),它与间隙锁不兼容
- 插入意向锁之间兼容:多个INSERT可以同时进行(只要不是同一个值)
插入意向锁示意图:
graph TB
A[间隙锁 Gap Lock] -->|锁住| B[间隙 5-10]
C[插入意向锁] -->|请求插入| B
A -.冲突.-> C
D[插入意向锁1] -->|插入id=7| B
E[插入意向锁2] -->|插入id=8| B
D -.兼容.-> E
style A fill:#4ecdc4
style C fill:#ff6b6b
5.5 MVCC 与锁的协作(重要!)
快照读 vs 当前读:
| 操作类型 | SQL示例 | 是否加锁 | 读取版本 |
|---|---|---|---|
| 快照读 | SELECT | ❌ 不加锁 | 读取历史版本(undo log) |
| 当前读 | SELECT ... FOR UPDATE | ✅ 加X锁 | 读取最新版本 |
| 当前读 | SELECT ... FOR SHARE | ✅ 加S锁 | 读取最新版本 |
| 当前读 | UPDATE/DELETE/INSERT | ✅ 加X锁 | 读取最新版本 |
MVCC版本链结构:
当前数据:id=1, name='Alice', age=25
事务100修改:age=26
事务200修改:age=27
版本链(Undo Log Chain):
[当前版本] [版本1] [版本2]
age=27 age=26 age=25
trx_id=200 --> trx_id=100 --> trx_id=50
roll_ptr --> roll_ptr --> NULL
快照读:根据ReadView判断读取哪个版本
当前读:直接读取最新版本 + 加锁
ReadView 可见性判断(源码逻辑):
// read0read.cc
bool ReadView::changes_visible(trx_id_t id) {
// 1. 如果 id < m_up_limit_id:版本在ReadView创建前已提交 -> 可见
if (id < m_up_limit_id) {
return true;
}
// 2. 如果 id >= m_low_limit_id:版本在ReadView创建后才生成 -> 不可见
if (id >= m_low_limit_id) {
return false;
}
// 3. 如果 id 在活跃事务列表中 -> 不可见
if (m_ids.contains(id)) {
return false;
}
// 4. 其他情况 -> 可见
return true;
}
🔥 面试高频问题:
Q: 为什么RR隔离级别下,普通SELECT不会阻塞UPDATE?
A: 因为普通SELECT是快照读,基于MVCC读取历史版本,不加锁。UPDATE是当前读,加X锁修改最新版本。两者读取不同版本的数据,互不影响。
六、性能分析与优化 ⚡
6.1 锁的性能开销分析
不同锁粒度的性能对比
| 锁粒度 | 加锁开销 | 并发性能 | 内存占用 | 适用场景 |
|---|---|---|---|---|
| 全局锁 | 极低 | 极差(全库阻塞) | 极低 | 全库备份(已淘汰) |
| 表锁 | 低 | 差(整表阻塞) | 低 | MyISAM、DDL操作 |
| 行锁 | 较高 | 优秀 | 随行数线性增长 | InnoDB默认,推荐 |
| 页锁 | 中等 | 中等 | 中等 | BDB引擎(已废弃) |
锁等待时间测试(基准测试)
# 测试环境:MySQL 8.0, InnoDB, 16C32G
# 测试工具:sysbench
# 场景1:无锁冲突(快照读)
sysbench oltp_read_only --threads=100 --time=60 run
# TPS: 12000+, 平均响应时间: 8ms
# 场景2:轻度锁冲突(10%写操作)
sysbench oltp_read_write --threads=100 --time=60 run
# TPS: 6000, 平均响应时间: 16ms
# 场景3:重度锁冲突(50%写操作)
sysbench oltp_write_only --threads=100 --time=60 run
# TPS: 2000, 平均响应时间: 50ms
# 锁等待时间占比: 60%+
性能瓶颈分析:
graph TB
A[锁性能瓶颈] --> B[锁粒度过大]
A --> C[锁持有时间过长]
A --> D[锁冲突频率高]
B --> B1[全表扫描导致全表锁]
B --> B2[无索引导致表锁]
C --> C1[事务中执行慢SQL]
C --> C2[业务逻辑复杂]
C --> C3[外部IO操作]
D --> D1[热点数据集中]
D --> D2[业务设计不合理]
D --> D3[并发量超载]
6.2 锁优化实战技巧
优化1:索引优化减少锁范围 🔥
-- ❌ 错误:无索引,全表锁
UPDATE orders SET status = 'paid'
WHERE order_no = 'ORD20231115001';
-- EXPLAIN: type=ALL, 扫描全表100万行,锁住所有行!
-- ✅ 正确:添加索引,精准锁定
ALTER TABLE orders ADD INDEX idx_order_no(order_no);
UPDATE orders SET status = 'paid'
WHERE order_no = 'ORD20231115001';
-- EXPLAIN: type=ref, 只锁1行
-- 性能对比:
-- 无索引:耗时5000ms,阻塞所有并发请求
-- 有索引:耗时5ms,不影响其他订单
优化2:缩短事务持有锁的时间
-- ❌ 错误:事务中执行慢操作
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- 🔒 加锁
-- 执行复杂业务逻辑(耗时2秒)
-- 调用外部API(耗时3秒)
-- 生成PDF报表(耗时5秒)
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT; -- 总共锁了10秒!😱
-- ✅ 正确:先准备数据,最后才加锁
-- 1. 查询数据(快照读,不加锁)
SELECT * FROM orders WHERE id = 1;
-- 2. 执行业务逻辑(在应用层完成)
-- 3. 最小化事务
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- 🔒 加锁
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT; -- 只锁了50ms!✅
优化3:避免大事务
-- ❌ 错误:一次性更新10万条记录
START TRANSACTION;
UPDATE orders SET is_deleted = 1 WHERE user_id = 12345; -- 10万条
COMMIT;
-- 问题:
-- 1. 锁住10万行,阻塞时间长
-- 2. undo log巨大,回滚慢
-- 3. 主从延迟严重
-- ✅ 正确:分批更新
DELIMITER $$
CREATE PROCEDURE batch_update()
BEGIN
DECLARE batch_size INT DEFAULT 1000;
DECLARE affected_rows INT DEFAULT 1000;
WHILE affected_rows = batch_size DO
START TRANSACTION;
UPDATE orders SET is_deleted = 1
WHERE user_id = 12345 AND is_deleted = 0
LIMIT 1000; -- 每次只更新1000条
SET affected_rows = ROW_COUNT();
COMMIT;
-- 暂停100ms,释放锁,给其他事务机会
SELECT SLEEP(0.1);
END WHILE;
END$$
DELIMITER ;
优化4:合理利用索引顺序避免死锁
-- ❌ 错误:随机顺序更新,容易死锁
-- 事务A: UPDATE id=10, then id=5
-- 事务B: UPDATE id=5, then id=10
-- 结果:死锁!
-- ✅ 正确:统一按主键升序更新
-- 应用层先排序
ids = [10, 5, 20, 15]
ids.sort() // [5, 10, 15, 20]
START TRANSACTION;
-- 按顺序加锁
UPDATE orders SET status = 'paid' WHERE id IN (5, 10, 15, 20) ORDER BY id;
COMMIT;
优化5:使用低隔离级别
-- 场景:允许读到部分未提交数据的统计查询
-- 从 RR 降级到 RC,减少间隙锁
-- 会话级别设置
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 此时不会产生间隙锁
SELECT COUNT(*) FROM orders WHERE user_id = 100;
-- RC级别:只锁存在的记录
-- RR级别:会锁住间隙,阻塞INSERT
6.3 监控与诊断
查看当前锁等待情况
-- 1. 查看InnoDB锁等待(MySQL 8.0+)
SELECT
waiting_trx_id AS '等待事务ID',
waiting_pid AS '等待线程ID',
waiting_query AS '等待SQL',
blocking_trx_id AS '阻塞事务ID',
blocking_pid AS '阻塞线程ID',
blocking_query AS '阻塞SQL',
wait_age AS '等待时长(秒)'
FROM sys.innodb_lock_waits;
-- 2. 查看所有事务状态
SELECT
trx_id,
trx_state,
trx_started,
trx_wait_started,
trx_mysql_thread_id,
trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
-- 3. 查看锁信息(MySQL 8.0+)
SELECT
ENGINE_LOCK_ID,
ENGINE_TRANSACTION_ID,
OBJECT_SCHEMA,
OBJECT_NAME,
LOCK_TYPE, -- TABLE 或 RECORD
LOCK_MODE, -- X, S, IX, IS
LOCK_STATUS, -- GRANTED 或 WAITING
LOCK_DATA -- 锁定的索引值
FROM performance_schema.data_locks;
杀死阻塞事务
-- 1. 找到阻塞线程ID(假设是123)
SELECT blocking_pid FROM sys.innodb_lock_waits;
-- 2. 杀死会话
KILL 123;
-- ⚠️ 注意:KILL会回滚事务,慎用!
查看死锁日志
-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS\G
-- 输出示例(截取关键部分):
/*
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-11-15 10:30:25
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 10, OS thread handle 140234, query id 500 localhost root updating
UPDATE orders SET status = 'paid' WHERE id = 10
*** (1) WAITING FOR THIS LOCK:
RECORD LOCKS space id 15 page no 4 n bits 72 index PRIMARY of table `test`.`orders`
trx id 12345 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
UPDATE orders SET status = 'paid' WHERE id = 5
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 15 page no 4 n bits 72 index PRIMARY
trx id 12346 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK:
RECORD LOCKS space id 15 page no 3 n bits 72 index PRIMARY
trx id 12346 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (1) <- MySQL选择回滚事务1
*/
6.4 参数调优
-- 1. 锁等待超时时间(默认50秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
SET GLOBAL innodb_lock_wait_timeout = 10; -- 改为10秒
-- 🔥 面试高频:超时后会报错 ERROR 1205 (HY000): Lock wait timeout exceeded
-- 2. 死锁检测开关(默认ON,强烈建议保持)
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
SET GLOBAL innodb_deadlock_detect = ON;
-- ⚠️ 关闭后,死锁只能等超时,风险极大!
-- 3. 打印死锁日志到错误日志
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 所有死锁都会写入 error.log,方便排查
-- 4. 行锁监控
SHOW STATUS LIKE 'Innodb_row_lock%';
/*
Innodb_row_lock_current_waits: 5 -- 当前等待行锁的事务数
Innodb_row_lock_time: 1234567 -- 总等待时间(ms)
Innodb_row_lock_time_avg: 123 -- 平均等待时间(ms)
Innodb_row_lock_time_max: 5000 -- 最大等待时间(ms)
Innodb_row_lock_waits: 10000 -- 总等待次数
*/
🔥 性能优化总结(必背):
- 索引优化:避免全表扫描导致全表锁 ⭐⭐⭐
- 缩短事务:减少锁持有时间,外部IO移到事务外
- 降低隔离级别:RC级别无间隙锁,并发更高(如果业务允许)
- 分批操作:大批量更新拆分成小批次
- 统一加锁顺序:避免循环等待导致死锁
- 监控告警:设置锁等待阈值告警
七、易混淆概念对比 🆚
7.1 悲观锁 vs 乐观锁
| 维度 | 悲观锁(Pessimistic Lock) | 乐观锁(Optimistic Lock) |
|---|---|---|
| 核心思想 | 先加锁再操作,假设一定会冲突 | 不加锁,提交时检查版本号 |
| MySQL实现 | FOR UPDATE, LOCK IN SHARE MODE | WHERE version = ? |
| 适用场景 | 写多读少,冲突频繁 | 读多写少,冲突罕见 |
| 性能 | 锁等待多,吞吐量低 | 无锁等待,吞吐量高 |
| 失败处理 | 阻塞等待 | 需要业务层重试 |
| 死锁风险 | ⚠️ 有(需要检测) | ✅ 无 |
| 典型场景 | 金融转账、库存扣减 | 文章点赞、阅读计数 |
代码对比:
-- 悲观锁示例
START TRANSACTION;
SELECT stock FROM product WHERE id = 1 FOR UPDATE; -- 加锁
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;
-- 乐观锁示例
-- 1. 查询当前版本
SELECT stock, version FROM product WHERE id = 1; -- stock=10, version=5
-- 2. 带版本号更新
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 3. 检查是否成功
IF ROW_COUNT() = 0 THEN
-- 版本冲突,需要重试
RETRY();
END IF;
7.2 共享锁 vs 排他锁
| 维度 | 共享锁(S Lock) | 排他锁(X Lock) |
|---|---|---|
| 英文名 | Shared Lock / Read Lock | Exclusive Lock / Write Lock |
| 加锁SQL | SELECT ... FOR SHARE | SELECT ... FOR UPDATE |
| 兼容性 | S锁之间兼容(可同时持有) | X锁与任何锁都不兼容 |
| 允许操作 | 只读,不能修改 | 读写都可以 |
| 典型用途 | 防止读取过程中被修改 | 修改数据前独占 |
| 生活类比 | 图书馆同一本书多人同时看📖 | 租房独占使用🏠 |
兼容性示意:
graph LR
A[事务A持有S锁] -->|允许| B[事务B请求S锁 ✅]
A -->|拒绝| C[事务C请求X锁 ❌]
D[事务D持有X锁] -->|拒绝| E[任何事务请求S锁 ❌]
D -->|拒绝| F[任何事务请求X锁 ❌]
7.3 表锁 vs 行锁 vs 元数据锁
| 维度 | 表锁 | 行锁 | 元数据锁(MDL) |
|---|---|---|---|
| 锁粒度 | 整张表 | 单行或多行 | 表结构 |
| 引擎支持 | MyISAM, InnoDB | InnoDB | 所有引擎 |
| 自动/手动 | 手动 LOCK TABLES | 自动(DML语句) | 自动 |
| 并发性 | 差(整表阻塞) | 优秀 | 一般 |
| 典型触发 | 手动执行 | UPDATE/DELETE/INSERT | ALTER TABLE, SELECT |
| 锁的内容 | 表中所有数据 | 索引记录 | 表的元数据(结构) |
| 释放时机 | UNLOCK TABLES | 事务提交/回滚 | 事务提交/回滚 |
MDL锁的隐蔽性(⚠️ 面试高频):
-- 场景:线上DDL导致服务雪崩
-- 1. 事务A:长时间未提交(持有MDL读锁)
START TRANSACTION;
SELECT * FROM orders WHERE id = 1;
-- ... 忘记提交,持续5分钟 ...
-- 2. DBA执行DDL(请求MDL写锁,被阻塞)
ALTER TABLE orders ADD COLUMN remark VARCHAR(255);
-- ⏳ 等待事务A释放MDL读锁
-- 3. 后续所有查询都被阻塞(排队等待MDL)
SELECT * FROM orders WHERE id = 2; -- ⏳ 等待
SELECT * FROM orders WHERE id = 3; -- ⏳ 等待
-- ... 数百个请求排队,服务雪崩!💀
避免MDL锁阻塞的方法:
-- 方法1:设置DDL超时时间(MySQL 8.0.31+)
SET SESSION lock_wait_timeout = 5; -- 5秒超时
ALTER TABLE orders ADD COLUMN remark VARCHAR(255);
-- 方法2:使用在线DDL工具
-- pt-online-schema-change(Percona Toolkit)
-- gh-ost(GitHub开源)
7.4 间隙锁 vs 临键锁 vs 记录锁
| 锁类型 | 锁定范围 | 使用场景 | 是否锁记录 | 是否锁间隙 |
|---|---|---|---|---|
| 记录锁 | 单条索引记录 | 唯一索引等值查询(记录存在) | ✅ | ❌ |
| 间隙锁 | 记录之间的间隙 | 范围查询、记录不存在 | ❌ | ✅ |
| 临键锁 | 记录 + 前面的间隙 | RR隔离级别默认 | ✅ | ✅ |
图解三种锁:
数据:id = 5, 10, 15
┌─────┬─────┬─────┬─────┬─────┐
│ (-∞,5] │ (5,10] │ (10,15] │ (15,+∞) │
└─────┴─────┴─────┴─────┴─────┘
SELECT * FROM t WHERE id = 10 FOR UPDATE; (唯一索引)
→ 记录锁:只锁 id=10
🔒
10
SELECT * FROM t WHERE id = 8 FOR UPDATE; (记录不存在)
→ 间隙锁:锁住 (5, 10)
(──────)
5 10
SELECT * FROM t WHERE id >= 10 FOR UPDATE; (范围查询)
→ 临键锁:(5,10] + (10,15] + (15,+∞)
(──────]───────]─────────→
5 10 15
八、常见坑与最佳实践 ⚠️
坑1:隐式锁升级 - 行锁变表锁 😱
-- 建表
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT
-- ⚠️ 注意:age 没有索引!
);
-- ❌ 错误:WHERE条件无索引 -> 全表锁
UPDATE users SET name = 'Alice' WHERE age = 25;
-- 虽然只想更新age=25的记录,但因为age无索引
-- MySQL扫描全表,锁住所有行!
-- 验证锁范围
SELECT * FROM performance_schema.data_locks;
-- 发现:锁住了全表的所有记录!
-- ✅ 正确:添加索引
ALTER TABLE users ADD INDEX idx_age(age);
UPDATE users SET name = 'Alice' WHERE age = 25;
-- 现在只锁age=25的行
底层原因:
- InnoDB必须通过索引加锁
- 无索引时,使用主键索引全表扫描
- 扫描过的所有记录都会加锁(临键锁)
- 虽然MySQL会在Server层过滤,但锁已经加上了
坑2:SELECT ... FOR UPDATE 锁住了不存在的记录
-- 数据:id = 5, 10, 15
-- 查询不存在的记录
SELECT * FROM users WHERE id = 7 FOR UPDATE;
-- 返回空结果,但加了间隙锁 (5, 10)
-- 其他事务尝试插入
INSERT INTO users VALUES (7, 'Alice'); -- ❌ 被阻塞!
INSERT INTO users VALUES (8, 'Bob'); -- ❌ 被阻塞!
-- 🔥 面试高频:为什么查询不存在的记录也会加锁?
-- 答案:防止幻读。通过间隙锁阻止其他事务插入id=7的记录
坑3:INSERT 也会被阻塞
-- 场景:两个事务同时插入间隙
-- 事务A
SELECT * FROM users WHERE id = 7 FOR UPDATE; -- 间隙锁 (5, 10)
-- 事务B
INSERT INTO users VALUES (8, 'Bob');
-- ❌ 阻塞!因为插入意向锁与间隙锁冲突
-- 🔥 重点:间隙锁之间兼容,但插入意向锁与间隙锁不兼容
坑4:主从复制中的锁问题
-- 主库(RR隔离级别)
UPDATE users SET status = 1 WHERE age > 25;
-- 加临键锁,锁住多个间隙
-- 从库(如果是RC隔离级别)
-- 回放时只加记录锁,不加间隙锁
-- 可能导致主从数据不一致!
-- ✅ 最佳实践:主从使用相同隔离级别
坑5:长事务持有锁导致故障
-- ❌ 典型错误场景
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 然后在应用层执行:
// 1. 调用第三方API(5秒)
// 2. 发送邮件(3秒)
// 3. 生成报表(10秒)
// 总共锁了18秒!😱
COMMIT;
-- ✅ 正确做法:
// 1. 先完成所有准备工作(查询数据)
// 2. 调用第三方API
// 3. 发送邮件
// 4. 最后开启事务,快速更新
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT; -- 只锁50ms
坑6:死锁后的处理不当
-- ❌ 错误:简单粗暴重试
try {
executeTransaction();
} catch (DeadlockException e) {
executeTransaction(); // 立即重试,可能继续死锁
}
-- ✅ 正确:指数退避重试
int maxRetries = 3;
int retryDelay = 100; // ms
for (int i = 0; i < maxRetries; i++) {
try {
executeTransaction();
break; // 成功则退出
} catch (DeadlockException e) {
if (i == maxRetries - 1) {
throw e; // 最后一次还失败,抛出异常
}
Thread.sleep(retryDelay * (1 << i)); // 指数退避:100ms, 200ms, 400ms
}
}
最佳实践总结 ✅
- 索引优先:所有WHERE条件都建立索引,避免锁升级
- 事务最小化:只在必要时开启事务,尽快提交
- 统一加锁顺序:避免相反顺序导致死锁
- 避免长事务:不要在事务中执行RPC、文件IO
- 监控锁等待:设置告警,及时发现问题
- 合理设置超时:
innodb_lock_wait_timeout不要太长 - 使用低隔离级别:如果业务允许,RC级别性能更好
- 批量操作分批:大批量更新拆分成小批次
- 测试环境验证:DDL操作先在测试环境验证锁影响
- 准备回滚方案:死锁时有自动重试机制
九、⭐ 面试题精选(必背!) 📝
⭐ 基础题(P5-P6级别)
Q1: MySQL有哪些锁?请分类说明。(⭐ 难度:基础)
标准答案(分点作答):
按锁粒度分类:
- 全局锁:锁定整个数据库实例(
FLUSH TABLES WITH READ LOCK) - 表级锁:锁定整张表(表锁、元数据锁MDL、意向锁)
- 行级锁:锁定索引记录(InnoDB默认,记录锁、间隙锁、临键锁)
按锁模式分类:
- 共享锁(S锁):允许多个事务同时读取
- 排他锁(X锁):独占访问,读写都阻塞其他事务
按锁实现分类:
- 乐观锁:不真正加锁,通过版本号控制(应用层实现)
- 悲观锁:先加锁再操作(数据库层实现)
Q2: 什么是意向锁?为什么需要意向锁?(⭐ 难度:基础)
标准答案:
定义: 意向锁(Intention Lock)是表级锁,分为意向共享锁(IS)和意向排他锁(IX)。当事务要对表中的某行加S锁或X锁时,会先在表上加相应的意向锁。
作用(核心):
- 快速判断表锁冲突:无需遍历每一行,直接检查表级意向锁即可
- 提高加表锁的效率:避免全表扫描检查行锁
举例说明:
-- 事务A
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 会自动在表上加IX锁,在行上加X锁
-- 事务B想加表锁
LOCK TABLES orders WRITE;
-- 只需检查意向锁,发现表上有IX锁,直接拒绝
-- 如果没有意向锁,需要扫描所有行检查是否有行锁(低效!)
🔥 记忆要点:
- 意向锁是表级锁,自动加,无需手动
- 意向锁之间兼容(IS和IX可以共存)
- 意向锁的目的是优化表锁的加锁性能
Q3: 间隙锁(Gap Lock)是什么?解决了什么问题?(⭐⭐ 难度:进阶)
标准答案:
定义: 间隙锁是一种特殊的行锁,锁住的是索引记录之间的间隙,不锁记录本身。
解决的问题(核心): 防止幻读。在可重复读(RR)隔离级别下,通过锁住间隙防止其他事务插入新数据。
工作原理:
-- 表数据:id = 5, 10, 15
SELECT * FROM t WHERE id > 5 AND id < 15 FOR UPDATE;
-- 锁住的间隙:(5, 10), (10, 15)
-- 此时其他事务无法插入 id=7, 8, 9, 11, 12, 13, 14
重要特性:
- 只在**可重复读(RR)**隔离级别下启用
- READ COMMITTED 级别没有间隙锁
- 间隙锁之间相互兼容(多个事务可以同时持有同一间隙的间隙锁)
- 但插入意向锁与间隙锁不兼容
🔥 面试追问:为什么间隙锁之间兼容? 答:因为间隙锁的目的是防止INSERT,不是防止其他事务加间隙锁。多个事务同时防止插入,互不冲突。
⭐⭐ 进阶题(P6-P7级别)
Q4: 说说临键锁(Next-Key Lock)的原理和退化机制。(⭐⭐ 难度:进阶)
标准答案:
定义: 临键锁 = 记录锁 + 间隙锁,锁定范围是 (左开右闭区间],即记录本身 + 记录前的间隙。
默认规则: InnoDB 在 RR 隔离级别下,默认使用临键锁算法。
三种退化场景(⭐⭐⭐ 必考):
- 唯一索引等值查询 + 记录存在 → 退化为记录锁
SELECT * FROM t WHERE id = 10 FOR UPDATE; -- id是主键
-- 只锁 id=10,不锁间隙
- 唯一索引等值查询 + 记录不存在 → 退化为间隙锁
SELECT * FROM t WHERE id = 7 FOR UPDATE; -- id=7不存在
-- 只锁间隙 (5, 10),不锁记录
- 非唯一索引 → 临键锁 + 下一个间隙
-- age是普通索引,数据:age = 10, 10, 20
SELECT * FROM t WHERE age = 10 FOR UPDATE;
-- 锁范围:(-∞, 10] + (10, 20)
-- 包含所有age=10的记录 + 下一个间隙
🔥 为什么要退化?
- 唯一索引已经保证唯一性,无需间隙锁防幻读
- 优化性能,减少锁范围
Q5: 为什么UPDATE没有索引会导致全表锁?如何避免?(⭐⭐ 难度:进阶)
标准答案:
原因(底层机制):
- InnoDB 的锁是加在索引上的,不是加在数据行上
- 如果WHERE条件没有索引,必须使用主键索引全表扫描
- 扫描过的每一行都会加临键锁(Next-Key Lock)
- 虽然MySQL Server层会过滤不符合条件的行,但锁已经加上了
示例:
-- 表结构:id(主键), name, age(无索引)
-- 数据:1000万行
UPDATE users SET name = 'Alice' WHERE age = 25;
-- 1. 全表扫描主键索引(1000万行)
-- 2. 每一行都加临键锁
-- 3. Server层过滤出age=25的行(假设1000行)
-- 4. 但1000万行的锁已经加上了!
-- 结果:锁住全表,所有其他UPDATE/DELETE都被阻塞
如何避免:
- 添加索引(最根本)
ALTER TABLE users ADD INDEX idx_age(age);
-- 现在只锁age=25的行
- 使用覆盖索引
-- 如果有联合索引 (age, name)
UPDATE users SET name = 'Alice' WHERE age = 25;
-- 只扫描索引,锁范围更小
- 使用主键更新
-- 先查出主键
SELECT id FROM users WHERE age = 25; -- 快照读,不加锁
-- 再用主键更新
UPDATE users SET name = 'Alice' WHERE id IN (1, 5, 10);
Q6: 死锁是怎么产生的?MySQL如何检测和处理死锁?(⭐⭐⭐ 难度:高级)
标准答案:
死锁产生的4个必要条件:
- 互斥:资源不能被多个事务共享
- 持有并等待:已持有锁,还要请求新锁
- 不可剥夺:锁不能被强制释放
- 循环等待:形成等待环路
常见死锁场景:
场景1:相反顺序加锁
-- 事务A: 锁id=1 → 锁id=2
-- 事务B: 锁id=2 → 锁id=1
-- 形成环路:A等B,B等A
场景2:间隙锁死锁
-- 两个事务都持有间隙锁(5,10)
-- 同时尝试插入 → 互相等待对方的间隙锁
MySQL的死锁检测算法(Wait-For Graph):
- 构建等待图:节点是事务,边是等待关系
- DFS检测环路:如果发现环,说明死锁
- 选择牺牲者:回滚 undo log 最少的事务(成本最小)
- 返回错误码:ERROR 1213 (40001): Deadlock found
源码逻辑(简化):
if (detect_cycle_in_wait_graph()) {
victim = choose_victim(); // 选择代价最小的事务
rollback(victim);
return DEADLOCK_DETECTED;
}
如何避免死锁:
- 统一加锁顺序:所有事务按主键升序加锁
- 缩短事务时间:减少锁持有时间
- 大事务拆分:分批操作
- 使用低隔离级别:RC级别无间隙锁,死锁概率低
- 添加合理索引:减少锁范围
⭐⭐⭐ 高级题(P7-P8级别)
Q7: 在可重复读(RR)隔离级别下,为什么普通SELECT不会被UPDATE阻塞?(⭐⭐⭐ 难度:高级)
标准答案(必须讲清MVCC):
核心原因:快照读(Snapshot Read)+ MVCC
-
普通SELECT是快照读
- 不加任何锁
- 读取的是历史版本(通过undo log)
- 基于事务启动时的ReadView判断可见性
-
UPDATE是当前读
- 加排他锁(X锁)
- 读取的是最新版本
-
两者读取的数据版本不同
- 快照读:读undo log的历史版本
- 当前读:读buffer pool的最新版本
- 互不影响!
示例说明:
-- 初始数据:id=1, age=25
-- 事务A(RR隔离级别)
START TRANSACTION;
SELECT age FROM users WHERE id = 1; -- 读到25(快照读)
-- 事务B
UPDATE users SET age = 30 WHERE id = 1; -- 加X锁,修改最新版本
COMMIT;
-- 事务A继续查询
SELECT age FROM users WHERE id = 1; -- 还是25!(快照读)
-- 因为读的是事务启动时的快照
-- 如果改成当前读
SELECT age FROM users WHERE id = 1 FOR UPDATE; -- 读到30(当前读 + 加锁)
MVCC版本链结构:
[最新版本] [版本1]
age=30 age=25
trx_id=200 -> trx_id=100 -> NULL
事务A的ReadView: m_up_limit_id=100
- trx_id=200 > 100 → 不可见 → 回溯到 trx_id=100 → 可见 → 返回age=25
🔥 追问:为什么需要当前读? 答:UPDATE/DELETE必须基于最新版本操作,否则会出现丢失更新问题。
Q8: 解释一下这个死锁场景,如何避免?(⭐⭐⭐ 难度:高级)
场景代码:
-- 表数据:id = 5, 10, 15
-- 事务A
START TRANSACTION;
SELECT * FROM t WHERE id = 7 FOR UPDATE; -- 间隙锁(5,10)
INSERT INTO t VALUES (7, 'A'); -- 等待事务B
-- 事务B(同时执行)
START TRANSACTION;
SELECT * FROM t WHERE id = 8 FOR UPDATE; -- 间隙锁(5,10) - 兼容✅
INSERT INTO t VALUES (8, 'B'); -- 等待事务A
-- 死锁!
标准答案:
死锁原因分析:
-
阶段1:A和B都对不存在的记录加锁
- A加间隙锁(5,10)
- B也加间隙锁(5,10)
- 两个间隙锁兼容✅,都成功
-
阶段2:A和B都尝试插入
- A的INSERT需要插入意向锁 → 与B的间隙锁冲突 → 等待B
- B的INSERT需要插入意向锁 → 与A的间隙锁冲突 → 等待A
- 形成环路:A等B,B等A → 死锁💀
为什么间隙锁兼容,插入意向锁不兼容?
- 间隙锁:目的是防止其他事务插入
- 插入意向锁:表示即将插入
- 设计原则:多个事务可以同时"防止插入",但不能同时"执行插入"
如何避免:
- 方法1:使用唯一索引 ⭐ 推荐
ALTER TABLE t ADD UNIQUE INDEX (id);
-- 插入时会先加记录锁,避免间隙锁死锁
- 方法2:先INSERT,失败则UPDATE
INSERT INTO t VALUES (7, 'A') ON DUPLICATE KEY UPDATE ...;
-- 利用唯一约束,避免间隙锁
- 方法3:降低隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- RC级别无间隙锁
Q9: 设计题:秒杀系统如何防止库存超卖?请给出至少3种方案并对比优劣。(⭐⭐⭐ 难度:开放题)
标准答案(体现设计能力):
方案1:数据库悲观锁(FOR UPDATE)
START TRANSACTION;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
IF (stock > 0) THEN
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;
ELSE
ROLLBACK;
END IF;
- 优点:强一致性,不会超卖
- 缺点:并发性能差(锁等待严重),QPS < 1000
- 适用场景:小规模秒杀
方案2:数据库乐观锁(版本号)
-- 1. 查询
SELECT stock, version FROM product WHERE id = 1;
-- 2. 更新(CAS)
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = ? AND stock > 0;
-- 3. 检查影响行数,失败则重试
- 优点:无锁等待,吞吐量高
- 缺点:高并发下大量失败重试
- 适用场景:中等并发(QPS < 5000)
方案3:Redis原子操作
-- Lua脚本保证原子性
local stock = redis.call('GET', 'product:1:stock')
if tonumber(stock) > 0 then
redis.call('DECR', 'product:1:stock')
return 1
else
return 0
end
- 优点:性能极高(QPS > 10万),Redis单线程天然串行
- 缺点:需要保证Redis与MySQL数据一致性
- 适用场景:超高并发秒杀
方案4:消息队列削峰
用户请求 → MQ → 消费者(限流) → 数据库
- 优点:削峰填谷,保护数据库
- 缺点:异步返回,用户体验差
- 适用场景:允许延迟的场景
方案5:数据库原子操作(推荐)
UPDATE product SET stock = stock - 1
WHERE id = 1 AND stock > 0;
-- 检查影响行数
IF ROW_COUNT() > 0 THEN
-- 扣减成功
END IF;
- 优点:简洁、性能好(QPS < 3000)
- 缺点:依赖数据库性能
- 适用场景:中小规模秒杀
综合方案(生产级):
Redis预扣减(QPS > 10万)
↓
MQ异步削峰(缓冲)
↓
MySQL兜底(保证一致性)
Q10: 如果线上出现大量锁等待,你会如何排查和解决?(⭐⭐⭐ 难度:高级实战)
标准答案(体现实战经验):
第一步:快速定位阻塞源
-- 1. 查看当前锁等待关系
SELECT
waiting_pid AS '被阻塞线程',
waiting_query AS '被阻塞SQL',
blocking_pid AS '阻塞线程',
blocking_query AS '阻塞SQL',
wait_age AS '等待时长'
FROM sys.innodb_lock_waits
ORDER BY wait_age DESC;
-- 2. 找到罪魁祸首(最上游的阻塞者)
-- 通常是一个长时间未提交的事务
第二步:分析锁信息
-- 查看具体锁信息
SELECT
OBJECT_NAME AS '表名',
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
LOCK_DATA AS '锁定的索引值'
FROM performance_schema.data_locks
WHERE ENGINE_TRANSACTION_ID = ?; -- 填入阻塞事务ID
第三步:查看事务状态
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS '事务运行时长',
trx_query
FROM information_schema.innodb_trx
WHERE trx_id = ?;
第四步:紧急处理
-- 如果确认是异常长事务
KILL <blocking_pid>; -- 杀死阻塞线程
-- ⚠️ 注意:KILL会回滚事务,需评估影响
第五步:根本原因分析
常见原因:
-
无索引导致全表锁
- 解决:添加索引
ALTER TABLE xxx ADD INDEX idx_xxx(column); -
长事务未提交
- 解决:设置事务超时 + 应用层事务监控
SET GLOBAL innodb_lock_wait_timeout = 10; -
DDL操作阻塞
- 解决:使用在线DDL工具(pt-osc, gh-ost)
-
死锁频繁
- 解决:统一加锁顺序 + 开启死锁日志
SET GLOBAL innodb_print_all_deadlocks = ON;
第六步:长期优化
- 添加监控告警(锁等待超过阈值告警)
- 慢查询日志分析(找出耗时SQL)
- 应用层优化(减少事务时间)
- 数据库参数调优
- 代码review(规范加锁顺序)
🔥 面试题总结(必背知识点):
- 锁的分类:粒度、模式、算法
- 意向锁:快速判断表锁冲突
- 间隙锁:防幻读,RR级别特有
- 临键锁退化:三种场景
- 无索引锁升级:全表扫描 → 全表锁
- 死锁检测:Wait-For Graph
- MVCC:快照读 vs 当前读
- 超卖解决方案:5种方案对比
- 故障排查:sys.innodb_lock_waits
十、总结与延伸 🎯
核心要点回顾
1. MySQL锁的层次结构(⭐⭐⭐ 必记)
graph TB
A[MySQL锁体系] --> B[全局锁]
A --> C[表级锁]
A --> D[行级锁]
B --> B1[FLUSH TABLES WITH READ LOCK]
C --> C1[表锁 LOCK TABLES]
C --> C2[元数据锁 MDL]
C --> C3[意向锁 IS/IX]
D --> D1[记录锁 Record Lock]
D --> D2[间隙锁 Gap Lock]
D --> D3[临键锁 Next-Key Lock]
D --> D4[插入意向锁]
style D fill:#4ecdc4
style D3 fill:#ff6b6b
记忆口诀:
- 粒度从大到小:全局 > 表 > 行
- 并发从低到高:全局锁最差,行锁最优
- InnoDB默认:行级锁 + 临键锁算法
- 防幻读核心:间隙锁(仅RR级别)
2. 三大锁算法对比(⭐⭐⭐ 高频考点)
| 锁算法 | 锁定范围 | 触发条件 | 是否锁间隙 | 典型场景 |
|---|---|---|---|---|
| 记录锁 | 单条索引记录 | 唯一索引等值查询(记录存在) | ❌ | WHERE id = 10 FOR UPDATE |
| 间隙锁 | 记录间的间隙 | 记录不存在、范围查询 | ✅ | WHERE id = 7 FOR UPDATE(不存在) |
| 临键锁 | 记录 + 前面的间隙 | RR级别默认 | ✅ | WHERE id > 5 FOR UPDATE |
退化规则(必背):
临键锁退化条件:
1. 唯一索引 + 记录存在 → 记录锁
2. 唯一索引 + 记录不存在 → 间隙锁
3. 非唯一索引 → 临键锁 + 下一个间隙
3. 性能优化核心原则(⭐⭐⭐ 实战必备)
优化金字塔(从下到上,优先级递增):
┌─────────────────┐
│ 1. 监控告警 │ ← 发现问题
├─────────────────┤
│ 2. 参数调优 │ ← innodb_lock_wait_timeout
├─────────────────┤
│ 3. SQL优化 │ ← 索引、批量操作
├─────────────────┤
│ 4. 事务设计 │ ← 缩短事务、统一加锁顺序
├─────────────────┤
│ 5. 架构设计 │ ← 读写分离、分库分表
└─────────────────┘
最重要的3条(80%问题靠这3条解决):
- 所有WHERE条件必须有索引 - 避免锁升级
- 事务尽可能短 - 减少锁持有时间
- 统一加锁顺序 - 避免死锁
4. 常见故障场景与应对(⭐⭐⭐ 生产必备)
| 故障现象 | 根本原因 | 快速排查 | 紧急处理 | 长期方案 |
|---|---|---|---|---|
| 大量锁等待 | 长事务未提交 | sys.innodb_lock_waits | KILL blocking_pid | 事务监控 + 超时设置 |
| 死锁频繁 | 加锁顺序不一致 | SHOW ENGINE INNODB STATUS | 开启死锁日志 | 统一加锁顺序 |
| 全表锁 | WHERE条件无索引 | EXPLAIN 分析 | 添加索引 | 索引规范 |
| ALTER TABLE阻塞 | MDL锁冲突 | innodb_trx 查长事务 | 等待或KILL | 使用在线DDL工具 |
5. 面试必背知识地图
mindmap
root((MySQL锁))
锁分类
粒度::全局/表/行
模式::S锁/X锁
算法::记录/间隙/临键
核心原理
意向锁::快速判断表锁冲突
间隙锁::防幻读 RR特有
临键锁::默认算法 会退化
MVCC::快照读 vs 当前读
性能优化
索引优化::避免全表锁
事务优化::缩短持有时间
隔离级别::RC无间隙锁
监控诊断::锁等待排查
常见问题
死锁::Wait-For Graph
锁升级::无索引导致
超卖::5种方案对比
MDL阻塞::DDL风险
进阶学习路径
Level 1: 基础理解(1-2周)
- ✅ 掌握锁的分类和基本用法
- ✅ 理解共享锁、排他锁的区别
- ✅ 会使用
FOR UPDATE和FOR SHARE - ✅ 了解死锁的基本概念
学习资源:
- 《高性能MySQL》第1章、第7章
- MySQL官方文档:InnoDB Locking
Level 2: 深入原理(2-4周)
- ✅ 理解间隙锁、临键锁的工作原理
- ✅ 掌握临键锁的退化机制
- ✅ 理解MVCC与锁的协作
- ✅ 能够分析死锁日志
学习资源:
- 《MySQL技术内幕:InnoDB存储引擎》第6章
- 阅读InnoDB源码:
lock0lock.cc,lock0lock.h - 极客时间:《MySQL实战45讲》(林晓斌)
Level 3: 实战优化(1-3个月)
- ✅ 能够设计高并发场景下的锁方案
- ✅ 能够排查和解决线上锁问题
- ✅ 掌握秒杀系统的防超卖方案
- ✅ 能够优化慢SQL的锁等待
实战项目:
- 实现一个秒杀系统(压测 QPS > 5000)
- 排查一次死锁故障(分析
SHOW ENGINE INNODB STATUS) - 优化一次锁等待问题(从30秒降到1秒以内)
Level 4: 架构设计(3-6个月)
- ✅ 分库分表场景下的分布式锁
- ✅ 读写分离架构的锁设计
- ✅ 微服务架构下的事务一致性
- ✅ 分布式事务(2PC、TCC、SAGA)
学习资源:
- 《分布式系统原理与范型》
- Redis分布式锁:Redlock算法
- ZooKeeper分布式锁
- Seata分布式事务框架
相关技术栈推荐
1. MySQL周边技术
- 索引优化:B+树原理、覆盖索引、索引下推
- 事务隔离级别:RU、RC、RR、Serializable
- MVCC原理:ReadView、undo log版本链
- Binlog机制:ROW、STATEMENT、MIXED格式
- 主从复制:异步、半同步、组复制
2. 分布式锁方案
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| MySQL悲观锁 | 强一致性 | 性能差、单点故障 | 单库小并发 |
| Redis SET NX | 高性能 | 锁失效问题 | 高并发场景 |
| Redis Redlock | 高可用 | 复杂度高 | 严格一致性要求 |
| ZooKeeper | 强一致性 | 性能一般 | 配置中心、分布式协调 |
| etcd | 强一致性、高可用 | 依赖外部服务 | 云原生架构 |
3. 高并发解决方案
graph LR
A[高并发请求] --> B[Nginx限流]
B --> C[Redis缓存]
C --> D{缓存命中?}
D -->|是| E[返回结果]
D -->|否| F[MQ削峰]
F --> G[DB读写分离]
G --> H[主库]
G --> I[从库1]
G --> J[从库2]
style C fill:#4ecdc4
style F fill:#ffd93d
style H fill:#ff6b6b
写在最后
从那个周五晚上的线上故障开始,到现在对MySQL锁机制有了系统性的理解,我最大的感悟是:锁的本质是对并发访问的控制,优化的核心是在性能和一致性之间找平衡。
三个建议送给你:
-
不要过度设计
- 不是所有场景都需要悲观锁
- 读多写少的场景,MVCC + 乐观锁足够
- 先简单实现,性能不够再优化
-
监控先于优化
- 没有监控的优化是盲目的
- 关键指标:锁等待时间、死锁次数、事务耗时
- 问题出现前就能预警
-
理论联系实际
- 光看文章不够,要动手实验
- 搭建测试环境,模拟各种锁场景
- 压测是检验方案的唯一标准
推荐阅读顺序:
- 本文快速过一遍,建立知识框架
- 重点章节(底层原理、面试题)精读并做笔记
- 动手实验每一个SQL示例
- 阅读源码(
lock0lock.cc) - 实战项目(秒杀系统)
后续文章预告(如果有机会):
- 《MySQL索引深度解析》- B+树、覆盖索引、索引优化
- 《MVCC原理剖析》- ReadView、undo log版本链
- 《MySQL主从复制与高可用》- Binlog、半同步复制、MGR
最后的最后, 如果这篇文章对你有帮助,希望你能:
- ⭐ 收藏备用(面试前翻一翻)
- 🔖 标注重点(画出你不熟悉的部分)
- 💬 留言讨论(说说你遇到过的锁问题)
- 📢 分享给需要的人
记住:理解锁机制,不是为了炫技,而是为了在线上故障时,能快速定位问题、从容应对。 🚀
"The best time to plant a tree was 20 years ago. The second best time is now."
学习MySQL锁机制,现在就是最好的时机。💪
参考资料:
- MySQL 8.0 Reference Manual - InnoDB Locking
- 《MySQL技术内幕:InnoDB存储引擎》- 姜承尧
- 《高性能MySQL》- Baron Schwartz
- 《数据库系统内幕》- Alex Petrov
- MySQL源码:github.com/mysql/mysql…
- 极客时间《MySQL实战45讲》- 林晓斌(丁奇)
版本说明:
- 本文基于 MySQL 8.0 编写
- InnoDB存储引擎
- 部分特性在MySQL 5.7也适用,会特别标注版本差异
全文完 🎉
共计:约18000字 | 10个章节 | 50+代码示例 | 15+图表 | 10道面试题
感谢阅读!如有疑问,欢迎交流讨论。📧