InnoDB 是 MySQL 5.5+ 的默认引擎,几乎承担了所有互联网 OLTP 场景。它的特性多且互相串联——面试要分清"存储结构层 / 内存结构层 / 后台线程层 / 关键特性层"四块怎么协同。
本篇解决:① InnoDB 物理存储是什么样 → ② 内存里 Buffer Pool / Change Buffer / Log Buffer 各自的作用 → ③ 后台线程在干什么 → ④ Insert Buffer / Double Write / AHI 这些高频特性的设计动机和坑。
┌─ 内存结构(In-Memory)─────────────────────────────────┐
│ ┌──────────────────────────────────────────────┐ │
│ │ Buffer Pool │ │
│ │ ├ 数据页 / 索引页缓存(LRU 双链) │ │
│ │ ├ Change Buffer(二级索引写优化) │ │
│ │ ├ Adaptive Hash Index(热点页哈希) │ │
│ │ └ Lock Info / Data Dictionary 区 │ │
│ └──────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Log Buffer(redo log buffer,默认 16MB) │ │
│ └──────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
│ fsync / 刷盘
┌─ 磁盘结构(On-Disk)────────────────────────────────────┐
│ ibdata1 共享表空间(系统数据 + 部分 undo)│
│ ib_logfile0/1 redo log(环形写) │
│ undo_001 / undo_002 undo 表空间(5.7+ 可独立) │
│ <db>/<table>.ibd 独占表空间(数据 + 索引) │
└────────────────────────────────────────────────────────┘
↑
┌─ 后台线程 ──────────────────────────────────────────────┐
│ Master Thread / IO Thread / Purge Thread / Page Cleaner│
└────────────────────────────────────────────────────────┘
| 类型 | 文件 | 内容 | 配置 |
|---|---|---|---|
| 系统表空间 | ibdata1 |
数据字典(5.7)/ Change Buffer / Double Write Buffer / 部分 undo | 5.7 默认 |
| 独立表空间 | <db>/<table>.ibd |
每张表自己的数据 + 索引 | innodb_file_per_table=ON(5.6+ 默认) |
| undo 表空间 | undo_NNN |
undo log | 5.7+ 可独立,回收方便 |
| 临时表空间 | ibtmp1 |
临时表 / 排序临时数据 | 5.7+ 独立 |
强烈建议:保持
innodb_file_per_table=ON。否则DROP TABLE不会真正释放磁盘(ibdata1 只增不减),生产事故重灾区。
表空间 (Tablespace)
├─ 段 (Segment) 每个索引一个段(数据段 + 索引段)
│ ├─ 区 (Extent) 1 区 = 64 个页 = 1MB
│ │ ├─ 页 (Page) 默认 16KB,InnoDB 最小 IO 单位
│ │ │ └─ 行 (Row)
关键参数:
- 一页 16KB,InnoDB 加载和刷盘都以页为单位
- 一区 1MB(64 页),保证连续磁盘空间
- B+Tree 一层节点对应若干页
InnoDB 5.7+ 默认 DYNAMIC 行格式:
[变长字段长度列表][NULL 标志位][记录头][列1][列2]...[trx_id][roll_ptr]
要点:
- 变长字段 ≤768 字节存行内,超过用溢出页(off-page)
- 一行最大 8KB(半页)——超出会拒绝插入或某些列变溢出页
- 三个隐藏列:
DB_TRX_ID(6B)+DB_ROLL_PTR(7B)+DB_ROW_ID(6B,无主键时才有)
生产坑:单行超过 8KB 会报错
Row size too large。常见于 VARCHAR(65535) 大量列、或 utf8mb4 下 VARCHAR 长度被 ×4。
每个 16KB 页内部:
File Header (38B) 页号、前后页指针、校验和
Page Header (56B) 页内行数、空闲空间、最大 trx_id
Infimum + Supremum 哨兵记录(最小/最大)
User Records 实际数据,按主键有序,单链表
Free Space 空闲区
Page Directory 稀疏索引,4~8 行一个 slot,二分查找
File Trailer (8B) 校验和(与 Header 对应)
Page Directory 的作用:行内单链表查找是 O(N),加上 Page Directory 后变 O(log N)——这是页内查找快的关键。
InnoDB 性能的核心。没有 Buffer Pool,每次查询都要随机 IO,吞吐量直接除以 100。
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';生产配置:物理内存的 50%~75%(DB 专用机)。
多实例:
SHOW VARIABLES LIKE 'innodb_buffer_pool_instances';默认 8 个,减少 mutex 争用。每实例独立 LRU、独立锁。
普通 LRU 的两大问题:
- 预读污染:InnoDB 一次预读 64 页,多数用不到 → 把热点页挤掉
- 全表扫描污染:一次大表扫描把 Buffer Pool 全冲刷
InnoDB 的两段式 LRU:
LRU 链表
├─ Young 区(默认 5/8) ← 真正的热点
│
└─ Old 区(默认 3/8) ← 新加载/预读的页先进这里
↑
新加载页插入 Old 区头部
晋升规则:页在 Old 区被再次访问,且距首次访问 > innodb_old_blocks_time(默认 1000ms),才晋升到 Young 区头部。
设计意图:全表扫描时,同一页通常在毫秒内被多次访问,不会满足 1000ms 条件 → 一直停留 Old 区 → 不污染 Young。
| 链表 | 作用 |
|---|---|
| Free List | 空闲页链表 |
| LRU List | 已使用页(含脏页 + 干净页),管淘汰 |
| Flush List | 仅脏页,按 LSN 排序,管刷盘 |
关键点:脏页同时挂在 LRU 和 Flush List 上——LRU 决定它能不能被淘汰(脏页淘汰前必须先刷),Flush List 决定它什么时候刷。
Change Buffer:解决"二级索引页不在 Buffer Pool 时,写操作的磁盘随机 IO 开销"。
INSERT INTO t (id, name) VALUES (1, '张三');
-- 主键索引:id 顺序插入,页基本在 Buffer Pool(命中率高)
-- 二级索引(如 idx_name):name 是离散值,对应索引页大概率不在内存 → 随机 IO
写操作 → 二级索引页在 Buffer Pool 吗?
├─ 在 → 直接改
└─ 不在 → 暂存到 Change Buffer(不读盘)
│
▼
未来某次该页被读时(merge)
或后台线程定期 merge
│
▼
合并应用所有缓冲的变更
收益:把随机读 变成 顺序写 + 一次合并 IO,IOPS 节省 5 倍以上。
✅ 必须满足:
- 二级索引(聚簇索引按主键顺序,本来就快)
- 非唯一索引(唯一索引必须读页验证唯一性)
❌ 不适用:
- 主键索引
- 唯一索引(INSERT 必须查冲突,必须读盘)
- 变更后立即被读的页(没时间累积,merge 提前)
自适应哈希索引:对反复访问同一种条件的热点页建哈希。
B+Tree 三层 → 每次查 3 次 IO
↓
建好 AHI 后 → 1 次哈希查找
触发条件:
- 同一页用同种 WHERE 模式被访问 ≥100 次
- 模式必须固定(
WHERE a=?和WHERE a=? AND b=?算两种模式,会让该页失去 AHI)
生产坑:
- 访问模式不规律时,AHI 反而是开销(哈希维护成本)
- 高并发场景下 AHI 锁竞争是瓶颈,需考虑关闭:
SET GLOBAL innodb_adaptive_hash_index = OFF;
SHOW VARIABLES LIKE 'innodb_log_buffer_size'; -- 默认 16MB写 redo log 的内存缓冲。增大它减少大事务的刷盘次数,但占内存。通常默认够用——异常增大要先排查是否有大事务。
历史名字叫 Insert Buffer,5.5 后扩展支持 update/delete,改名 Change Buffer——网上老资料还会用 Insert Buffer,是同一个东西。
问题:InnoDB 页 16KB,OS 写盘最小单位 4KB,断电时可能只写了 4KB → 页损坏(partial page write)。
为什么 redo log 救不了?
redo log 是物理日志,记的是"在页 X 的偏移 Y 写 Z"——前提是页 X 本身完整。页都坏了,redo log 也无能为力。
脏页要刷盘
│
▼
1. 先 memcpy 到 Double Write Buffer(内存 2MB)
│
▼
2. Double Write Buffer 顺序写入共享表空间(连续 128 页)
┌─ 第 1 次 fsync 写 1MB
└─ 第 2 次 fsync 写 1MB
│
▼
3. 再把脏页写到各自表空间(离散写)
重启时检查页 checksum
├─ 页完整 → 正常恢复
└─ 页损坏 → 从 Double Write Buffer 找副本 → 还原 → apply redo
每个脏页要写两次 → 写放大 2x。但因为 Double Write 是顺序写,实际开销约 +10%——可以接受。
SET GLOBAL innodb_doublewrite = OFF;仅以下场景:
- 文件系统/磁盘自身保证原子写(如 ZFS、某些 SSD)
- 极端写入压力 + 可容忍数据损坏(极少见)
默认开启不要关。生产 99% 的实例都开。
InnoDB 用 Linux Native AIO 同时发起多个 IO 请求 + 自动合并相邻页([1,2]+[2,3] → 连续读 1~3)。
SHOW VARIABLES LIKE 'innodb_use_native_aio';刷一脏页时,顺带刷它在同一区内的其它脏页。
| 场景 | 设置 |
|---|---|
| 机械盘 | 开(默认)— 利用顺序 IO |
| SSD | 关 (innodb_flush_neighbors=0)— SSD 随机 IO 已快,多刷反成开销 |
最核心的线程,负责协调:每秒任务(合并 Change Buffer 5%、刷脏页 ≤10%、刷 redo),每 10 秒任务(刷脏页、合并 Insert Buffer、回收 undo)。
5.5 之后大量职责被拆出去给专门线程,但 Master 仍是节奏总指挥。
SHOW VARIABLES LIKE 'innodb_%io_threads';read线程:处理读请求(默认 4 个)write线程:处理写请求(默认 4 个)insert buffer线程:合并 Change Bufferlog线程:刷 redo log
回收 undo log。条件:没有任何活跃 ReadView 还可能读到这个版本。
SHOW VARIABLES LIKE 'innodb_purge_threads'; -- 默认 4
SHOW ENGINE INNODB STATUS\G -- 看 History list lengthHistory list 长 → 大概率有长事务卡住 Purge → undo 不释放 → ibdata 膨胀(高频生产事故)。
把脏页从 Buffer Pool 异步刷盘,5.6+ 从 Master 拆出来。
SHOW ENGINE INNODB STATUS\G输出里:
Pending normal aio reads/writes:IO 队列History list length:未清理 undo 数Modified db pages:脏页数
# 内存
innodb_buffer_pool_size = 物理内存的 50%~75%
innodb_buffer_pool_instances = 8
innodb_log_buffer_size = 16M
# 文件
innodb_file_per_table = ON # 永远开
innodb_data_file_path = ibdata1:1G:autoextend
# redo log
innodb_log_file_size = 1G ~ 4G # 太小频繁切换
innodb_log_files_in_group = 2
# 可靠性
innodb_doublewrite = ON # 默认,不要关
innodb_flush_log_at_trx_commit = 1 # 双 1 高可靠
sync_binlog = 1
# IO
innodb_io_capacity = 2000 # SSD 调高
innodb_io_capacity_max = 4000
innodb_flush_neighbors = 0 # SSD 关
innodb_use_native_aio = ON
# 并发
innodb_purge_threads = 4
innodb_thread_concurrency = 0 # 默认不限制
# AHI
innodb_adaptive_hash_index = ON # 默认;高并发瓶颈时可关DROP TABLE 后 ibdata1 不变小——innodb_file_per_table=OFF 的老库都有这病。
根因:共享表空间一旦扩大,文件大小不会缩小。
唯一解:导出数据 → drop schema → 改 file_per_table=ON → 导回。
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
-- 1 - reads/read_requests = 命中率低于 99% 时排查:
- Buffer Pool 太小
- 大全表扫描污染(看
slow_log) - 某 SQL 走错索引扫海量页
SHOW ENGINE INNODB STATUS\G
-- History list length: 50000000 ← 5000 万,警告找慢事务 kill:
SELECT * FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;并发刷盘时 Double Write Buffer 是单点 → 高并发写场景见过 IO 瓶颈在 dblwr。8.0+ 的 innodb_doublewrite_files 可拆多文件缓解。
主键有序插入本来快,唯一索引必须读页查冲突——只有"非唯一二级索引"既离散又不需要立即读,最适合缓冲。
防止预读和全表扫描污染热点。新加载页先进 Old 区,1 秒后再次访问才进 Young——一次性扫描的页停留 Old 区,不挤掉真正热数据。
Partial Page Write:OS 写 16KB 页时只写了 4KB 就断电,页损坏。redo log 是物理日志,依赖页本身完整——页坏了 redo 也没法 apply。Double Write 在共享表空间留一份完整页副本,恢复时先用副本还原页,再 apply redo。
热点访问模式必须稳定才有用。访问模式不规律时,维护 AHI 自身(哈希插入/查找/锁)开销大于收益;并发高时 AHI 锁还会成为瓶颈。
四个时机:
- redo log 快写满(强制 checkpoint)
- Buffer Pool 不够用(要淘汰脏页)
- MySQL 空闲时
- 正常关闭时(全部刷完才退出)
由 Page Cleaner 线程异步执行。
机械盘随机 IO 慢、顺序 IO 快,多刷相邻页摊薄寻道成本——划算。SSD 没寻道开销,多刷反而增加写放大。
8KB(半页)。超过就拒绝插入或某些大列变成溢出页。VARCHAR(65535) 一行只能放一列且要 utf8 字符集。
InnoDB 是 MySQL 默认引擎,提供事务、行锁、MVCC、崩溃恢复——OLTP 必选。
存储上分段→区→页→行,页 16KB 是最小 IO 单位;行格式 DYNAMIC,含两个必有的隐藏列 trx_id、roll_pointer,用于 MVCC。
内存最关键的是 Buffer Pool(缓存数据页 + 索引页,建议物理内存 50%~75%),用两段式 LRU 防全表扫描污染热点。Change Buffer 把"非唯一二级索引页不在内存"时的随机读优化成累积合并;AHI 给热点页建哈希。
可靠性靠 Double Write Buffer 防部分写失效——共享表空间留完整页副本,崩溃时先还原页再 apply redo log。
后台有 Master、IO、Purge、Page Cleaner 等线程协同:Master 调度、IO 处理读写、Purge 回收 undo(长事务会卡这个)、Page Cleaner 异步刷脏页。