MySQL统计信息与直方图:优化器为什么选错索引

那条本来应该很快的 SQL

事情发生在一个只有 30 万行的小表上。一张记录文章浏览量的日志表,代码里按「日期区间 + 来源渠道」做聚合,按理说走索引就是毫秒级。但那段时间我发现后台统计页偶尔要转三秒。慢查询日志里对应的 SQL 扫描行数是 298000——它选择了全表扫描,而我明明在 (stat_date, channel) 上建了联合索引。

用 EXPLAIN 看了执行计划,possible_keys 里索引确实在,但 key 是 NULL。当时第一反应是「索引失效」,于是按老经验去查隐式类型转换、函数包裹、字符集不一致这些常见原因,全都没有。真正的原因藏在 EXPLAIN 的 rows 估算里:优化器认为这个索引要扫 25 万行,而全表才 30 万行,走索引还要回表,不如直接顺序扫。

问题就出在这里:这个「25 万」是错的。实际匹配只有几百行。优化器不是不看索引,而是它手里的数据分布信息是错的,所以算了一笔错误的账。这篇文章要讲的就是这笔账——MySQL 8.0 里统计信息到底是怎么收集的、ANALYZE TABLE 为什么经常没用、以及 8.0 才有的直方图(histogram)在什么场景下才是解药。

优化器靠什么估算行数

理解这个问题的前提是搞清楚 InnoDB 提供给优化器的「成本情报」有哪几种,因为它们的更新机制完全不同,排查时用错工具就会一直在原地打转。

第一种:索引的基数(Cardinality)

执行 SHOW INDEX FROM 表名 时看到的 Cardinality 列,表示「这个索引列大约有多少个不同的值」。它是优化器判断一个条件能过滤掉多少行的主要依据——基数越高(越接近表行数),等值条件估算出的匹配行数就越少。

关键在于:这个数字不是实时统计的。InnoDB 用的是随机采样,每次从索引里随机读取若干个页面(由 innodb_stats_persistent_sample_pages 控制,默认 20 个页),统计这些页里不同值的个数,再按比例外推到全表。20 个页听起来不少,但一个页 16KB,对一张大表来说这个样本量非常小,估算误差可以很大。

更麻烦的是,这个采样什么时候发生也有讲究。开了持久化统计(innodb_stats_persistent=ON,8.0 默认开启)之后,统计信息存在 mysql.innodb_index_stats 和 mysql.innodb_table_stats 两张表里,只在特定时机重新计算:建表、ANALYZE TABLE、或者表数据变化超过 10%(innodb_stats_auto_recalc 控制)。也就是说,如果你的表是「一次性导入 30 万行历史数据,之后每天只新增几百行」,那么基数可能永远停留在导入时的那个值上。

第二种:表的行数估算

SHOW TABLE STATUS 或者 information_schema.TABLES 里的 TABLE_ROWS 同样是个采样估算值,不是精确行数。做容量规划的时候拿它算磁盘占用会差得很离谱,更不能用它来做「表里到底有多少条数据」的业务判断——那个必须 SELECT COUNT(*)。

第三种:直方图(8.0 新增)

前面两种都是「一个数字概括整个列」,但真实数据往往是倾斜的。比如 channel 这一列,90% 的记录是 direct,剩下 10% 分散在十几个渠道里。优化器只知道「有 16 个不同值」,它假设每个值均匀分布,于是估算 channel='weibo' 会匹配 30万/16 ≈ 1.8 万行,而实际可能只有 300 行。

直方图就是给这种倾斜数据准备的分桶统计。它把列值按范围或频次切成若干个桶,记录每个桶里大约有多少行,让优化器能回答「这个具体的值大概有多少行」这种问题。这是 MySQL 8.0 之前完全没有的能力——5.7 时代遇到这类问题只能靠改 SQL 写法硬绕。

先确认到底是哪一种统计出了问题

不要一上来就 ANALYZE TABLE。先花两分钟确认问题出在哪个环节,否则可能做了无效操作还误以为「优化器就是不行」。

第一步:看估算行数与真实行数的差距

-- 看优化器估算
EXPLAIN SELECT COUNT(*) FROM stat_log
WHERE stat_date BETWEEN '2026-10-01' AND '2026-10-07'
  AND channel = 'weibo';

-- 看真实行数
SELECT COUNT(*) FROM stat_log
WHERE stat_date BETWEEN '2026-10-01' AND '2026-10-07'
  AND channel = 'weibo';

如果 EXPLAIN 的 rows 和真实 COUNT(*) 差了十倍以上,基本可以确定是统计信息问题,而不是 SQL 写法问题。这是整个排查的分水岭。

第二步:看基数是准还是不准

SELECT COUNT(DISTINCT channel) AS real_distinct FROM stat_log;
SHOW INDEX FROM stat_log WHERE Key_name = 'idx_date_channel';

如果 Cardinality 和 real_distinct 差得很远,那是采样的问题,ANALYZE TABLE 或者调大采样页数能解决。如果两者接近,但估算行数还是离谱,那就是数据分布倾斜的问题,ANALYZE TABLE 再怎么跑也没用,因为基数这个指标本身表达不了倾斜——这时才需要直方图。

第三步:看统计信息是什么时候更新的

SELECT table_name, last_update, n_rows, clustered_index_size
FROM mysql.innodb_table_stats
WHERE table_name = 'stat_log';

SELECT index_name, stat_name, stat_value, last_update
FROM mysql.innodb_index_stats
WHERE table_name = 'stat_log' AND stat_name = 'n_diff_pfx01';

last_update 如果是几个月前,而表在这期间翻了好几倍,那答案就很明显了。

ANALYZE TABLE 为什么会「跑了也没用」

这一节是本文最想讲清楚的部分,因为我在这个问题上浪费了不少时间。执行 ANALYZE TABLE stat_log; 之后看到 Table is already up to date,很多人就得出结论「已经是最新的了,问题不在统计信息」。这个结论有一半是错的。

「already up to date」不等于「统计是对的」

InnoDB 的持久化统计信息有一个机制:如果表的数据变化比例没有超过阈值(默认 10%),ANALYZE TABLE 会直接跳过重新计算,返回 already up to date。它的意思是「我判断没必要重算」,而不是「我算过了,结果正确」。

问题在于,这个 10% 阈值对那些「增量小但分布剧变」的表完全失灵。举个例子:一张 30 万行的表,你删掉了 2 万行旧数据(不到 10%),又插入了 2 万行新数据,总量没变。但从「已经修改的行数」这个内部计数器看,变化量可能没有触发阈值。于是统计信息仍然停留在旧分布上,哪怕 Channel 列的分布已经彻底变了。

强制重算的办法是先改一个明显的值,或者直接关掉持久化统计再做一次:

-- 方法一:让内部计数器超过阈值(改一行再改回来)
UPDATE stat_log SET channel = channel WHERE id = 1;
ANALYZE TABLE stat_log;

-- 方法二:临时绕过持久化统计
SET SESSION innodb_stats_persistent = OFF;
ANALYZE TABLE stat_log;
SET SESSION innodb_stats_persistent = ON;

注意方法二只影响当前会话的计算方式,但因为计算结果是写进 mysql.innodb_index_stats 的,所以对其他会话也生效(前提是持久化统计开着)。不过它的副作用是:非持久化模式下,统计信息会在实例重启后丢失,需要重新计算。

采样页数决定的是精度上限

默认 20 个采样页,对一亿行的大表来说完全不够。调大这个是有效的:

-- 全局设置(影响之后所有新计算的统计)
SET GLOBAL innodb_stats_persistent_sample_pages = 200;

-- 或者只给这张表设置,更精准
ALTER TABLE stat_log STATS_SAMPLE_PAGES = 500;

代价很直接:ANALYZE TABLE 会变慢,而且它持有表的元数据锁(MDL)。在生产环境上对大表跑高采样的 ANALYZE,一定要先确认没有长事务——否则 ANALYZE 会排在 DDL 队列里,而后续所有查询又排在它后面,这就是著名的 MDL 队列雪崩。稳妥做法是给 ANALYZE 单独设一个短超时:

SET SESSION lock_wait_timeout = 10;
ANALYZE TABLE stat_log;

10 秒拿不到锁就放弃,比把整站拖死强得多。低峰期再重试。

直方图:8.0 才有的那把钥匙

如果确认问题是数据倾斜,那么无论怎么 ANALYZE 都解决不了,因为基数统计天生表达不了「某个值特别多」这件事。MySQL 8.0 的直方图正是补这个缺口的。

创建与查看

-- 对单列建直方图,100 个桶
ANALYZE TABLE stat_log UPDATE HISTOGRAM ON channel WITH 100 BUCKETS;

-- 也可以一次给多列建(每列各自独立)
ANALYZE TABLE stat_log UPDATE HISTOGRAM ON channel, source_type WITH 64 BUCKETS;

-- 查看
SELECT * FROM information_schema.COLUMN_STATISTICS
WHERE TABLE_NAME = 'stat_log';

返回的 HISTOGRAM 是一段 JSON,里面有两种类型:singleton 和 equi-height。前者适用于不同值个数少于桶数的情况(每个值一个桶,直接记录这个值的频次),后者适用于值很多的情况(把数据按高度切桶,每桶行数相近,记录每桶的上下界)。

对于前面 channel 这个例子,singleton 直方图会明确记下 direct 有 27 万行、weibo 有 300 行。优化器看到这个,就会知道 channel='weibo' 是个高选择性的条件,应该走索引。

几个必须知道的边界

直方图不会自动更新。 这是最容易踩的坑。统计信息(基数)有 innodb_stats_auto_recalc 在背后自动重算,直方图完全没有自动机制,数据变了直方图就过期了,而且没有任何警告。必须靠外部定时任务刷新:

-- 建议每天低峰期跑一次,只针对那几列倾斜的
SET SESSION lock_wait_timeout = 10;
ANALYZE TABLE stat_log UPDATE HISTOGRAM ON channel WITH 100 BUCKETS;

直方图只对「值分布」有用,对范围查询的帮助有限。 它最擅长的是等值条件的估算(WHERE channel='x'、WHERE status=1)。虽然 equi-height 理论上也能帮助范围估算,但实际效果远不如索引本身,而且 InnoDB 主要把直方图用于「没有可用索引的列」——如果列上有索引,优化器更倾向于用索引的基数。

桶数不是越多越好。 每个桶都要占空间,且都参与优化时的估算计算。经验上,不同值在几百到几千之间时用 singleton,桶数取实际不同值的数量;值非常多(如用户 ID)时用 equi-height,桶数 100 到 250 就够了。桶数超过 1000 收益很小,反而拖慢优化过程——因为优化器每次生成执行计划都要去读这些统计。

直方图不进 binlog 也不复制。 在主从架构下,主库上建的直方图不会同步到从库。如果从库要承担读请求,必须在从库上也单独建。这一点很容易漏,表现出来就是「同样的 SQL 主库走索引、从库全表扫」,非常迷惑人。

一个真实的对比实测

回到开头那张表。我在测试环境上做了三组对照,数据量 30 万行,direct 占 92%,其余分散在 15 个渠道。查询条件是 stat_date 区间七天(约 4000 行)+ channel='weibo'(约 280 行)。

-- 基线:无直方图,索引基数为导入时的旧值
EXPLAIN SELECT COUNT(*) FROM stat_log
WHERE stat_date BETWEEN '2026-10-01' AND '2026-10-07' AND channel='weibo';
-- rows: 248000  key: NULL  type: ALL   → 实际耗时 312ms

-- 重算统计信息(不加直方图)
SET SESSION innodb_stats_persistent_sample_pages = 200;
ANALYZE TABLE stat_log;
-- rows: 224000  key: NULL  type: ALL   → 实际耗时 305ms

-- 加直方图
ANALYZE TABLE stat_log UPDATE HISTOGRAM ON channel WITH 128 BUCKETS;
EXPLAIN SELECT COUNT(*) FROM stat_log
WHERE stat_date BETWEEN '2026-10-01' AND '2026-10-07' AND channel='weibo';
-- rows: 312  key: idx_date_channel  type: range  → 实际耗时 4ms

结果很清楚:光重算统计信息(哪怕把采样页调到 200)几乎没有改善,因为基数统计无论多准,都缺不了「某个具体值有多少行」这个信息。加上直方图之后,估算行数从 22 万降到 312,接近真实的 280 行,优化器立刻改走了 idx_date_channel,耗时从 305ms 掉到 4ms。

顺带说一句,也可以用 EXPLAIN ANALYZE(8.0.18+)来看实际执行情况和估算的对比,它比普通 EXPLAIN 更有说服力:

EXPLAIN ANALYZE SELECT COUNT(*) FROM stat_log
WHERE stat_date BETWEEN '2026-10-01' AND '2026-10-07' AND channel='weibo';

输出里的 actual rows 和 estimated rows 并排出现,差距一眼可见。actual rows × loops 才是真正的总扫描量——很多人只看 actual rows 而忽略了 loops,在嵌套循环连接里会严重低估工作量。

什么时候该用直方图,什么时候别用

直方图不是万能药,滥用反而增加维护负担。我整理了一张判断表:

值得建直方图的情形:
  1. 列上没有索引,但要用于过滤(如 status、type、category 之类的状态列)
  2. 列上有索引,但数据严重倾斜,且优化器频繁选错索引
  3. 该列经常出现在 WHERE 的等值条件里
  4. 表的数据变化频率可控(能接受每天手动刷新一次)

不必建的情形:
  1. 列上已有索引,且基数统计已经足够准确
  2. 数据分布均匀(直方图提供的信息和基数几乎一致)
  3. 该列只出现在 ORDER BY 或 SELECT 列表中,不做过滤
  4. 表很大且数据高频变动(直方图一分钟后就过期,维护成本高于收益)

还有一个更省事的替代方案值得提:如果某列的问题表现为「热值 vs 冷值」,而且你不介意改代码,把热值提前到 SQL 里单独处理往往比调统计信息更稳。比如 channel='direct' 这种占九成的值,可以把它拆成单独一条查询(或者干脆不加这个条件先查总量,再减去其他渠道),让每条 SQL 面对的分布都是均匀的。这种做法违反「一条 SQL 解决问题」的直觉,但在统计信息不可靠的环境里,它比反复调参靠谱得多。

日常维护:三段式检查单

统计信息这类问题一旦踩过一次,就值得把检查固化下来。我的做法是在低峰期跑三件事:

第一段:找出估算偏差最大的表

SELECT table_name, n_rows, last_update,
       DATEDIFF(NOW(), last_update) AS days_stale
FROM mysql.innodb_table_stats
WHERE database_name = '你的库名'
ORDER BY days_stale DESC
LIMIT 10;

days_stale 超过 30 天、且这张表在这期间有明显数据变动的,就值得重算一次。n_rows 也可以和真实行数对比——差得太多说明采样已经跟不上。

第二段:只看最慢的那几条 SQL

从 performance_schema.events_statements_summary_by_digest 里按平均耗时排序,前十条里如果有扫描行数(SUM_ROWS_EXAMINED)远大于返回行数(SUM_ROWS_SENT)的,就是考察对象:

SELECT DIGEST_TEXT, COUNT_STAR AS cnt,
       SUM_ROWS_EXAMINED, SUM_ROWS_SENT,
       ROUND(SUM_ROWS_EXAMINED / GREATEST(SUM_ROWS_SENT,1)) AS ratio,
       ROUND(SUM_TIMER_WAIT/1000000000000, 2) AS total_sec
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;

ratio 大于 100 的,几乎必然是统计信息出了问题或者索引缺失——扫描了一百行才返回一行。

第三段:刷新与验证

对确认需要的列刷新直方图,然后重跑 EXPLAIN 对比前后。这里有个纪律要守住:每次只改一样东西。如果同时调了采样页、加了直方图、还改了 SQL,最后快了你也不知道是哪一项起的作用——下次遇到别的问题就没法复用经验。

几条踩过才知道的细节

分区表的统计信息是分区级的。 一个按日期分区的日志表,每个分区有自己的统计信息。ANALYZE TABLE 默认会分析所有分区,对大表非常慢。可以只分析指定分区:ANALYZE TABLE t PARTITION (p202610);。但要注意,如果查询跨越多个分区,优化器要做「分区裁剪 + 各分区估算求和」,估算误差会累积。

innodb_stats_on_metadata 默认是 OFF,这是好事。 5.6 时代它是 ON 的,意味着每次执行 SHOW TABLE STATUS 或者查询 information_schema.TABLES 都会触发一次统计重算,那才是真的性能杀手。8.0 默认关掉了,如果你在旧机器上做过「性能优化指南」里的操作把它打开了,建议关掉。

统计信息表不要手动改。 mysql.innodb_table_stats 是普通表,理论可以 UPDATE,网上也有「直接改 n_rows 骗优化器」的偏方。别这么干——它会在下次自动重算时被覆盖,而且中间这段时间所有基于它的执行计划都是错的,排查起来毫无线索。

优化器不是万能的,它也可能被统计信息带偏。 如果确认某个 SQL 的最优执行计划是明确的,而优化器始终算不对,可以用 FORCE INDEX 或者 8.0 的优化器提示(/*+ INDEX(t idx_name) */)兜底。但这是止痛药不是治本——要记得在优化器提示旁边写一句注释说明原因,否则半年后接手的人(很可能是你自己)会以为是随手乱加的。

Last modification:October 24th, 2026 at 08:26 pm

Leave a Comment