MySQL InnoDB 核心机制:MVCC、索引、事务、锁与 SQL 优化
面试必问,生产必用。把 InnoDB 的几个核心机制串起来理一遍。
InnoDB 架构概览
InnoDB 是 MySQL 默认存储引擎,核心特性:
- B+Tree 索引 — 主键索引(聚簇) + 二级索引
- MVCC — 多版本并发控制,读不阻塞写
- 行级锁 — Record Lock / Gap Lock / Next-Key Lock
- 事务 — ACID,通过 undo log + redo log 保证
- Buffer Pool — 内存缓冲池,缓存数据页和索引页
┌─────────────────────────────────────────┐
│ 客户端连接 │
└─────────────────┬───────────────────────┘
▼
┌─────────────────────────────────────────┐
│ MySQL Server 层 │
│ 连接器 → 分析器 → 优化器 → 执行器 │
└─────────────────┬───────────────────────┘
▼
┌─────────────────────────────────────────┐
│ InnoDB 存储引擎 │
│ ┌──────────┐ ┌──────────┐ │
│ │Buffer Pool│ │ Redo Log│ │
│ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Undo Log │ │ 锁管理 │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────┘
B+Tree 索引
为什么是 B+Tree
| 数据结构 | 特点 |
|---|---|
| 二叉搜索树 | 可能退化成链表,高度太高 |
| 红黑树 | 二叉,高度依然大,IO 次数多 |
| B-Tree | 多路平衡,非叶子节点也存数据 |
| B+Tree | 数据只存叶子节点,叶子形成有序链表 |
B+Tree 的优势:
- 高度低 — 每个节点可存上千个 key,3-4 层就能存千万级数据
- 范围查询快 — 叶子节点有双向链表指针,范围扫描直接遍历
- 查询稳定 — 任何查询都走到叶子节点,IO 次数固定
聚簇索引 vs 二级索引
聚簇索引(主键索引)
┌─────────┐
│ 主键 ID │ → [全部列数据]
└─────────┘
二级索引(普通索引)
┌─────────┐
│ index列 │ → [主键 ID] → 回表 → [全部列数据]
└─────────┘
聚簇索引:叶子节点存的是完整行数据。一张表只有一个。
二级索引:叶子节点存的是主键值。查询需要「回表」再到聚簇索引查一次。
覆盖索引
如果查询列都在索引里,不需要回表:
-- name 列有索引,查询只取 id 和 name
SELECT id, name FROM users WHERE name = 'Tom';
-- 不回表,Extra: Using index
EXPLAIN 中 Extra: Using index 表示覆盖索引,性能最佳。
最左前缀原则
用 省-市-区 类比最容易理解:
联合索引 (province, city, district) 就好比地址层级。你要定位一个地址,必须先知道省,才能往下找市,再往下找区。
- 指定省 → 能定位,缩小到省范围 ✅
- 指定省、市 → 更精确,缩小到市范围 ✅
- 指定省、市、区 → 完全定位 ✅
- 只指定市 → 不知道是哪个省的市,没法定位 ❌
- 跳过市直接指定区 → 只有省和区,中间断了,区用不上 ⚠️
-- 索引 (province, city, district)
-- 提供所有层级 → 完全匹配
SELECT * FROM address WHERE province = '广东' AND city = '深圳' AND district = '南山';
-- 有省有区但跳过市 → 只用省定位,district 用不上
SELECT * FROM address WHERE province = '广东' AND district = '南山';
-- 只有市 → 不知道哪个省,索引失效
SELECT * FROM address WHERE city = '深圳';
-- 只有省 → 能定位,缩到省范围
SELECT * FROM address WHERE province = '广东';
对应回通用写法,联合索引 (a, b, c):
(a)✅ — 同「只查省」(a, b)✅ — 同「查省和市」(a, b, c)✅ — 同「查省市区全有」(b)❌ — 同「只查市,不知道省」(a, c)⚠️ — 同「查省和区,中间市断了」
索引失效场景
-- 1. 函数/计算破坏索引
SELECT * FROM t WHERE DATE(create_time) = '2024-01-01'; -- ❌
SELECT * FROM t WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'; -- ✅
-- 2. 隐式类型转换
SELECT * FROM t WHERE phone = 13800138000; -- ❌ phone 是 varchar
SELECT * FROM t WHERE phone = '13800138000'; -- ✅
-- 3. LIKE 前置 %
SELECT * FROM t WHERE name LIKE '%Tom'; -- ❌
SELECT * FROM t WHERE name LIKE 'Tom%'; -- ✅
-- 4. OR 条件有非索引列
SELECT * FROM t WHERE name = 'Tom' OR age = 25; -- age 无索引则全表扫描
-- 5. !=, <>, NOT IN, NOT EXISTS
SELECT * FROM t WHERE status != 1; -- 通常不走索引
事务与隔离级别
ACID
| 特性 | 含义 | InnoDB 实现 |
|---|---|---|
| Atomicity | 原子性,要么全做要么全不做 | undo log 回滚 |
| Consistency | 一致性,事务前后数据合法 | 约束 + 应用层保证 |
| Isolation | 隔离性,并发事务互不干扰 | MVCC + 锁 |
| Durability | 持久性,提交后数据不丢 | redo log + 刷盘 |
四种隔离级别
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | ✅ | ✅ | ✅ |
| READ COMMITTED | ❌ | ✅ | ✅ |
| REPEATABLE READ(默认) | ❌ | ❌ | ⚠️ 基本解决 |
| SERIALIZABLE | ❌ | ❌ | ❌ |
-- 查看当前隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
-- 设置
SET SESSION transaction_isolation = 'READ-COMMITTED';
脏读 / 不可重复读 / 幻读
脏读:读到别的事务还没提交的数据
T1: UPDATE age=20 WHERE id=1 (未提交)
T2: SELECT age FROM t WHERE id=1 → 读到 20
T1: ROLLBACK → age 实际还是 18
不可重复读:同一事务内两次读到不同值(update 导致)
T2: SELECT age FROM t WHERE id=1 → 18
T1: UPDATE age=20 WHERE id=1; COMMIT;
T2: SELECT age FROM t WHERE id=1 → 20 (不一致!)
幻读:同一事务内两次查询多出/少了行(insert/delete 导致)
T2: SELECT * FROM t WHERE age>18 → 3 行
T1: INSERT INTO t VALUES(4, 20); COMMIT;
T2: SELECT * FROM t WHERE age>18 → 4 行 (多了一行!)
MVCC 多版本并发控制
MVCC 让读操作不阻塞写操作,写操作不阻塞读操作。
核心:ReadView + undo log
每行数据有两隐藏列:
- DB_TRX_ID — 最近修改这行的事务 ID(6 字节)
- DB_ROLL_PTR — 回滚指针,指向 undo log 中的旧版本(7 字节)
Undo log 中形成版本链:
当前行 (TRX_ID=100)
↑ DB_ROLL_PTR
旧版本 (TRX_ID=80)
↑ DB_ROLL_PTR
旧版本 (TRX_ID=50)
↑ DB_ROLL_PTR
旧版本 (TRX_ID=10)
ReadView 可见性判断
ReadView 包含四个关键值:
- m_ids — 生成 ReadView 时活跃(未提交)的事务 ID 列表
- min_trx_id — m_ids 中的最小值
- max_trx_id — 系统下一个将分配的事务 ID
- creator_trx_id — 创建 ReadView 的事务 ID
判断规则:
如果 trx_id == creator_trx_id → 可见(自己的修改)
如果 trx_id < min_trx_id → 可见(已提交)
如果 trx_id >= max_trx_id → 不可见(将来的事务)
如果 min_trx_id <= trx_id < max_trx_id:
如果 trx_id 在 m_ids 中 → 不可见(未提交)
如果 trx_id 不在 m_ids 中 → 可见(已提交)
RR vs RC 的 ReadView 时机差异
| READ COMMITTED | REPEATABLE READ | |
|---|---|---|
| ReadView 生成 | 每个 SELECT 都生成一个新的 | 事务第一次 SELECT 时生成,之后复用 |
| 效果 | 能看到其他事务提交的修改 | 整个事务看到的数据一致(快照) |
这就是为什么 RR 能避免不可重复读 — ReadView 不变,看到的始终是事务开始时的快照。
InnoDB 锁机制
锁类型
-- 查看当前锁状态
SELECT * FROM performance_schema.data_locks;
SHOW ENGINE INNODB STATUS;
| 锁 | 说明 |
|---|---|
| Record Lock | 锁定单行记录(索引记录) |
| Gap Lock | 锁定索引记录间的间隙,阻止 insert |
| Next-Key Lock | Record Lock + Gap Lock,锁定记录及其前间隙 |
| Insert Intention Lock | insert 前申请的间隙锁,不冲突 |
| 共享锁 (S) | SELECT ... LOCK IN SHARE MODE,允许其他 S 锁,阻塞 X 锁 |
| 排他锁 (X) | SELECT ... FOR UPDATE / UPDATE / DELETE,阻塞所有其他锁 |
加锁规则(RR 隔离级别下)
两条核心原则:
- 加锁基本单位是 Next-Key Lock(前开后闭区间)
- 等值查询唯一索引退化为 Record Lock
表 t:id 主键 (1, 5, 10, 15, 25)
SELECT * FROM t WHERE id = 10 FOR UPDATE;
→ 锁住 id=10(Record Lock,等值 + 唯一索引)
SELECT * FROM t WHERE id = 8 FOR UPDATE;
→ id=8 不存在,锁住 (5, 10] → Gap Lock (5, 10)
防止其他事务在 5-10 之间插入
SELECT * FROM t WHERE id >= 10 AND id < 15 FOR UPDATE;
→ (5, 10] + (10, 15] → 实际锁住 [10, 15]
死锁案例
-- T1 -- T2
BEGIN; BEGIN;
UPDATE t SET a=1
WHERE id=10; -- X 锁 id=10
UPDATE t SET a=2
WHERE id=20; -- X 锁 id=20
UPDATE t SET a=1
WHERE id=20; -- 等待 T2 释放
UPDATE t SET a=2
WHERE id=10; -- 等待 T1 释放
-- 死锁!
死锁检测:InnoDB 自动检测死锁,回滚代价较小的事务,报错 Deadlock found。应用层需要重试。
减少锁冲突
- 尽量用主键/唯一键做条件,缩小锁范围
- 缩短事务:不要在事务里做 RPC、文件 IO
- RC 隔离级别下 Gap Lock 不存在,锁范围更小(但要评估是否接受幻读)
- 合理设计索引:不走索引会导致全表锁
SQL 分析与优化
EXPLAIN 解读
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY create_time DESC;
| 字段 | 关注点 |
|---|---|
| type | 访问类型:system > const > eq_ref > range > index > ALL |
| key | 实际使用的索引 |
| rows | 预估扫描行数 |
| Extra | Using index 覆盖索引 ✅ / Using filesort 额外排序 ⚠️ / Using temporary 临时表 ❌ |
| key_len | 索引使用长度,判断用了联合索引的几列 |
type 从好到差
system → 表只有一行
const → 主键/唯一键等值查询,最多一行
eq_ref → 关联查询,驱动表每行匹配被驱动表唯一索引
ref → 非唯一索引等值查询,可能多行
range → 索引范围扫描
index → 全索引扫描(比 ALL 好一点)
ALL → 全表扫描,最差
优化案例
1. 大偏移量分页
-- 慢:扫描 100010 行,丢掉前 100000 行
SELECT * FROM t ORDER BY id LIMIT 100000, 10;
-- 优化:记住上次的 id,从该位置接着取
SELECT * FROM t WHERE id > 100000 ORDER BY id LIMIT 10;
2. filesort 优化
-- Extra: Using filesort
SELECT * FROM t WHERE name = 'Tom' ORDER BY age;
-- 建联合索引 (name, age),Extra: Using index
CREATE INDEX idx_name_age ON t(name, age);
3. JOIN 优化
-- 小表驱动大表
SELECT * FROM small_table s
JOIN big_table b ON s.id = b.sid;
-- JOIN 字段建索引
CREATE INDEX idx_sid ON big_table(sid);
4. IN vs EXISTS
-- 外表小用 IN
SELECT * FROM t WHERE id IN (SELECT tid FROM small);
-- 外表大用 EXISTS
SELECT * FROM t WHERE EXISTS (SELECT 1 FROM small WHERE small.tid = t.id);
5. COUNT 优化
-- MyISAM 快,InnoDB 慢(需要扫索引)
SELECT COUNT(*) FROM t;
-- 估算(不精确但快)
SELECT TABLE_ROWS FROM information_schema.TABLES
WHERE TABLE_NAME = 't';
-- 真有大量统计需求用 Redis 计数器
慢查询定位
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过 1 秒记录
-- 查看慢查询
SHOW VARIABLES LIKE 'slow_query%';
总结
| 机制 | 一句话 |
|---|---|
| B+Tree 索引 | 数据存叶子、叶子链式连接,范围查询利器 |
| 聚簇索引 | 主键索引的叶子存整行,二级索引叶子存主键 |
| 覆盖索引 | 查询列全在索引里,不用回表 |
| MVCC | undo log + ReadView,读不阻塞写 |
| RR vs RC | RR 事务内复用 ReadView(快照读),RC 每次新生成 |
| Record Lock | 锁单行 |
| Gap Lock | 锁间隙,防 insert(RR 特有) |
| Next-Key Lock | Record + Gap,InnoDB 默认加锁单位 |
| EXPLAIN | type 看访问方式,Extra 看额外操作 |
| 最左前缀 | 联合索引从最左列开始匹配 |