一句话结论
MySQL InnoDB 用 B+ 树作为索引结构——非叶子节点只存键不存数据、叶子节点存完整行并通过双向链表连接。千万级数据只需 3-4 层,范围查询顺序 I/O 极快。
核心原理
为什么 B+ 树
二叉树千万数据约 24 层(24 次随机 I/O),B+ 树每节点存 1000 个键 → 3 层(3 次 I/O)。
聚簇索引 vs 二级索引
项目应用
分布式电商交易系统 订单表:主键 B+ 树聚簇索引,SELECT WHERE id=? 直接 O(log n),范围走链表。
面试
Q: 为什么不用哈希索引? 哈希 O(1) 但不支持范围、排序——B+ 树更通用。
速记
非叶存键+叶子存数据+双向链表。千万 3 层。聚簇=完整行。二级=主键→回表。
深入原理
一、InnoDB 页结构(16KB)
InnoDB 所有数据、索引都以页(Page)为最小磁盘单位,默认 16KB。
+----------------------------------------------------+
| InnoDB Page (16KB) |
+----------------------------------------------------+
| File Header (38 bytes) 页类型/页号/校验和 |
| Page Header (56 bytes) 记录数/层级/槽数 |
| Infimum + Supremum (26 bytes) 最小/最大虚拟记录 |
| User Records (动态) 真正的数据行 |
| Free Space (动态) 空闲空间 |
| Page Directory (动态) 二分查找定位记录 |
| File Trailer (8 bytes) 校验和+匹配 |
+----------------------------------------------------+
关键字段详解:
记录之间通过单向链表连接(按主键升序),Page Directory 的 Slot 稀疏指向链表中"带头"的记录。页内查找流程:先二分定位 Slot,再线性扫描找到具体行。
B+ 树在磁盘上的完整结构:
[B+ 树非叶子节点(内部页)]
Page Level = 2,只存键 + 子页指针
扇出:每页存约 1200 个键指针
/ | \
[内部页 L=1] [内部页 L=1] [内部页 L=1]
键[5,10) + 指针 键[10,15) + 指针 键[15,20) + 指针
/ \ / \ / \
[叶子页 L=0] [叶子页 L=0] [叶子页 L=0] [叶子页 L=0] [叶子页 L=0] [叶子页 L=0]
[1,2,3,4,5] [6,7,8,9,10] ...
叶子页之间通过 File Header 的 FIL_PAGE_PREV / FIL_PAGE_NEXT 形成双向链表
→ 支持 ORDER BY key ASC/DESC 的顺序扫描,范围查询只需定位起点后沿链表顺序读
二、B+ 树 vs B 树 vs 红黑树 vs AVL vs 跳表 vs Hash
逐项分析为什么 MySQL 不用其他结构:
红黑树 / AVL: 千万数据约 24 层,24 次随机 I/O(每次读 1 个节点 → 1 个磁盘页 → 严重浪费页空间,1 页 16KB 只读几十字节)。二叉树的扇出为 2,B+ 树扇出约 1200(16KB/(8B键+4B指针)),差距 600 倍。
跳表: 虽然范围查询不错,但层级比 B+ 树多不少(跳表每层只跨越部分节点,B+ 树一层跨越整页节点)。每次访问多节点导致更多随机 I/O。更适合内存场景(Redis ZSet)。
Hash: O(1) 等值查询,但不支持范围、排序、最左前缀匹配、部分键匹配。Memory 引擎可选 Hash 索引。InnoDB 有自适应哈希索引(Adaptive Hash Index, AHI)——对热点等值查询自动在 Buffer Pool 中构建 Hash 索引,但不可手动控制。
B 树 vs B+ 树(核心区别): B 树非叶子节点也存数据 → 同样高度下 B+ 树叶子更多(扇出更大,约大 20%),树更矮。B 树范围查询需要中序遍历(回溯父节点),B+ 树叶子链表直接顺序扫。数据库场景下 B+ 树是明确更优选择。
三、聚簇索引叶子节点行格式
Compact 格式(MySQL 5.0 ~ 5.6)
+---------------+---------------+------+--------+--------+----------+-----+-----+-----+-----+
| 变长字段长度列表 | NULL 标志位 | 记录头 | row_id | trx_id | roll_ptr| 列1 | 列2 | ... | 列N |
| (每列1-2字节) | (每列1位) |(5字节)|(6字节) |(6字节) |(7字节) | | | | |
+---------------+---------------+------+--------+--------+----------+-----+-----+-----+-----+
|<--------------- 系统隐藏列 -------------->|<------ 用户数据列 ------->|
Dynamic 格式(MySQL 5.7+ 默认)
与 Compact 唯一区别:溢出页处理方式不同。
Compact:BLOB/TEXT/VARCHAR 超过约 768 字节时,前 768 字节存在本行内(前缀),其余存到溢出页(Overflow Page)。
Dynamic:溢出列完全不存前缀在行内,行内只存 20 字节的溢出页指针。好处:本页能存更多行,减少页分裂。
Compressed 格式
在 Dynamic 之上支持页级压缩(通过 KEY_BLOCK_SIZE 指定,如 4KB / 8KB)。适合 SSD 容量敏感场景,但 CPU 解压开销大。
四、回表过程完整图示
假设表 orders(id PK, user_id, status, amount, created_at),有二级索引 INDEX idx_uid (user_id)。
SELECT * FROM orders WHERE user_id = 201;
[SQL 执行层] 解析 WHERE user_id = 201
│
▼
[Server 层] 调用存储引擎 ha_innobase::index_read()
│
▼
┌─────────────────────────────────────────────────────────┐
│ 步骤 1:在二级索引 idx_uid(user_id) 中定位 201 │
│ │
│ idx_uid B+ 树(非叶子只存 user_id + 主键值) │
│ [150] │
│ / \ │
│ [100] [200] ← 内部页 │
│ / \ / \ │
│ [50] [120] [180] [201 → PK=55] ← 叶子页 │
│ ↓ │
│ 在叶子页找到 user_id=201,存主键 id=55 │
└──────────────────────┬──────────────────────────────────┘
│ 拿到主键 id=55
▼
┌─────────────────────────────────────────────────────────┐
│ 步骤 2(回表):用主键 id=55 在聚簇索引中查完整行 │
│ │
│ 聚簇索引 B+ 树(叶子存完整行数据) │
│ [100] │
│ / \ │
│ [50] [150] │
│ / \ / \ │
│ [20] [55*] [120] [180] │
│ ↓ │
│ { id:55, user_id:201, status:'paid', │
│ amount:99.90, created_at:'2026-06-01' } │
└──────────────────────┬──────────────────────────────────┘
│ 返回完整行
▼
[Server 层] → 返回结果给客户端
EXPLAIN 解读: rows 字段是步骤 1 + 步骤 2 扫描行数的估算。Extra: Using index 表示没有步骤 2(覆盖索引,不需要回表)。
五、索引下推(ICP)详细执行流程
ICP(Index Condition Pushdown)MySQL 5.6 引入:将 WHERE 中索引列上的过滤条件下推到存储引擎,在索引层面过滤,减少回表。
-- 联合索引:INDEX idx_name_age (name, age)
SELECT * FROM users WHERE name LIKE '张%' AND age = 20;
无 ICP(MySQL 5.5):
存储引擎:扫描 idx_name_age 中所有 name LIKE '张%' 的行
→ 10 万行全部返回给 Server 层
Server 层:逐行检查 age = 20,保留 500 行
→ 500 次回表取完整行
开销:扫描 10 万行索引 + Server 层处理 10 万行 + 500 次回表
有 ICP(MySQL 5.6+):
存储引擎:扫描 idx_name_age 中所有 name LIKE '张%' 的行
→ 在索引内直接检查 age = 20(索引中已有 age!)
→ 过滤后只返回 500 行给 Server 层
Server 层:500 行直接回表取完整行
开销:扫描 10 万行索引 + 500 次回表
节省:Server 层少处理 99500 行(大幅减少 CPU 和网络传输)
EXPLAIN 特征: Extra 字段显示 Using index condition。
ICP 不生效的场景:
查询 type 不是 range / ref / eq_ref / ref_or_null(如 type=ALL 或 index 不走 ICP)
包含子查询
引用了存储函数或触发器
六、联合索引与最左前缀原理(深入)
联合索引 (a, b, c) 在 B+ 树叶子节点中按 a → b → c 优先级排序:
叶子节点数据(按排序键排列):
a=1, b=1, c=1 → PK=101
a=1, b=1, c=2 → PK=205
a=1, b=2, c=1 → PK=108
a=1, b=2, c=3 → PK=330
a=2, b=1, c=1 → PK=155
a=2, b=2, c=1 → PK=240
a=3, b=1, c=1 → PK=180
...
为什么跳过第一列不走索引? B+ 树数据先按 a 全局有序,再在 a 值相同的段内按 b 有序,再在 (a,b) 相同的段内按 c 有序。如果跳过 a 直接查 b,b 在全表范围内是无序的(b=1 分散在 a=1、a=2、a=3... 各处),B+ 树无法二分查找定位。
WHERE a=1 AND b=2:
→ 二分定位到 a=1 区间起始,再在 a=1 的连续段内二分找 b=2
→ 高效(两次二分)
WHERE b=2(无 a):
→ b=2 不连续,分散在全表 → 只能全索引扫描(type=index)或全表(type=ALL)
WHERE a=1 AND c=3:
→ a=1 定位成功,但 c 在 a=1 的区间内不全局有序(c 只在 (a,b) 相同时有序)
→ 能用 a 部分做 range,c 走 ICP 过滤(Using index condition)
→ key_len 只算 a 的长度
经典面试陷阱:
-- 索引 (a, b)
WHERE a > 10 AND b = 5
-- a 范围扫描,b 在 a 范围内用于 ICP 过滤(不是索引查找)
-- key_len = a 的长度
-- 索引 (a, b, c)
WHERE a = 1 AND b > 10 AND c = 5
-- a 等值,b 范围,c 不走索引查找
-- key_len = a + b 的长度
-- BETWEEN 也是范围:WHERE a=1 AND b BETWEEN 10 AND 20 AND c=5
-- 同样 c 不走索引
-- 同一列的多个等值 = 范围
WHERE a = 1 AND b IN (2, 3) AND c = 5
-- b 走范围(IN 是多个等值),c 不走
七、EXPLAIN 输出字段完整详解
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'paid';
type 字段从优到劣(逐级详解):
NULL > system > const > eq_ref > ref > fulltext > ref_or_null
> index_merge > unique_subquery > index_subquery
> range > index > ALL
Extra 值精解:
八、索引失效 10 种场景
-- 前提:表 orders,存在联合索引 INDEX idx_uid_status (user_id, status)
九、Cardinality 与索引选择性
Cardinality = 索引列/组合列不同值的估算数量。
SHOW INDEX FROM orders;
-- Cardinality 列:user_id=50000, status=3
索引选择性 = Cardinality / 总行数
user_id选择性强(50000 / 100000 ≈ 0.5)→ 走索引命中小于 0.1% 的行 → 适合建索引status选择性弱(3 / 100000 ≈ 0.00003)→ 每个值命中 1/3 的表 → 优化器走全表
优化器决策过程:
用 Cardinality 估算
WHERE user_id=100命中100000 / 50000 ≈ 2 行→ 2 次随机回表 I/O < 全表顺序读 → 走索引WHERE status='paid'命中100000 / 3 ≈ 33333 行→ 33333 次随机回表 >> 全表顺序读 → 全表扫描
注意事项:
Cardinality 是估算值(InnoDB 随机采样 8 个叶子页),非精确
ANALYZE TABLE t;可刷新统计联合索引的 Cardinality 按最左前缀计算:
(user_id, status)的 Cardinality 近似 user_id 的去重值统计持久化:
innodb_stats_persistent = ON时统计落盘,重启不丢失
十、索引设计实战案例(电商订单表)
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
shop_id BIGINT NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已发货 3已完成 4已取消
amount DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_created (user_id, created_at)
) ENGINE=InnoDB;
场景 1:买家查自己订单(按状态筛选 + 分页 + 按时间倒序)
-- 频率:每天百万次
SELECT id, status, amount, created_at
FROM orders WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT 20;
索引设计:INDEX idx_ust (user_id, status, created_at)
user_id 等值 + status 等值 → 左前缀完美匹配
ORDER BY created_at DESC → 在 (user_id,status) 确定的小区间内,数据已按 created_at 有序 → 无 filesort
id, amount不在索引 → 需回表。如不想回表可建覆盖索引(user_id, status, created_at, id, amount),但索引变大且写入变慢
场景 2:商家查店铺订单(多状态 + 分页 + 时间排序)
SELECT id, user_id, amount, created_at
FROM orders WHERE shop_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?, 20;
索引设计:INDEX idx_shop_status_time (shop_id, status, created_at)
场景 3:后台对账(时间区间 + 多状态汇总)
SELECT COUNT(*), SUM(amount)
FROM orders WHERE created_at BETWEEN '2026-06-01' AND '2026-06-07'
AND status IN (1, 2, 3) AND shop_id = ?;
索引设计原则:先等值后范围,先高选择性后低选择性。哪个条件过滤掉的越多,越应该放前面。
如果
shop_id一个商家日均 10 单(选择性高)→INDEX (shop_id, created_at, status)如果
created_at一星期共 1 万行(选择性尚可)→ 也可INDEX (created_at, shop_id, status)
深度面试追问
Q1:一张 2000 万行的订单表,WHERE user_id=100 ORDER BY created_at LIMIT 20 很慢,你建什么索引?如果再加 AND status='paid' 呢?
30 秒回答: 第一个场景建 (user_id, created_at) 联合索引,等值 user_id 后数据天然按 created_at 有序,无需 filesort。加了 AND status='paid' 后建 (user_id, status, created_at),等值在前、排序在后。
深入回答: 联合索引本质是 B+ 树叶子节点按索引列顺序物理排序。(user_id, created_at) 中数据先按 user_id 全局有序再按 created_at 有序,所以 WHERE user_id=100 定位到起始后,该范围内的数据天然按 created_at 排好——直接顺序读走人,Extra 显示 Using index condition 而非 Using filesort。加了 status:(user_id, status, created_at) 中数据先按 user_id 排,同一 user_id 内按 status 排,同一 (user_id,status) 内按 created_at 排。WHERE user_id=100 AND status='paid' 精确定位到 (100,'paid') 区间的起始位置,区间内 created_at 有序。注意:status 必须是等值,如果用 status IN(1,2,3) 则是范围,created_at 的有序性被打破,filesort 回归。
追问 1: 如果 QPS 10000,索引每次都命中,还能怎么优化?
读写分离:从库扛读流量。热点用户订单进 Redis 缓存(查询前查缓存,命中直接返回)。如果单表太大考虑按 user_id 分库分表,每个分片数据量减小。
追问 2: 深分页 LIMIT 100000, 20 怎么优化?
经典问题——MySQL 需扫描前 100020 行再丢弃 100000 行。优化:延迟关联——先在覆盖索引上取主键 ID,再 JOIN 回表取完整行:
SELECT * FROM orders o INNER JOIN (SELECT id FROM orders WHERE user_id=100 ORDER BY created_at LIMIT 100000,20) t ON o.id=t.id。更优方案:游标分页——记住上一页最后一条的(created_at, id),用WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20代替 OFFSET。
追问 3: 游标分页有个问题——如果用户同时按 status 筛选,怎么处理?
组合游标:把 status 也纳入排序条件,游标变为
WHERE (status, created_at, id) < (?, ?, ?)。前提是索引包含这些列。
Q2:为什么 InnoDB 必须有主键?不指定主键会怎样?
30 秒回答: InnoDB 按主键组织数据(聚簇索引),必须有主键。不指定时,优先选第一个 NOT NULL UNIQUE 索引;都没有则自动生成 6 字节隐藏列 DB_ROW_ID 做主键。
深入回答: 聚簇索引是 InnoDB 数据的唯一物理存储方式——叶子页存完整行,数据按主键有序排列。主键选择优先级:1) 用户指定的 PRIMARY KEY;2) 第一个 NOT NULL UNIQUE 索引;3) 自动生成 6 字节 DB_ROW_ID(用户不可见,全局递增)。使用隐式 DB_ROW_ID 的问题:无法跨表关联、无法用于应用查询、无法用于排序分页。生产必须显式指定主键,且强烈建议自增 BIGINT——自增主键插入总是追加到 B+ 树最右侧,页填充率 ~95%,几乎无页分裂。
追问 1: UUID 做主键 vs 自增 ID,差多少?
UUID 是随机的——插入随机定位到 B+ 树中间 → 频繁页分裂 → 页填充率 ~65% vs 自增的 ~95%。每次分裂移动一半记录产生大量随机 I/O。二级索引叶子存主键值,UUID 36 字节 vs BIGINT 8 字节,索引体积大 4.5 倍,回表更慢。但分布式系统有时必须用雪花算法(Snowflake)生成趋势递增的 64 位 ID 替代。
追问 2: 雪花算法 ID 趋势递增但不严格递增,也会页分裂吗?
会但比 UUID 好很多。时间戳在高位保证整体趋势递增,大多数插入在 B+ 树右侧。因为不同机器生成的 ID 可能乱序(时钟差异、机器 ID 不同),少数插入会落到中间——页填充率约 85-90%。优化手段:把 worker_id 放低位而非高位,同一时间窗口内更有序。
Q3:EXPLAIN 看到 type=ALL 但有 possible_keys,为什么不用索引?
30 秒回答: 优化器基于代价模型判断——走索引的代价(随机回表 I/O)大于全表扫描(顺序 I/O)时,主动放弃索引。通常发生在索引选择性极低(如 status 只有 3 个值)的场景。
深入回答: 代价估算核心公式:全表扫描 ≈ 聚簇索引页数 × 顺序读系数;索引扫描 ≈ 需要扫描的索引行数 × 随机读系数(回表是随机 I/O)。随机读 vs 顺序读的成本比约 4:1。当 WHERE status='paid' 预计命中 33% 行时,33 万次随机回表 I/O > 1 次全表顺序读,优化器选全表。可用 FORCE INDEX(idx) 强制走索引实测对比(不要盲目用 FORCE,它只是验证工具)。
追问 1: ANALYZE TABLE 后 Cardinality 变了导致执行计划变了,怎么稳定?
正常现象——采样随机性。可用 Optimizer Hint 如
SELECT /*+ INDEX(t idx_name) */ ...固定索引,或增大innodb_stats_persistent_sample_pages(默认 20,可到 100)让统计更稳定。
追问 2: FORCE INDEX 和 USE INDEX 区别?
USE INDEX是"建议"(优化器可能不听),FORCE INDEX是"强制"(优化器几乎必须用)——但仍然不能强制 index_merge。FORCE INDEX不优化表扫描。
Q4:5000 万行表在线加索引要注意什么?
30 秒回答: MySQL 5.6+ 使用 ALGORITHM=INPLACE, LOCK=NONE 在线加索引不阻塞读写。但需要关注磁盘空间(约等于表大小 + 索引大小)、复制延迟(从库回放慢)、以及极端情况下回滚代价。生产用 pt-online-schema-change 或 gh-ost 更安全。
深入回答:
DDL 方式演变: MySQL 5.5 COPY 方式锁表(5000 万行可能数小时)。5.6+ 的 INPLACE 在线加索引原理:在原表上构建新索引,期间对原表的修改写入在线日志(Online Log),索引构建完成后回放日志,全程只加两次短元数据锁。8.0 大部分 DDL 支持 INSTANT。
磁盘空间: 至少留
表大小 + 新索引大小 + 临时排序文件的剩余空间。复制延迟: 主库 DDL 完成后从库串行执行——如果从库是单线程(5.6),延迟会非常大。8.0 并行复制可缓解。
最佳实践: 用
gh-ost(基于 binlog 同步,对原表影响最小,支持暂停限速测试模式)或pt-osc(基于触发器,有开销但稳定)。先在从库验证,确定时间后再主库操作。
追问 1: gh-ost vs pt-osc 核心区别?
gh-ost从 binlog 读取增量(无需触发器,切换到新表时更安全,支持测试模式)。pt-osc用触发器同步增量(触发器有性能开销 10-30%)。gh-ost支持--migrate-on-replica(先在从库执行,再切换主从)。
追问 2: 加索引过程中服务器挂了怎么办?
INPLACE DDL 期间,InnoDB 将进度的 LSN 写入 Redo Log。崩溃恢复后可继续构建。但实际生产中依赖工具重试——
gh-ost支持断点续传(--throttle-control-replicas)。
Q5:COUNT(*)、COUNT(1)、COUNT(id)、COUNT(col) 有什么区别?
30 秒回答: COUNT(*) 和 COUNT(1) 完全相同——统计行数。COUNT(col) 统计 col IS NOT NULL 的行数。COUNT(id) 在主键上统计(id 不为 NULL),和不指定列的 COUNT 语义相同但性能可能略差。InnoDB 的 COUNT(*) 会选最小二级索引全扫。
深入回答:
COUNT(*)=COUNT(1)=COUNT(0)= ...:InnoDB 优化器将它们视为完全等价——统计所有行。优化器会自动选择最小的二级索引进行全索引扫描(二级索引叶子页远小于聚簇索引叶子页)。COUNT(col):统计 col IS NOT NULL 的行数。col 有索引则走该索引,无索引则全表扫描。COUNT(id):id 主键 NOT NULL,语义同 COUNT(),但可能走聚簇索引(页更大)——不如 COUNT() 聪明。MyISAM 的 COUNT(*) O(1)(行数存于表元数据)但 MyISAM 不支持事务。
InnoDB 不缓存 COUNT(*):因为 MVCC——不同事务看到的快照不同,A 看到 100 行,B 看到 105 行,无法缓存一个全局值。每次 COUNT(*) 必须实时计算可见行数。
追问 1: 5000 万行 SELECT COUNT(*) FROM orders WHERE user_id=100 很慢,怎么优化?
非精确场景用
EXPLAIN看 rows 估算值。精确场景:Redis 维护计数器(创建 +1,删除 -1);汇总表定时聚合。最终手段:按 user_id 分表减小单用户数据量。
追问 2: 为什么优化器选二级索引而不是聚簇索引做 COUNT(*)?
二级索引叶子只存索引列 + 主键,远小于聚簇索引叶子(存完整行)。同样全扫,二级索引页数少 → 更少 I/O。