TiDB 索引原理与 SQL 优化

一、 索引分类概述

1.1 按底层结构

  • B + 树索引:最通用,支持等值、范围、排序;InnoDB、Oracle、TiDB 主力索引。
  • 哈希索引:等值快(O (1)),不支持范围 / 排序;MySQL Memory、Oracle 哈希簇等。
  • R 树(空间索引):地理坐标、范围查询。
  • 倒排索引(全文):文本分词、关键词检索。

1.2 按存储与数据关系

  • 聚簇索引(Clustered):索引和数据行 “捆在一起”,叶子节点存整行;一张表只能一个(InnoDB 主键、TiDB 主键)。
  • 非聚簇索引(Secondary):索引和数据分离,叶子只存主键 / ROWID;可多个。

1.3 按约束 / 用途

  • 主键索引(PRIMARY KEY):非空 + 唯一,特殊的聚簇 / 唯一索引。
  • 唯一索引(UNIQUE):列值唯一,允许 NULL。
  • 普通索引(NORMAL):无约束,只加速查询。
  • 复合索引(联合):多列组合,遵循最左前缀。
  • 函数索引:对表达式结果建索引(如 UPPER (name))。
  • 前缀索引:只对字符串前 N 个字符建索引。

1.4 按分布式 / 分区特性

  • 本地索引(Local):分区表内,索引分区 = 表分区。
  • 全局索引(Global):跨所有分区,独立分区。

二、TiDB 的索引类型

2.1 聚簇索引(主键索引)

聚簇索引 = 索引和「整行数据」存放在一起。数据行被 “聚集、捆绑” 在索引结构里,所以叫聚簇索引。

  • 表的完整数据行,直接存在聚簇索引的叶子节点里
  • 整个表的数据,本身就是一棵 B+ 树(聚簇索引树)
  • 一张表有且只有一个聚簇索引

TiDB 中每张表都有一行数据按主键组织存储,这就是聚簇索引:

CREATE TABLE users (
    id BIGINT PRIMARY KEY,       -- 聚簇索引
    name VARCHAR(64),
    email VARCHAR(128)
);

聚簇索引的特点:

  • 数据就是索引,索引就是数据
  • 按主键查询只需要一次 KV 查找
  • 主键查询效率最高
主键查询路径:
SELECT * FROM users WHERE id = 100

TiDB Server
    │
    v
构造 Key: t{table_id}_r100
    │
    v
发送到对应 TiKV Region
    │
    v
RocksDB 查找 Key → 直接得到行数据

2.2 二级索引

二级索引是独立于主键的索引结构:

CREATE INDEX idx_email ON users(email);

二级索引的存储:

Key:  t{table_id}_i{index_id}_{email_value}_{row_id}
Value: (空,row_id 已在 Key 中)

通过二级索引查询的过程:

SELECT * FROM users WHERE email = 'alice@example.com'

步骤 1: 查找二级索引
Key: t56_i1_"alice@example.com"
→ 得到 row_id = 100

步骤 2: 回表查找主键
Key: t56_r100
→ 得到完整行数据: [id=100, name="Alice", email="alice@example.com"]

回表是二级索引查询不可避免的额外开销。如果查询的列都在索引中,就不需要回表,这叫做覆盖索引

-- 覆盖索引示例
SELECT id, email FROM users WHERE email = 'alice@example.com';
-- 只需要查二级索引,不需要回表

2.3 联合索引

CREATE INDEX idx_name_age ON users(name, age);

联合索引遵循最左前缀原则

-- 能使用联合索引
SELECT * FROM users WHERE name = 'Alice';                    -- ✓ 使用最左列
SELECT * FROM users WHERE name = 'Alice' AND age = 28;       -- ✓ 使用全部列

-- 不能使用联合索引
SELECT * FROM users WHERE age = 28;                          -- ✗ 缺少最左列
SELECT * FROM users WHERE name LIKE '%ice' AND age = 28;      -- ✗ name 前缀通配符导致索引失效

可以通过 EXPLAIN 验证:

EXPLAIN SELECT * FROM users WHERE age = 28;
-- 如果看到 TableFullScan,说明没走索引

EXPLAIN SELECT * FROM users WHERE name = 'Alice';
-- 如果看到 IndexRangeScan,说明走了索引

2.4 唯一索引

CREATE UNIQUE INDEX uk_email ON users(email);

唯一索引与普通二级索引在结构上完全一致,只是额外在写入时检查唯一性约束。

2.5 前缀索引

当列值很长时(如 URL、长文本),可以对前缀建立索引:

CREATE INDEX idx_url_prefix ON pages(url(64));
-- 只对 URL 的前 64 个字符建索引

前缀索引可以减小索引大小,但可能导致查询效率下降(需要回表后再精确匹配)。


三、执行计划解读

3.1 EXPLAIN 基础

EXPLAIN 是理解 SQL 执行过程最重要的工具:

EXPLAIN SELECT * FROM users WHERE name = 'Alice';
+-------------------------------+---------+-----------+----------------------+--------------------------------+
| id                            | estRows | task      | access object        | operator info                  |
+-------------------------------+---------+-----------+----------------------+--------------------------------+
| IndexReader_6                 | 1.00    | root      |                      | index:IndexRangeScan_5         |
| └─IndexRangeScan_5            | 1.00    | cop[tikv] | table:users, index:idx_name | range:["Alice","Alice"]   |
+-------------------------------+---------+-----------+----------------------+--------------------------------+

各列含义:

含义
id 算子名称
estRows 优化器估算的输出行数
task 执行位置:root(TiDB)或 cop[tikv](下推到 TiKV)
access object 访问的表/索引
operator info 算子的详细信息

3.2 常见算子

数据访问算子

算子 含义 触发条件
TableFullScan 全表扫描 无索引或优化器认为全表更快
TableRangeScan 按主键范围扫描 WHERE id BETWEEN …
TableRowIDScan 按 Row ID 扫描 回表时使用
IndexFullScan 全索引扫描 索引列无过滤条件
IndexRangeScan 按索引范围扫描 WHERE indexed_col = …
Point_Get 单行获取 WHERE primary_key = const
Batch_Point_Get 批量单行获取 WHERE primary_key IN (…)

Point_Get 是效率最高的查询

EXPLAIN SELECT * FROM users WHERE id = 100;
-- id: Point_Get, 直接从 TiKV 获取一行,无需构建执行计划树

EXPLAIN SELECT * FROM users WHERE id IN (1, 2, 3);
-- id: Batch_Point_Get, 批量获取指定主键的行

过滤算子

算子 含义
Selection WHERE 条件过滤
HashAgg 哈希聚合(GROUP BY)
StreamAgg 流式聚合(有序数据的 GROUP BY)
Sort 排序(ORDER BY)
TopN Top N 查询(ORDER BY … LIMIT)
Limit 行数限制

Join 算子

算子 含义 适用场景
HashJoin 哈希 Join 通用,两表都无合适索引
IndexJoin 索引嵌套 Join 内表有索引,外表数据量小
IndexHashJoin 索引哈希 Join 与 IndexJoin 类似
MergeJoin 归并 Join 两边都有序(如有索引)
Apply 子查询展开 相关子查询

3.3 执行计划示例分析

EXPLAIN
SELECT
    u.name,
    COUNT(o.id) AS order_count
FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
WHERE u.age > 25
GROUP BY u.name
ORDER BY order_count DESC
LIMIT 10;
+----------------------------------+----------+-----------+--------------------------+-----------------------------------------------+
| id                               | estRows  | task      | access object            | operator info                                 |
+----------------------------------+----------+-----------+--------------------------+-----------------------------------------------+
| TopN_15                          | 10.00    | root      |                          | Column#6:desc, offset:0, limit:10             |
| └─HashAgg_24                     | 10.00    | root      |                          | group by:users.name, count(orders.id)         |
|   └─TopN_26                      | 100.00   | root      |                          | Column#6:desc, offset:0, limit:10             |
|     └─HashAgg_28                 | 100.00   | root      |                          | group by:users.name, count(orders.id)         |
|       └─Projection_30            | 8000.00  | root      |                          | users.name, orders.id                         |
|         └─HashJoin_32            | 8000.00  | root      |                          | left outer join, inner:IndexReader_36         |
|           ├─Selection_34         | 3333.33  | root      |                          | gt(users.age, 25)                             |
|           │ └─TableReader_35     | 10000.00 | root      |                          | data:TableFullScan_33                         |
|           │   └─TableFullScan_33 | 10000.00 | cop[tikv] | table:u                  | keep order:false                              |
|           └─IndexReader_36       | 10000.00 | root      |                          | index:IndexFullScan_35                        |
|             └─IndexFullScan_35   | 10000.00 | cop[tikv] | table:o, idx:idx_user_id | keep order:false                              |
+----------------------------------+----------+-----------+--------------------------+-----------------------------------------------+

执行计划解读(从下往上):

1. TableFullScan_33:  全表扫描 users 表
2. Selection_34:      过滤 age > 25
3. IndexFullScan_35:  全索引扫描 orders.idx_user_id
4. HashJoin_32:       将两表结果 Join
5. HashAgg_28/24:     按 name 聚合,计算 COUNT
6. TopN_15/26:        排序取前 10

优化空间分析

  • users 表走了 TableFullScan,如果在 age 上建索引,可能更高效
  • 但由于 age > 25 可能匹配大量行,优化器可能认为全表扫描更快

四、SQL 优化方法

4.1 索引优化

规则 1:为高频查询列建索引

-- 慢查询
SELECT * FROM orders WHERE user_id = 12345;
-- 如果 user_id 没有索引,会全表扫描

-- 优化
CREATE INDEX idx_user_id ON orders(user_id);
-- 现在走 IndexRangeScan

规则 2:联合索引的列顺序很重要

-- 场景: 经常按 status 和 created_at 查询
SELECT * FROM orders WHERE status = 1 AND created_at > '2024-01-01';

-- 差的设计(区分度低的列在前)
CREATE INDEX idx_status_date ON orders(status, created_at);
-- status 可能只有几个值(0,1,2),过滤效果差

-- 好的设计(区分度高的列在前,或按查询模式)
CREATE INDEX idx_date_status ON orders(created_at, status);
-- created_at 范围更精确,过滤效果更好

规则 3:避免过多的索引

每个索引都会:

  • 占用额外存储空间
  • 降低写入性能(每次 INSERT/UPDATE 都要更新索引)
  • 增加优化器选择执行计划的复杂度
-- 查看所有索引信息(供分析参考)
-- 注意:TiDB 不在 information_schema.statistics 中记录索引使用频率,
-- 需通过 EXPLAIN 分析执行计划或使用 TiDB Dashboard 观察实际查询模式
SELECT
    table_schema,
    table_name,
    index_name,
    seq_in_index,
    column_name
FROM information_schema.statistics
WHERE table_schema = 'your_database'
ORDER BY table_name, index_name;

4.2 查询重写

优化 1:避免 SELECT *

-- 差
SELECT * FROM users WHERE id = 1;
-- 回表获取所有列

-- 好
SELECT id, name, email FROM users WHERE id = 1;
-- 如果这些列都在索引中,可以覆盖索引,不回表

优化 2:避免在索引列上使用函数

-- 差(不能使用索引,在索引列上使用函数会导致全表扫描)
-- 假设 users 表有 created_at 列
SELECT * FROM users WHERE YEAR(created_at) = 2024;

-- 好(可以使用范围扫描索引)
SELECT * FROM users WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';

优化 3:避免隐式类型转换

-- 假设 phone 是 VARCHAR 类型

-- 差(数字与字符串比较,触发类型转换,索引失效)
SELECT * FROM users WHERE phone = 13800138000;

-- 好(类型一致,使用索引)
SELECT * FROM users WHERE phone = '13800138000';

优化 4:IN 列表不要太大

-- 差(IN 列表过大会影响优化器判断)
SELECT * FROM users WHERE id IN (1, 2, 3, ..., 10000);

-- 好(分批处理或使用临时表)
-- 方案 A: 分批
SELECT * FROM users WHERE id IN (1, 2, ..., 1000);  -- 第 1 批
SELECT * FROM users WHERE id IN (1001, 1002, ..., 2000);  -- 第 2 批

-- 方案 B: 临时表 + JOIN
CREATE TEMPORARY TABLE tmp_ids (id BIGINT PRIMARY KEY);
INSERT INTO tmp_ids VALUES (1), (2), ..., (10000);
SELECT u.* FROM users u JOIN tmp_ids t ON u.id = t.id;

4.3 JOIN 优化

规则 1:小表驱动大表

-- 假设 users 有 1000 行,orders 有 100 万行

-- 差的方式(如果优化器选择错误)
-- 假设 users 表有 status 列
SELECT * FROM orders o
    JOIN users u ON o.user_id = u.id
WHERE u.status = 1;

-- 好的方式(让优化器先过滤小表)
SELECT * FROM users u
    JOIN orders o ON u.id = o.user_id
WHERE u.status = 1;

-- TiDB 的优化器通常会自动 reorder Join,
-- 但可以通过 Hint 指导:
SELECT /*+ TIDB_INLJ(u) */ *
FROM users u
    JOIN orders o ON u.id = o.user_id
WHERE u.status = 1;
-- TIDB_INLJ 强制使用 IndexJoin

规则 2:确保 Join 列有索引

-- orders.user_id 必须有索引
SHOW INDEX FROM orders WHERE Column_name = 'user_id';

-- 如果没有,创建
CREATE INDEX idx_user_id ON orders(user_id);

4.4 分页优化

传统分页在数据量大时效率很低:

-- 深分页慢:需要扫描前 1000000 行,然后丢弃
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 1000000;

优化方法一:延迟关联

-- 先通过索引获取 ID,再回表
SELECT o.* FROM orders o
    INNER JOIN (
        SELECT id FROM orders ORDER BY id LIMIT 10 OFFSET 1000000
    ) tmp ON o.id = tmp.id;

优化方法二:基于上次查询的最大 ID 翻页

-- 第 1 页
SELECT * FROM orders ORDER BY id LIMIT 20;
-- 记住最后一条的 id,假设为 12345

-- 第 2 页
SELECT * FROM orders WHERE id > 12345 ORDER BY id LIMIT 20;
-- 不需要 OFFSET,直接从上次的位置开始

这种方法适合"无限滚动"场景,性能远优于 OFFSET。


五、Optimizer Hints

当优化器的选择不理想时,可以通过 Hint 指导:

5.1 索引 Hint

-- 强制使用特定索引
SELECT /*+ USE_INDEX(users, idx_name_age) */ *
FROM users WHERE name = 'Alice';

-- 忽略特定索引
SELECT /*+ IGNORE_INDEX(users, idx_name_age) */ *
FROM users WHERE name = 'Alice';

-- 强制全表扫描
SELECT /*+ USE_INDEX_MERGE(users, PRIMARY, idx_name_age) */ *
FROM users WHERE id = 1 OR name = 'Alice';

5.2 Join Hint

-- 强制 HashJoin
SELECT /*+ HASH_JOIN(u, o) */ *
FROM users u JOIN orders o ON u.id = o.user_id;

-- 强制 IndexJoin
SELECT /*+ INL_JOIN(u, o) */ *
FROM users u JOIN orders o ON u.id = o.user_id;

-- 强制 MergeJoin
SELECT /*+ MERGE_JOIN(u, o) */ *
FROM users u JOIN orders o ON u.id = o.user_id;

5.3 执行计划缓存

-- 绑定执行计划(类似 Oracle 的 SQL Plan Baseline)
CREATE BINDING FOR
    SELECT * FROM users WHERE name = 'Alice'
USING
    SELECT /*+ USE_INDEX(users, idx_name) */ * FROM users WHERE name = 'Alice';

-- 查看绑定
SHOW BINDINGS;

-- 删除绑定
DROP BINDING FOR SELECT * FROM users WHERE name = 'Alice';

六、统计信息与优化器

6.1 统计信息的作用

优化器依赖统计信息来选择最优执行计划:

-- 手动收集统计信息
ANALYZE TABLE users;

-- 收集特定索引的统计信息
ANALYZE TABLE users INDEX idx_name_age;

-- 查看统计信息
SHOW STATS_META WHERE table_name = 'users';
SHOW STATS_HISTOGRAMS WHERE table_name = 'users';

6.2 统计信息不准确的后果

-- 假设 users 表刚导入了大量数据,但统计信息未更新

-- 优化器可能认为表很小,选择了全表扫描
EXPLAIN SELECT * FROM users WHERE name = 'Alice';
-- 实际表很大,全表扫描很慢

-- 解决: 更新统计信息
ANALYZE TABLE users;

6.3 自动分析

TiDB 默认会自动分析:

-- 查看自动分析配置
SHOW VARIABLES LIKE 'tidb_auto_analyze%';

-- 关闭自动分析(不推荐)
SET GLOBAL tidb_enable_auto_analyze = OFF;

-- 调整自动分析的时间窗口
SET GLOBAL tidb_auto_analyze_start_time = '02:00 +0800';
SET GLOBAL tidb_auto_analyze_end_time = '06:00 +0800';

七、慢查询分析实战

7.1 开启慢查询日志

-- 设置慢查询阈值(单位:纳秒)
-- TiDB 中 tidb_slow_log_threshold 的单位是纳秒
-- 300ms = 300000000 纳秒
SET GLOBAL tidb_slow_log_threshold = 300000000;

-- 查看慢查询
SELECT * FROM information_schema.slow_query
    WHERE Time > NOW() - INTERVAL 1 HOUR
    ORDER BY Query_time DESC
    LIMIT 10;

7.2 分析慢查询

-- 查看具体的慢查询
SELECT
    Query_time,
    Query,
    Digest,
    Mem_max,
    Stats
FROM information_schema.slow_query
ORDER BY Query_time DESC
LIMIT 10;

7.3 常见慢查询原因及对策

原因 特征 对策
全表扫描 TableFullScan 加索引
索引选择不当 用了低效索引 ANALYZE TABLE / Hint
Join 方式不对 HashJoin 大表 INL_JOIN Hint
深分页 大 OFFSET 延迟关联 / 游标分页
数据分布倾斜 某 Region 特别慢 热点分析 / Split Region
锁等待 悲观事务锁等待长 优化事务范围 / 改用乐观

八、小结

SQL 优化的核心思路:

  1. 理解执行计划:知道 SQL 实际是怎么执行的
  2. 合理利用索引:为高频查询列建索引,避免过多索引
  3. 查询重写:避免 SELECT *、函数调用、隐式转换
  4. 收集统计信息:确保优化器有准确的数据分布信息
  5. 必要时使用 Hint:引导优化器选择更好的执行计划
1 个赞

写的好详细啊,学到了。

1 个赞

分享的很详细,很实用,收藏了

1 个赞

谢谢分享

写的好详细

赞一个

赞一个。

谢谢分享,写的真棒,已收藏

数据库的索引与优化,无论是传统数据库还是原声分布式数据库道理都差不多