MySQL InnoDB 核心机制:MVCC、索引、事务、锁与 SQL 优化

Posted on Dec 27, 2024

面试必问,生产必用。把 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

EXPLAINExtra: 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 COMMITTEDREPEATABLE READ
ReadView 生成每个 SELECT 都生成一个新的事务第一次 SELECT 时生成,之后复用
效果能看到其他事务提交的修改整个事务看到的数据一致(快照)

这就是为什么 RR 能避免不可重复读 — ReadView 不变,看到的始终是事务开始时的快照。


InnoDB 锁机制

锁类型

-- 查看当前锁状态
SELECT * FROM performance_schema.data_locks;
SHOW ENGINE INNODB STATUS;
说明
Record Lock锁定单行记录(索引记录)
Gap Lock锁定索引记录间的间隙,阻止 insert
Next-Key LockRecord Lock + Gap Lock,锁定记录及其前间隙
Insert Intention Lockinsert 前申请的间隙锁,不冲突
共享锁 (S)SELECT ... LOCK IN SHARE MODE,允许其他 S 锁,阻塞 X 锁
排他锁 (X)SELECT ... FOR UPDATE / UPDATE / DELETE,阻塞所有其他锁

加锁规则(RR 隔离级别下)

两条核心原则:

  1. 加锁基本单位是 Next-Key Lock(前开后闭区间)
  2. 等值查询唯一索引退化为 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预估扫描行数
ExtraUsing 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 索引数据存叶子、叶子链式连接,范围查询利器
聚簇索引主键索引的叶子存整行,二级索引叶子存主键
覆盖索引查询列全在索引里,不用回表
MVCCundo log + ReadView,读不阻塞写
RR vs RCRR 事务内复用 ReadView(快照读),RC 每次新生成
Record Lock锁单行
Gap Lock锁间隙,防 insert(RR 特有)
Next-Key LockRecord + Gap,InnoDB 默认加锁单位
EXPLAINtype 看访问方式,Extra 看额外操作
最左前缀联合索引从最左列开始匹配