摘要
数据库优化器是 SQL 性能的核心引擎,而统计信息(Statistics)和索引(Index)是优化器做出正确决策的两个关键输入。统计信息告诉优化器"数据长什么样",索引告诉优化器"数据在哪里"。本文详解 TiDB 统计信息的收集与维护机制、索引类型与选择策略、优化器的执行计划选择逻辑,以及统计信息不准时的诊断与处理方法。本文适合需要优化 SQL 性能、理解 TiDB 执行计划的 DBA 和后端开发工程师。
一、统计信息对优化器的重要性
1.1 优化器为什么需要统计信息
关系数据库的 SQL 声明式查询需要优化器自动选择执行路径。这个过程类似导航系统规划路线——导航系统需要知道道路的车流量(统计信息)和道路连接关系(索引)才能规划最优路线。
优化器基于成本估算模型(Cost-Based Optimizer, CBO)计算每种执行路径的成本,选择成本最低的方案。统计信息直接影响成本估算的准确性:
| 统计信息类型 | 影响的优化器决策 | 示例 |
|---|---|---|
| 表行数(Count) | 是否选择索引扫描 vs 全表扫描 | 行数少 → 全表扫描 |
| 列基数(NDV) | 选择哪个索引更高效 | 高基数列更适合索引 |
| 直方图(Histogram) | 精确估算 WHERE 条件的过滤比例 | `age > 30` 匹配多少行 |
| NULL 比例 | 是否跳过 NULL 值的索引扫描 | NULL 多 → 可能不选索引 |
| 列相关性(Correlation) | 是否使用索引有序扫描 | 数据有序 → 索引范围扫描更优 |
| TopN 值 | 常量等值查询的行数估算 | `status = 'active'` 匹配多少行 |
1.2 统计信息不准确会怎样
示例:orders 表有 1 亿行数据,但统计信息显示只有 1 万行
SELECT * FROM orders WHERE customer_id = 123 AND order_date = '2025-06-01';
优化器的错误决策:
✗ 选择全表扫描(认为数据量小,扫描成本低)
✓ 实际应选择索引扫描(数据量大,索引能快速定位)
结果:查询耗时从毫秒级变为秒级甚至分钟级
二、TiDB 统计信息收集机制
2.1 手动收集统计信息
-- 收集整个表的统计信息
ANALYZE TABLE orders;
-- 收集指定列的统计信息
ANALYZE TABLE orders COLUMNS customer_id, order_date, status;
-- 收集索引的统计信息
ANALYZE TABLE orders INDEX idx_customer_date;
-- 指定采样比例(默认自动确定)
ANALYZE TABLE orders WITH 200000 SAMPLES;
2.2 自动收集统计信息
TiDB 提供了自动统计信息收集功能,默认开启:
| 参数 | 默认值 | 说明 |
|---|---|---|
| `tidb_auto_analyze_ratio` | 0.5 | 表数据变化超过该比例触发自动收集 |
| `tidb_auto_analyze_start_time` | `00:00 +0000` | 自动收集允许开始时间 |
| `tidb_auto_analyze_end_time` | `23:59 +0000` | 自动收集允许结束时间 |
-- 查看自动收集配置
SHOW VARIABLES LIKE 'tidb_auto_analyze%';
-- 手动触发一次全量收集
ANALYZE ALL;
自动收集触发条件:
- 表的修改行数超过 `tidb_auto_analyze_ratio * 总行数`
- 在配置的时间窗口内执行
- 不与手动 ANALYZE 冲突
2.3 统计信息内容详解
-- 查看表的统计信息
SHOW STATS_META WHERE db_name = 'mydb' AND table_name = 'orders';
-- 返回:行数、修改行数、版本号等
-- 查看列的统计信息
SHOW STATS_HISTOGRAMS WHERE table_name = 'orders';
-- 返回:列名、NDV、NULL 比例、直方图桶数等
-- 查看直方图详情
SHOW STATS_BUCKETS WHERE column_name = 'customer_id';
-- 返回:上界、下界、重复次数、NDV 等桶信息
-- 查看索引的统计信息
SHOW STATS_HISTOGRAMS WHERE table_name = 'orders' AND is_index = 1;
| 统计信息指标 | 含义 | 获取方式 |
|---|---|---|
| Count | 表的总行数 | `SHOW STATS_META` |
| NDV(Number of Distinct Values) | 列的不同值数量 | `SHOW STATS_HISTOGRAMS` |
| Null Count | NULL 值数量 | `SHOW STATS_HISTOGRAMS` |
| Correlation | 列值与物理位置的有序相关性 | `SHOW STATS_HISTOGRAMS` |
| TopN | 出现频率最高的值及其计数 | `SHOW STATS_TOP_N` |
| Histogram | 等高直方图桶 | `SHOW STATS_BUCKETS` |
三、索引类型与选择策略
3.1 TiDB 支持的索引类型
| 索引类型 | 定义 | 适用场景 | 示例 |
|---|---|---|---|
| 主键索引(Primary Key) | `_tidb_rowid` 或聚簇索引 | 唯一标识行,默认创建 | `id BIGINT PRIMARY KEY` |
| 二级索引(Secondary Index) | 非主键列的 B+ 树索引 | 等值查询、范围查询 | `CREATE INDEX idx_status ON orders(status)` |
| 复合索引(Composite Index) | 多列组合索引 | 多条件 AND 查询 | `CREATE INDEX idx_cus_date ON orders(customer_id, order_date)` |
| 唯一索引(Unique Index) | 保证列值唯一的索引 | 防重复、快速等值查找 | `CREATE UNIQUE INDEX idx_email ON users(email)` |
| 函数索引(Functional Index) | 对表达式/函数结果建立索引 | 函数条件查询 | `CREATE INDEX idx_lower_name ON users((LOWER(name)))` |
| 前缀索引(Prefix Index) | 对字符串前 N 个字符建索引 | 长 VARCHAR 列等值查询 | `CREATE INDEX idx_name ON users(name(20))` |
| 表达式索引(Expression Index) | TiDB 6.6+ 支持的表达式索引 | 复杂条件查询 | `CREATE INDEX idx_expr ON t((a + b))` |
3.2 索引设计原则
原则 1:高选择性列优先
-- 查看列选择性(NDV / 总行数,越接近 1 越好)
SELECT COUNT(DISTINCT customer_id) / COUNT(*) AS selectivity
FROM orders;
-- selectivity > 0.1 才值得建索引
原则 2:复合索引遵循最左前缀原则
CREATE INDEX idx_abc ON t(a, b, c);
-- 以下查询可以使用索引:
WHERE a = 1; -- 使用 a
WHERE a = 1 AND b = 2; -- 使用 a, b
WHERE a = 1 AND b = 2 AND c = 3; -- 使用 a, b, c
-- 以下查询不能使用该索引:
WHERE b = 2; -- 跳过 a
WHERE c = 3; -- 跳过 a, b
WHERE b = 2 AND c = 3; -- 跳过 a
原则 3:覆盖索引减少回表
-- 查询只需索引列时,无需回表(Covering Index Scan)
SELECT customer_id, order_date FROM orders WHERE customer_id = 100;
-- 如果 idx_cus_date (customer_id, order_date) 存在,无需读取主表数据
-- 查看执行计划确认
EXPLAIN SELECT customer_id, order_date FROM orders WHERE customer_id = 100;
-- 注意看操作符是否为 IndexReader__(表示索引覆盖)
3.3 TiDB 索引的特殊考虑
| 特性 | 说明 |
|---|---|
| 聚簇索引 | TiDB 支持聚簇主键(`CLUSTERED`),主键值直接存储在 KV 中 |
| 索引回表 | 非聚簇索引需要回表获取完整行数据,可能产生大量随机读 |
| 索引维护成本 | 每个索引增加写入开销(Raft 复制 + 索引更新) |
| 索引数量建议 | 单表建议不超过 5-7 个二级索引 |
| TiFlash 索引 | TiFlash 不使用二级索引,依赖列式存储和 MPP 并行 |
-- TiDB 聚簇主键示例
CREATE TABLE users (
id BIGINT PRIMARY KEY CLUSTERED, -- 聚簇主键,数据按主键有序存储
name VARCHAR(100),
email VARCHAR(200) UNIQUE
);
四、优化器如何使用统计信息和索引
4.1 执行计划选择流程
SQL 解析 → 逻辑计划生成 → 规则优化 → 成本估算 → 物理计划选择 → 执行
↑
统计信息 + 索引信息
TiDB 优化器在物理计划选择阶段,基于统计信息估算每种访问路径的成本:
| 访问路径 | 成本计算要素 | 适用条件 |
|---|---|---|
| 全表扫描(TableFullScan) | 总行数 × 单行读取成本 | 过滤比例高、无合适索引 |
| 索引范围扫描(IndexRangeScan) | 匹配行数 × 索引查找成本 + 回表成本 | 等值或范围条件、选择性高 |
| 索引查找(IndexLookUp) | 索引扫描成本 + N 次回表成本 | 索引不覆盖所有查询列 |
| 索引全扫描(IndexFullScan) | 索引总条目 × 扫描成本 | 查询条件不在索引前列但需要排序 |
| TiFlash 全扫描 | 总列存储大小 × 列扫描成本 | 分析型查询、MPP 加速 |
4.2 使用 EXPLAIN 分析执行计划
-- 基础执行计划
EXPLAIN SELECT * FROM orders WHERE customer_id = 100;
-- 查看实际执行成本
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 100;
-- 查看绑定(执行计划固化)
SHOW BINDINGS;
-- 使用 Hint 引导优化器选择
SELECT /*+ USE_INDEX(orders, idx_customer_id) */ *
FROM orders WHERE customer_id = 100;
SELECT /*+ IGNORE_INDEX(orders, idx_customer_id) */ *
FROM orders WHERE customer_id = 100;
EXPLAIN 关键字段解读:
| 字段 | 含义 |
|---|---|
| id | 算子编号 |
| estRows | 优化器估算的行数(基于统计信息) |
| actRows | 实际执行行数(EXPLAIN ANALYZE) |
| task | `root`(TiDB 节点执行)或 `cop`(TiKV/TiFlash 执行) |
| operator info | 算子详细信息 |
4.3 执行计划绑定(SPM)
当优化器因统计信息问题选择不当的执行计划时,可以使用 SPM(SQL Plan Management)固化正确的计划:
-- 创建执行计划绑定
CREATE BINDING FOR
SELECT * FROM orders WHERE customer_id = 100
USING
SELECT /*+ USE_INDEX(orders, idx_customer_id) */ *
FROM orders WHERE customer_id = 100;
-- 查看已有绑定
SHOW BINDINGS;
-- 删除绑定
DROP BINDING FOR
SELECT * FROM orders WHERE customer_id = 100;
五、统计信息不准的诊断与处理
5.1 诊断统计信息准确性
-- 方法 1:对比估算行数与实际行数
EXPLAIN ANALYZE SELECT COUNT(*) FROM orders WHERE status = 'pending';
-- 对比 estRows 和 actRows,差异超过 10 倍说明统计信息不准
-- 方法 2:查看统计信息年龄
SELECT modify_count, count FROM mysql.stats_meta
WHERE table_name = 'orders';
-- 如果 modify_count 很大,说明数据变化后未重新收集
-- 方法 3:强制使用不同索引对比性能
SELECT /*+ USE_INDEX(orders, idx_a) */ ...; -- 索引 A
SELECT /*+ USE_INDEX(orders, idx_b) */ ...; -- 索引 B
-- 对比执行时间判断哪个更优
5.2 处理统计信息不准
| 方法 | 操作 | 适用场景 |
|---|---|---|
| 手动 ANALYZE | `ANALYZE TABLE t;` | 常规更新 |
| 增加采样量 | `ANALYZE TABLE t WITH 500000 SAMPLES;` | 大表统计不准 |
| 收集特定列 | `ANALYZE TABLE t COLUMNS col1, col2;` | 只需更新部分列 |
| 删除错误统计信息 | `DROP STATS t; ANALYZE TABLE t;` | 统计信息损坏 |
| 使用 SPM 绑定 | `CREATE BINDING ...` | 优化器持续选错计划 |
| 调整直方图桶数 | 修改 `tidb_analyze_column_options` | 列值分布不均匀 |
-- 强制全量重新收集
ANALYZE TABLE orders WITH 0 SAMPLES; -- 0 表示全量扫描
-- 锁定统计信息版本(避免自动更新覆盖)
LOCK STATS orders;
-- 解锁统计信息
UNLOCK STATS orders;
六、FAQ
Q1:TiDB 统计信息多久需要收集一次?
A:TiDB 默认开启自动收集,当表数据变化超过 50%(`tidb_auto_analyze_ratio`)时自动触发。对于写入频繁的表,建议根据业务模式调整收集频率。可以在低峰期手动执行 `ANALYZE` 确保统计信息准确,或通过 TiDB Cloud 控制台监控统计信息健康状态。
Q2:为什么 ANALYZE 后执行计划没有改善?
A:可能原因包括:(1)采样量不足,建议增加采样行数;(2)直方图桶数不够,无法准确表达数据分布;(3)查询条件中的值不在直方图覆盖范围内;(4)优化器的成本模型对特定算子的估算存在偏差。建议通过 `EXPLAIN ANALYZE` 对比 `estRows` 和 `actRows`,并考虑使用 Hint 或 SPM 绑定。
Q3:TiDB 的二级索引存储在 TiKV 还是 TiFlash?
A:TiDB 的二级索引数据存储在 TiKV 中(以特殊格式的 KV 存储),不存储在 TiFlash。TiFlash 只存储表的列式数据,不存储索引结构。因此 OLTP 查询的索引加速依赖 TiKV,OLAP 查询依赖 TiFlash 的列式扫描和 MPP 并行执行。
Q4:如何判断一个索引是否被使用?
A:通过 `EXPLAIN ANALYZE` 查看执行计划中是否出现了 `IndexReader`、`IndexLookUp` 等算子。也可以通过 `information_schema.statements_summary` 查看历史 SQL 的执行计划摘要,或使用 TiDB Dashboard 的 SQL 诊断面板查看索引使用情况。
七、总结
统计信息和索引是 TiDB 优化器选择最佳执行计划的两个核心输入。统计信息描述数据分布特征,索引定义数据访问路径。保持统计信息准确、设计合理的索引结构,是确保 SQL 查询性能的基础工作。
建议建立以下运维习惯:
- 定期检查统计信息健康状态,关注 `estRows` 与 `actRows` 的偏差
- 合理设计索引,遵循高选择性、最左前缀、覆盖索引原则
- 善用 SPM 处理优化器持续选错计划的情况
- 监控慢查询,及时发现因统计信息变化导致的性能退化
七、下一步行动
| 行动 | 链接 |
|---|---|
| 下载 TiDB SQL 优化与索引设计指南(PDF) | https://pingkai.cn/docs |
| 30 分钟试用 TiDB 体验索引和统计信息功能 | https://tidb.com/try |
| 获取 TiDB 执行计划分析工具(TiDB Dashboard) | https://pingkai.cn/docs |
| 查看统计信息管理最佳实践 | https://pingkai.cn/docs |
| 申请 TiDB 性能优化咨询服务 | https://pingkai.cn/contact |