--- tags: [MySQL, 聚簇索引, 二级索引, Covering Index, 回表] create time: 2026-05-16 00:00 --- # 聚簇索引 vs 二级索引 ## 概述 InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。理解这两者的差异,是你设计高效查询和执行计划的起点。 > [!QUESTION] 思考 > 假设一张用户表按 `id` 排序存储在磁盘上—— > 现在要查 `WHERE email = 'alice@test.com'`,数据库需要做什么? > (提示:email 不是存储排序的依据。) 带着这个问题往下看。 ## 聚簇索引(Clustered Index) 聚簇索引就是数据本身。InnoDB 表中**只能有一个聚簇索引**。 ### 聚簇索引的形成规则 | 优先级 | 条件 | 说明 | |--------|------|------| | ① | **PRIMARY KEY** | 如果有显式 PK,它自动成为聚簇索引 | | ② | **UNIQUE + NOT NULL** | 没有 PK 但有唯一非空列,用它 | | ③ | **隐藏 row_id** | 都没有时,InnoDB 自动生成隐藏的 6-byte row_id | > [!WARNING] 隐藏的 row_id 是个坑 > 如果你的表既没 PK 也没有 UNIQUE NOT NULL 列,InnoDB 会自动生成隐藏主键。这时: > - 你自定义的任何索引都会变成二级索引 > - 二级索引回表时需要额外跳转 → 回表代价更高 > - 外键引用不可靠(ID 对用户透明) > > **结论:每张表必须有显式 PRIMARY KEY。** ### 聚簇索引的特性 ```mermaid flowchart TD A["按主键排序存储数据"] --> B["数据页紧凑排列"] B --> C["连续主键 = 顺序写入"] C --> D["最小化页分裂"] D --> E["最佳写入性能"] A --> F["范围查询极快
WHERE id BETWEEN 100 AND 200
只需扫描一段连续的叶子页"] F --> G["顺序 I/O 而非随机 I/O"] style E fill:#00D866,color:#fff style G fill:#00B6BC,color:#fff ``` ## 二级索引(Secondary Index) 除了聚簇索引外的所有索引都是二级索引。叶子节点存储的是:**索引列的值 + 主键值**。 ```mermaid flowchart TD subgraph "二级索引页
idx_status_created = (status, created_at)" direction LR R1["status=1 \| created_at=01 → PK=5"] R2["status=1 \| created_at=02 → PK=12"] R3["status=1 \| created_at=03 → PK=18"] R4["status=0 \| created_at=01 → PK=3"] R5["status=0 \| created_at=04 → PK=25"] end style R1 fill:#E8DFF5,color:#333 style R3 fill:#00B6BC,color:#fff ``` > [!NOTE] 关键观察 > - 每行都包含 **索引列值**(用于匹配查询条件)和 **主键值**(用于回表定位完整行数据) > - 二级索引本身是 B+ Tree,按索引列排序;叶子层每一行指向聚簇索引中的对应数据 > - 如果只需要索引中的列(如 `SELECT status WHERE status=1`),就 **无需回表**——这就是 Covering Index ## 回表的代价 ```mermaid sequenceDiagram participant App as 应用层 participant SI as 二级索引(idx_email) participant CI as 聚簇索引(PK=id) participant DB as 数据盘 App->>SI: 查找 email='alice@test.com' SI-->>App: 找到 → id=12345 App->>CI: 用 id=12345 查找 CI->>DB: 定位叶子页 (随机 IO #1) DB-->>CI: 返回完整行 CI-->>App: 返回完整行 Note over SI,DB: 至少 2 次 IO:1次二级索引 + 1次回表 ``` ### 减少回表的策略 ```sql -- ❌ 差:Covering Index 未命中,需要回表拿 name 字段 SELECT name, email FROM users WHERE email = 'alice@test.com'; -- idx_email 能匹配 WHERE,但 name 不在索引里 → 需要回表 -- ✅ 好:覆盖索引,无需回表 ALTER TABLE users ADD INDEX idx_email_name (email, name); SELECT name, email FROM users WHERE email = 'alice@test.com'; -- EXPLAIN Extra: Using index ← 完美! ``` > [!SUCCESS] Covering Index 的黄金法则 > **把 SELECT 中的列 + WHERE 中的列放到同一个联合索引中**,就能实现 Index Only Scan。 > - 适合:高频查询、固定列选择 > - 不适合:SELECT *(永远无法覆盖)、列变化频繁的查询 ## 回表 vs 索引下推(ICP) MySQL 5.6 引入的 Index Condition Pushdown 优化了部分回表场景。 ```sql -- 假设联合索引 idx_name_status = (name, status) -- 查询:WHERE name LIKE '张%' AND status = 1 -- ❌ 无 ICP:所有匹配的 name 都要回表查 status SELECT * FROM users WHERE name LIKE '张%' AND status = 1; -- 步骤:1) 找到所有 '张%' 的行 2) 逐条回表查 status 3) 过滤 -- ✅ 有 ICP:在二级索引中就先过滤 status -- 引擎层直接读二级索引页,提取 name 和 status,先判断 status=1 -- 只有满足条件的才回表 -- 减少了大量无效回表 -- EXPLAIN 验证 EXPLAIN SELECT * FROM users WHERE name LIKE '张%' AND status = 1\G -- Extra 显示: Using index condition ``` ```mermaid flowchart TD N["无 ICP"] --> A["查到 1000 条 '张%' 的记录"] A --> B["1000 次回表检查 status"] B --> C["最终只有 10 条符合"] Y["有 ICP"] --> D["在索引中预检 status"] D --> E["1000 条中筛出 10 条"] E --> F["仅 10 次回表"] style B fill:#EE5A24,color:#fff style F fill:#00D866,color:#fff ``` ## 两种索引的空间对比 ```mermaid graph TB subgraph Clustered["聚簇索引 — 100 万行"] CI["每页约 200 行 | 16KB / 80 bytes"] CI2["总页数 ≈ 5000 页"] end subgraph Secondary["二级索引 — idx_email VARCHAR(255)"] SI["每页约 80 行 | 16KB / 200 bytes"] SI2["总页数 ≈ 12500 页"] end CI --> CI2 SI --> SI2 style CI2 fill:#00B6BC,color:#fff style SI2 fill:#C44569,color:#fff ``` > [!NOTE] 二级索引通常比聚簇索引大得多 > 因为二级索引存的是「索引列 + 主键」,而聚簇索引存的是「整行」。如果索引列很长(如 VARCHAR(255)),二级索引会膨胀得很厉害。这也是为什么长字符串列做索引时要限制长度:`(name(50))`。 ## 两种索引的对比总结 | 维度 | 聚簇索引 (Clustered) | 二级索引 (Secondary) | |------|---------------------|---------------------| | **数量** | 每张表仅一个 | 可以有多个 | | **叶子节点存储** | 整行数据 | 索引列值 + 主键值 | | **形成依据** | PK / 唯一非空列 / 隐藏 row_id | `CREATE INDEX` 或 `UNIQUE KEY` | | **范围查询** | 极快(顺序扫描连续页) | 需要回表,代价高 | | **覆盖索引** | 天然覆盖(本身就是数据) | 仅当所需列都在索引中时生效 | | **空间占用** | 基准大小 | 通常更大(多一列主键 + 膨胀风险) | | **写入代价** | 插入可能触发页分裂 | 更新索引列需改索引 + 回表改数据 | > [!SUMMARY] 核心记忆点 > 1. 聚簇索引 = 数据本身,**一张表只能有一个** > 2. 二级索引 = 「索引列 + 主键」,查数据要**回表** > 3. 能用 Covering Index 的场景永远优于回表 > 4. ICP 是 MySQL 5.6 对回表的温和优化——能省则省 ## 关联笔记 - [[hhs/MySQL/16-B+Tree 索引原理]] — B+ Tree 作为底层数据结构的工作原理 - [[hhs/MySQL/18-联合索引与最左前缀]] — 联合索引的搜索模式对回表的影响 - [[hhs/GORM/15-性能优化]] — GORM 场景下的 Covering Index 实践