一句话结论

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)  校验和+匹配              |
+----------------------------------------------------+

关键字段详解:

组件

大小

作用

File Header

38 B

FIL_PAGE_SPACE_ID(表空间ID)、FIL_PAGE_OFFSET(页号)、FIL_PAGE_PREV / FIL_PAGE_NEXT(双向链表连接相邻页,用于叶子节点间的范围扫描)、FIL_PAGE_TYPE(标识是索引页/Undo 页/Blob 页)

Page Header

56 B

PAGE_N_RECS(记录数,不含 Infimum/Supremum)、PAGE_LEVEL(B+ 树层级,0 = 叶子页)、PAGE_N_DIRECTION(插入方向,用于判断是否顺序插入)

Infimum

13 B

虚拟"最小记录",逻辑上比任何真实记录都小,作为页内记录链表头

Supremum

13 B

虚拟"最大记录",逻辑上比任何真实记录都大,作为页内记录链表尾

Page Directory

变长

由 Slot(槽)组成,每个 Slot 指向一组记录中的最大记录。通过二分定位 Slot 再线性扫描组内记录,加速页内查找(不用遍历整页链表)

File Trailer

8 B

前 4 字节是 LSN 的低位(用于崩溃恢复校验),后 4 字节是页校验和。若写盘后读回来校验和不一致 → 说明页损坏

记录之间通过单向链表连接(按主键升序),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

特性

B+ 树

B 树

红黑树

AVL 树

跳表

Hash

非叶存数据

不存,只存键

存完整行

N/A

N/A

N/A

N/A

叶子互连

双向链表

无

无

无

层级链表

无

高度(千万行)

~3 层

~3 层

~24 层

~24 层

~20-24 层

N/A

范围查询

O(log N+M) 顺序 I/O

需中序遍历

O(N)

O(N)

O(log N+M)

不支持

排序友好

天然有序

天然有序

否

否

天然有序

否

磁盘 I/O 模式

每次读一页,扇出高

每次读一页

每次读 1 节点

每次读 1 节点

每次读 1 节点

随机读

插入删除

页分裂/合并

页分裂/合并

旋转

旋转

概率调整

O(1)

MySQL 使用

InnoDB 默认索引

未使用

未使用

未使用

未使用

Memory 引擎

适合场景

磁盘数据库

磁盘数据库(次优)

内存数据结构

内存数据结构

内存/Redis

纯等值查询

逐项分析为什么 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字节)  |     |     |     |     |
+---------------+---------------+------+--------+--------+----------+-----+-----+-----+-----+
                 |<--------------- 系统隐藏列 -------------->|<------ 用户数据列 ------->|

隐藏列

大小

说明

DB_ROW_ID

6 B

行 ID。仅当用户未定义主键、且无 NOT NULL UNIQUE 索引时,InnoDB 自动生成。有主键/唯一索引时此列不存在

DB_TRX_ID

6 B

最近修改本行的事务 ID(MVCC 核心)

DB_ROLL_PTR

7 B

指向上一个版本在 Undo Log 中的地址(版本链指针)

NULL 位图

变长

每列为 1 bit,1 = NULL。紧随记录头之后

变长字段长度

变长

VARCHAR/VARBINARY/BLOB/TEXT 列的实际长度,每列 1-2 字节

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';

列名

含义

解读要点

id

SELECT 标识符

多表关联时同一数字为一组,从上到下执行;数字大的优先级高;包含子查询时值不同

select_type

查询类型

SIMPLE(简单查询)、PRIMARY(最外层)、SUBQUERY(子查询)、DERIVED(派生表/子查询在 FROM 中)、UNION、UNION RESULT、DEPENDENT SUBQUERY(依赖外层查询,极慢,需优化)

table

表名

<derivedN> 表示第 N 个派生表;<unionM,N> 表示并集

partitions

命中分区

分区表才需关注

type

访问类型

从优到劣见下表

possible_keys

可能使用的索引

空 = 无可用索引;有值但 key 为空 = 优化器选了全表

key

实际使用的索引

空 = 全表或全索引扫描

key_len

索引使用的字节数

越大越好——越大表示联合索引中越多列被使用。可用它倒推哪些列被用上了

ref

与索引比较的值

const = 常量比较;db.col = 关联查询中驱动表的列

rows

估算扫描行数

InnoDB 统计值,不精确但趋势可信。越小越好

filtered

WHERE 过滤后剩余行百分比

100% = 无额外过滤。ICP 会提升此值

Extra

额外信息

见下方表格

type 字段从优到劣(逐级详解):

NULL > system > const > eq_ref > ref > fulltext > ref_or_null
     > index_merge > unique_subquery > index_subquery
     > range > index > ALL

type

含义

触发条件

代码示例

NULL

不需访问表

SELECT 1

SELECT 1 FROM DUAL

system

表只有 1 行

MyISAM / Memory 的特殊常量表

极少见

const

主键/唯一索引等值,最多 1 行

WHERE id = 1 命中主键

SELECT * FROM t WHERE pk=1

eq_ref

关联查询,被驱动表用主键/唯一索引等值关联

JOIN ON a.id = b.id

驱动表每行只匹配被驱动表 1 行

ref

非唯一索引等值

WHERE user_id = 100(普通索引)

可能匹配多行,但走索引

ref_or_null

ref + IS NULL 条件

WHERE col = 1 OR col IS NULL

比 ref 多一次 NULL 查找

index_merge

多个索引合并结果

WHERE a=1 OR b=2

Extra 显示 Using union/intersect

range

索引范围扫描

>, <, BETWEEN, IN(), LIKE 'x%'

日常优化下限目标

index

全索引扫描(扫描整个索引树)

SELECT col FROM t ORDER BY col(覆盖索引)

比 ALL 快(索引树更小),但仍扫描全部索引

ALL

全表扫描(扫描聚簇索引所有页)

无可用索引或优化器认为全表更快

必须避免

Extra 值精解:

Extra 值

含义

优劣

Using index

覆盖索引,不回表

最优

Using index condition

ICP 生效

好

Using where

Server 层过滤

一般,结合 rows 和 filtered 评估

Using temporary

使用临时表(GROUP BY/ORDER BY 与索引不匹配时)

差,数据量大时临时表会写磁盘

Using filesort

文件排序(排序无法使用索引)

差,数据量大影响显著

Using join buffer

关联查询使用 Join Buffer

被驱动表无索引,需优化

Impossible WHERE

WHERE 恒假

好,直接返回空

Select tables optimized away

聚合结果直接优化

最优,COUNT(*) 等

Range checked for each record

每行探测索引

极差

Start temporary / End temporary

半连接去重

子查询相关

八、索引失效 10 种场景

-- 前提:表 orders,存在联合索引 INDEX idx_uid_status (user_id, status)

#

失效 SQL

原因

如何修复

1

WHERE LEFT(phone, 3) = '138'

对索引列使用函数,优化器无法评估

反写:WHERE phone LIKE '138%'

2

WHERE user_id + 1 = 101

索引列参与表达式计算,等效于函数

改写:WHERE user_id = 100

3

WHERE phone = 13800000000(phone 是 VARCHAR)

隐式类型转换。MySQL 将字符串列转为数字后再比较,等效 WHERE CAST(phone AS UNSIGNED)=13800000000,即对索引列用函数

加引号:WHERE phone = '13800000000'

4

WHERE name LIKE '%张'

左模糊破坏 B+ 树有序性——无法确定搜索起点

尽量右模糊 LIKE '张%';必须左模糊考虑全文索引

5

WHERE a = 1 OR b = 2(a、b 各自有独立索引)

OR 两边列不同,优化器可能放弃(MySQL 5.6+ 有 index_merge 但不稳定)

UNION ALL 改写:SELECT * WHERE a=1 UNION ALL SELECT * WHERE b=2 AND a<>1

6

WHERE user_id != 100

不等于 = 取反 = 大量行,优化器倾向于全表

确需不等时改写范围 WHERE user_id < 100 OR user_id > 100

7

WHERE status IS NULL

NULL 统计不准(Cardinality 不记录 NULL),优化器易误判

设 NOT NULL DEFAULT '' 或确保统计准确

8

WHERE status IS NOT NULL

同上,且等价于不等于 NULL

同上

9

WHERE user_id NOT IN (1,2,3)

NOT IN 是多个不等于的 OR

改写为范围或 NOT EXISTS

10

WHERE status = 'paid'(联合索引 (user_id, 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 的表 → 优化器走全表

优化器决策过程:

  1. 用 Cardinality 估算 WHERE user_id=100 命中 100000 / 50000 ≈ 2 行 → 2 次随机回表 I/O < 全表顺序读 → 走索引

  2. 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 更安全。

深入回答:

  1. DDL 方式演变: MySQL 5.5 COPY 方式锁表(5000 万行可能数小时)。5.6+ 的 INPLACE 在线加索引原理:在原表上构建新索引,期间对原表的修改写入在线日志(Online Log),索引构建完成后回放日志,全程只加两次短元数据锁。8.0 大部分 DDL 支持 INSTANT。

  2. 磁盘空间: 至少留 表大小 + 新索引大小 + 临时排序文件 的剩余空间。

  3. 复制延迟: 主库 DDL 完成后从库串行执行——如果从库是单线程(5.6),延迟会非常大。8.0 并行复制可缓解。

  4. 最佳实践: 用 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。