一条一直很快的查询突然变慢。SQL 没改、索引没改、数据量只涨了 8%。查下来是优化器选错了执行计划:它以为某个条件能过滤掉九成数据,实际上因为近期数据分布变化,那个条件现在只过滤掉一成。它的判断依据是两周前收集的统计信息。
优化器不是在看你的表,它在看关于你表的一份摘要。这份摘要过期之后,优化器的每个决定都建立在一个不再成立的假设上——而它不会告诉你。
1. 统计信息包含什么
| 内容 | 作用 |
|---|---|
| 表行数 | 决定全表扫描还是索引 |
| 列的基数(不同值个数) | 决定过滤性估计 |
| 数据分布(直方图) | 决定范围条件的选择性 |
| 索引的页数与层级 | 决定索引扫描成本 |
还有一个容易忽略的点:统计信息本身也有"采样"这层近似。多数数据库默认采样一部分页而不是全表,所以它给出的不是精确值,而是估计值。当某个值恰好落在采样之外时,估计会错得很离谱,而这类错误在数据倾斜的表上最严重。
第二项与第三项常被混淆:基数告诉你"有多少种值",直方图告诉你"这些值怎么分布"。数据倾斜严重时,只有基数没有直方图,优化器会按均匀分布算,误差可能达一个数量级。
2. 统计信息什么时候失效
| 触发 | 机制 |
|---|---|
| 数据量变化超过阈值 | 多数数据库有自动收集门槛 |
| 大量删除或更新 | 分布改变但行数没大变 |
| 新增枚举值 | 基数变化,直方图未更新 |
| 分区表新增分区 | 新分区可能没有统计 |
| 批量导入 | 导入后统计滞后 |
一个具体的数值感受:如果一列有一千种值、均匀分布,优化器认为每个值命中 0.1% 的行;实际某个值占了 40%,那么按 0.1% 选出的计划会低估扫描量 400 倍。计划不会因此报错,它只是选了另一条路。
第二项是最隐蔽的:行数没变,分布变了。自动收集通常按"变化行数比例"触发,删一半再插一半,比例可能正好低于阈值,统计信息一直不更新,而查询的选择性估计已经完全错了。
3. 自动收集够不够
| 场景 | 自动收集 | 需要手动 |
|---|---|---|
| 平稳写入的常规表 | 通常够 | 否 |
| 有明显倾斜的列 | 可能不够 | 是(调直方图桶数) |
| 大批量导入后 | 滞后 | 是(导入后立即收集) |
| 分区表 | 可能漏分区 | 是(逐分区确认) |
| 临时表/中间表 | 常被忽略 | 是 |
第四项在分区表上很常见:新分区刚建时没有统计信息,查询落到新分区就按默认估计走,计划可能是全扫。
-- 检查统计信息的最后收集时间(MySQL 8.0)
SELECT table_name, column_name, last_analyzed, histogram
FROM information_schema.column_statistics
WHERE table_name = 'orders';
-- 没有记录 → 该列从未收集过直方图,倾斜列的选择性估计可按均匀分布算
-- 检查表级统计的更新时间
SELECT table_name, update_time, table_rows
FROM information_schema.tables WHERE table_name = 'orders';
4. 手动收集的时机与代价
| 时机 | 代价 |
|---|---|
| 大批量导入后 | 收集耗时随表大小增长 |
| 显著删除后 | 需要足够的采样 |
| 上线新查询前 | 一次性成本 |
| 定期(如每周) | 需要与业务低峰对齐 |
第一项最应该固定下来:导入任务的成功条件应当包含"统计信息已更新",而不是"数据写完了"。缺这一步,导入后的第一批查询就会跑在旧统计上。
收集成本也要估:大表全量收集可能比导入本身还慢。所以实践中常用增量收集或按分区收集:
-- 按分区收集,避免一次扫全表
ANALYZE TABLE orders PARTITION (p202611) UPDATE HISTOGRAM ON status;
-- 或调大自动收集的触发阈值以外的采样比例(按版本核对参数名)
-- MySQL: innodb_stats_sample_pages;PostgreSQL: default_statistics_target
-- 导入后的标准收尾:收集统计并确认
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, channel WITH 128 BUCKETS;
-- 桶数按倾斜程度定:均匀列 64 够用,倾斜列要更多
-- 注意:收集会占用 IO,大表应放在低峰
5. 稳定性:让计划不随统计漂移
| 手段 | 作用 | 代价 |
|---|---|---|
| 固定执行计划(如 hint、plan baseline) | 不随统计变化 | 需要人工维护 |
| 提高采样比例 | 估计更准 | 收集更慢 |
| 定期重收集 | 减少漂移 | 仍有窗口 |
| 改写查询避免依赖估计 | 从根上减少影响 | 需要设计 |
还有一种更轻的做法:定期比对执行计划的变化。把关键查询的计划哈希存下来,变了就告警,比固定计划更灵活,也更早发现统计漂移。
// 每日记录关键查询的计划哈希,变化即告警
mysql -N -e "EXPLAIN FORMAT=JSON SELECT ... " | sha256sum >> plan-history.txt
tail -2 plan-history.txt | awk '{print $1}' | uniq | wc -l # 为 2 说明计划已变
第一种在关键查询上很实用:一条核心链路的 SQL,计划稳定比"可能更优"更重要。但它的维护成本高,每次改 SQL 都要重新评估。
-- 固定计划的两种做法(示意,按具体数据库版本核对语法)
SELECT /*+ INDEX(orders idx_user_status) */ id FROM orders WHERE user_id=? AND status=?;
-- 或使用计划基线(plan baseline)绑定已知良好的计划
6. 排错:五种"计划突然变了"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 同一条 SQL 突然变慢 | 统计过期导致估计错误 | 收集统计后复测 |
| 加减一个条件计划大变 | 基数估计对边界敏感 | 看直方图是否覆盖该值 |
| 新分区查询很慢 | 分区无统计 | 逐分区收集 |
| 早上快晚上慢 | 统计在夜间被刷新 | 对比两个时段的计划 |
| 复制库上计划不同 | 主从统计不一致 | 从库单独收集 |
这也解释了另一个常见现象:同一段代码在测试库快、在生产库慢。两边的数据分布完全不同,优化器按各自的统计选计划,而测试库的分布往往过于均匀。
第五项在读写分离架构里很常见:主库统计新、从库统计旧,同一查询在两边选不同计划,于是"从库慢"成了长期未解的问题。
统计信息的另一个使用方常被忽略:索引选择之外的 JOIN 顺序。多表连接时,优化器按各表的行数估计决定谁先谁后;一张表的统计过期,整个连接顺序可能倒过来,性能差几十倍。
7. 边界:什么情况不必关心统计
| 场景 | 为什么 | 做法 |
|---|---|---|
| 极小的表(万行内) | 全扫成本可忽略 | 不关心 |
| 纯主键点查 | 计划几乎固定 | 不关心 |
| 只追加不删除的日志表 | 分布稳定 | 依赖自动收集 |
| 已有固定计划的查询 | 不随统计变 | 定期校验计划未变 |
判断口径一句话:这条查询的性能是否依赖"优化器对数据分布的估计"。只按主键取值,不依赖;带范围或多条件组合,一定依赖。
统计过期还会带来一个反直觉的后果:同一条 SQL 在某些参数上快、某些参数上慢。因为优化器对不同的参数值给出不同的估计,估计准的那个快,估计错的慢。这类问题在"偶发慢查询"里占相当比例,而只看 SQL 文本永远找不到规律。
8. 自测
- 统计信息包含哪四项?"基数"与"直方图"分别回答什么问题?
- 为什么"行数没变、分布变了"是最隐蔽的失效情形?自动收集为什么可能不触发?
- 分区表上新分区为什么容易出现计划问题?如何确认?
- 为什么"导入完成"应当包含"统计已更新"这一步?不包含会发生什么?
- 固定执行计划解决了什么问题、带来了什么代价?什么查询值得这么做?
- 读写分离场景下,为什么同一查询会在主从选出不同计划?