Skip to content

02.2 - 聚簇索引与二级索引

定位:InnoDB 索引组织表的灵魂——数据和索引是"一体"的,理解这一点才能设计高效的索引 面试高频度:⭐⭐⭐⭐⭐ 考查方式:聚簇索引优缺点、回表过程、覆盖索引、为什么建议用自增主键


一、这是什么?为什么需要它?

是什么

InnoDB 是索引组织表(Index-Organized Table)——数据本身就是按主键排序的 B+树。所以:

  • 聚簇索引(Clustered Index):主键索引,叶子节点存整行数据
  • 二级索引(Secondary Index):非主键索引,叶子节点存主键值
聚簇索引(主键索引)                  二级索引
┌────────────┐                      ┌────────────┐
│  内节点: key │                      │  内节点: key │
│  (只存主键)  │                      │  (只存索引列) │
└──────┬─────┘                      └──────┬─────┘
       ▼                                     ▼
┌────────────┐                      ┌────────────┐
│  叶子节点    │                      │  叶子节点    │
│  key=行数据 │                      │  key=主键值  │
│  ┌──────┐  │                      │  ┌──────┐  │
│  │id=1   │  │                      │  │name   │  │
│  │name=A  │  │                      │  │='Alice' │  │
│  │age=20  │  │                      │  │id=1   │  │
│  └──────┘  │                      │  └──────┘  │
└────────────┘                      └────────────┘

                                    需要回表

                                    聚簇索引
                                   (通过 id=1 查整行)

为什么需要两种索引?

核心设计动机:避免数据冗余。

如果每一棵索引树都存完整数据行,一个表 N 个索引就要复制 N 份数据——空间爆炸。所以 InnoDB 的设计是:

  • 数据只存一份(在聚簇索引的叶子节点)
  • 二级索引只存"指向数据的位置"(即主键值)

回表(Bookmark Lookup) 就是通过二级索引查到主键值后,再去聚簇索引中取完整行的过程。

核心 insight:聚簇索引的本质是 "数据即索引"。所有其他索引都是"通往数据的指针"。这个设计让数据写入时只需维护一棵树的主排序,而非多棵树的数据副本。


二、原理拆解

2.1 聚簇索引的物理存储

聚簇索引(主键 = id,自增)

             ┌───────────────────────┐
             │        [500]          │ ← 内节点(根页)
             │      ↙        ↘      │
        ┌────┴────┐          ┌────┴────┐
        │  [250]   │          │  [750]   │ ← 内节点
       ↙   ↓    ↘           ↙   ↓    ↘
   ┌───┐ ┌───┐ ┌───┐   ┌───┐ ┌───┐ ┌───┐
   │1~ │ │251│ │501│   │751│ │...│   │
   │250│ │~  │ │~  │   │~  │ │   │   │
   │   │ │500│ │750│   │1000│ │   │   │
   ├───┤ ├───┤ ├───┤   ├───┤ ├───┤ ├───┤
   │行1│ │行 │ │行 │   │行 │ │   │   │
   │行2│ │251│ │501│   │751│ │   │   │
   │.. │ │.. │ │.. │   │.. │ │   │   │
   │250│ │500│ │750│   │1000│ │   │   │ ← 叶子节点:存完整行
   └───┘ └───┘ └───┘   └───┘ └───┘ └───┘

物理特性:

  • 数据按主键物理排序 → 主键范围查询极快
  • 叶子节点页之间是双链表连接 → 正向/反向遍历都高效

2.2 二级索引的回表过程

sql
-- 表结构
CREATE TABLE users (
  id INT PRIMARY KEY,
  name VARCHAR(50),
  age INT,
  INDEX idx_age (age)   -- 二级索引
);

-- 查询
SELECT * FROM users WHERE age = 25;
二级索引 idx_age:
┌──────────┐
│  age=25  │ → 找到叶子节点 → age=25, id=42
└──────────┘

     回表(Bookmark Lookup)

聚簇索引(主键索引):
┌──────────┐
│  id=42   │ → 叶子节点 → 完整行数据
└──────────┘

回表的问题:如果通过二级索引找到 1000 行,就要回表 1000 次(即 1000 次随机 IO)。这就是为什么:

  1. 查询所有列 SELECT *SELECT id(需要回表)
  2. 覆盖索引可以避免回表(SELECT age 直接从二级索引拿)

2.3 覆盖索引(Covering Index)

sql
-- 表结构
CREATE TABLE users (
  id INT PRIMARY KEY,
  name VARCHAR(50),
  age INT,
  INDEX idx_age (age)
);

-- 需要回表的查询
EXPLAIN SELECT * FROM users WHERE age = 25;
-- Extra: Using index condition → 回表查完整行

-- 不需要回表的查询(覆盖索引)
EXPLAIN SELECT age FROM users WHERE age = 25;
-- Extra: Using index → 索引完全覆盖了查询列

-- 覆盖索引 + 更多列
EXPLAIN SELECT age, name FROM users WHERE age = 25;
-- Extra: Using index condition → 二级索引不包含 name,需要回表

-- 如果加一个联合索引 (age, name) 就不需要回表了
ALTER TABLE users ADD INDEX idx_age_name (age, name);
EXPLAIN SELECT age, name FROM users WHERE age = 25;
-- Extra: Using index → 覆盖索引命中

为什么覆盖索引快?

  • 二级索引 B+树通常比聚簇索引小得多(只存几个列)
  • 避免回表的随机 IO
  • 二级索引可能全部在 Buffer Pool 中(占用空间小)

2.4 为什么建议用自增主键?

自增主键 vs UUID 主键的写入行为差异:

自增主键 (1, 2, 3, ...)

聚簇索引的插入顺序 = 物理顺序

新行总是追加到最后一个叶子页

只需要分裂"最右端"的叶子页

写入高效,页分裂少


UUID 主键 (随机字符串)

聚簇索引的插入顺序 ≠ 物理顺序

新行可能插入到"中间"的叶子页

频繁触发页分裂(找到分裂点、移动数据、更新内节点指针)

写入慢,碎片多,索引膨胀

数量化对比:

场景自增主键UUID 主键
插入位置末尾追加随机位置
页分裂频率低(页满才分裂)高(约 50% 概率触发)
索引碎片
空间利用率高(~15% 预留空间)低(大量碎片)
插入速度(100万行)~2-3s~10-20s
Buffer Pool 命中率低(频繁加载新页)

2.5 没有主键怎么办?

InnoDB 规定表必须有聚簇索引。如果用户不指定主键:

  1. 选择第一个 NOT NULL 唯一索引作为聚簇索引
  2. 如果没有,InnoDB 自动生成一个6 字节的 ROWID(隐藏主键)
sql
-- 查看隐藏主键
SELECT 
  t.TABLE_NAME,
  i.INDEX_NAME,
  i.GEN_CLUSTER_INDEX  -- 1 表示自动生成
FROM information_schema.INNODB_TABLES t
JOIN information_schema.INNODB_INDEXES i ON t.TABLE_ID = i.TABLE_ID
WHERE i.GEN_CLUSTER_INDEX = 1;

最佳实践:总是显式定义主键。自动生成的 ROWID 不可控,且二级索引叶子节点存 ROWID(非用户可查的值),回表时多一层定位。


三、图解全景

        聚簇索引 vs 二级索引 查询路径

查询: SELECT * FROM users WHERE age = 25

┌────────────────────────────────────────────────────┐
│  步骤 1: 二级索引 (idx_age)                        │
│                                                    │
│  ┌─────────┐                                       │
│  │ 根页     │                                       │
│  │ key: 20  │                                       │
│  └────┬────┘                                       │
│       │ 根据 25 > 20 选择右子节点                      │
│       ▼                                             │
│  ┌─────────┐                                       │
│  │ 内节点   │                                       │
│  │ key: 30  │                                       │
│  └────┬────┘                                       │
│       │ 根据 25 < 30 选择左子节点                      │
│       ▼                                             │
│  ┌──────────────┐                                   │
│  │ 叶子节点       │ ← 二级索引不存完整行               │
│  │ age=25, id=42│ rows=1                           │
│  └──────────────┘                                   │
│                                                    │
│  步骤 2: 回表到聚簇索引                              │
│                                                    │
│  ┌─────────┐                                       │
│  │ 根页     │                                       │
│  │ key: 100 │                                       │
│  └────┬────┘                                       │
│       │ 根据 42 < 100 选择左子节点                     │
│       ▼                                             │
│  ┌─────────┐                                       │
│  │ 内节点   │                                       │
│  │ key: 50  │                                       │
│  └────┬────┘                                       │
│       │ 根据 42 < 50 选择左子节点                      │
│       ▼                                             │
│  ┌────────────────────┐                             │
│  │ 叶子节点             │                             │
│  │ id=42, name=...     │ ← 完整行数据                │
│  │ age=25, ...         │                             │
│  └────────────────────┘                             │
└────────────────────────────────────────────────────┘

  总计: 4 次 IO(二级索引 2 次 + 聚簇索引 2 次)

四、实战验证

验证回表 vs 覆盖索引

sql
-- 创建测试表
CREATE TABLE test_index (
  id INT PRIMARY KEY AUTO_INCREMENT,
  a INT,
  b INT,
  c VARCHAR(100),
  INDEX idx_a_b (a, b)
);

-- 插入测试数据
INSERT INTO test_index (a, b, c) 
SELECT FLOOR(RAND() * 1000), FLOOR(RAND() * 1000), 
  CONCAT('data-', SEQ)
FROM seq_1_to_100000;

-- 验证覆盖索引(只需要 a,b 列)
EXPLAIN FORMAT=TREE
SELECT a, b FROM test_index WHERE a = 100;
-- 预期: Index lookup on idx_a_b, using index ✓

-- 验证需要回表(需要 c 列)
EXPLAIN FORMAT=TREE  
SELECT a, b, c FROM test_index WHERE a = 100;
-- 预期: Index lookup on idx_a_b, with index condition
--       (需要回表取 c 列)

验证自增主键 vs UUID 性能

sql
-- 创建两张表
CREATE TABLE test_auto (
  id INT PRIMARY KEY AUTO_INCREMENT,
  data VARCHAR(100)
);

CREATE TABLE test_uuid (
  id VARCHAR(36) PRIMARY KEY,
  data VARCHAR(100)
);

-- 插入测试(可以借助存储过程)
-- 自增表预期: 22 秒/100万行
-- UUID 表预期: 45 秒/100万行(多了一倍时间)

五、面试视角

追问答案要点
聚簇索引的优缺点?优:主键查询快、范围查询快、排序不需要额外操作。缺:插入可能页分裂、二级索引需要回表
什么是回表?怎么避免?二级索引查到主键值→再查聚簇索引。用覆盖索引(索引包含查询所有列)可避免
为什么推荐自增主键?写入是追加操作,减少页分裂和碎片,插入性能高 2-10 倍
可以不指定主键吗?可以但强烈不推荐——InnoDB 会自动生成 ROWID,用户不可控,且二级索引效率低
覆盖索引一定好吗?要注意平衡——过多列在索引中会增大索引体积,降低写入性能。只加真正需要的列
二级索引存的是什么?存索引列的值 + 主键值。在 8.0 中,如果有不可见列或虚拟列,也可能包含它们

📚 相关链接

  • 前置基础:**B+树索引结构**
  • 优化策略:**索引优化策略**
  • 分析工具:**EXPLAIN与查询优化**
  • ← 返回 **索引与查询优化索引**

Knowledge4J — Java 知识库