你有没有遇到过这种情况:明明给查询字段建好了索引,EXPLAIN一看,优化器偏偏不选索引,直接给你来个全表扫描。这时候很多人第一反应是骂MySQL“傻”,其实真不怪它,问题往往出在基数(Cardinality)这个关键指标上。MySQL优化器判断“走不走索引”,很大程度上就是看这个数字。今天我就把这个机制从原理到排查完整拆一遍,看完你就能明白优化器到底在想什么,以及怎么让它乖乖听话。
这篇内容适合谁?无论是刚接触索引优化的新手,还是被线上慢查询折磨过的老手,都应该把基数这个维度补上。我会结合优化器的代价模型、统计信息采样机制、索引失效的典型案例,以及force index、analyze table、直方图这些干预手段,把“基数导致索引放弃”这件事讲透。
1. 基数这个概念,到底在说什么
1.1 从“区分度”说起
基数的英文是Cardinality,翻译成大白话就是“一个字段里有多少种不同的取值”。比如一张用户表有100万行,gender字段只有“男”“女”两个值,那它的基数就是2;而id主键几乎每一行都不同,基数就接近100万。
基数越高,说明字段的区分度越好,索引越有价值;基数越低,说明大量行共享同一个值,索引的筛选能力就越差。
但在MySQL里,SHOW INDEX FROM table看到的Cardinality列,并不是实时统计的精确值,而是一个估算值。MySQL不会每次查询都去数一遍有多少个不同值,那样代价太高了,它用的是采样估算。所以这个数字经常会“不准”,而一旦不准,优化器就可能做出错误的判断——明明索引很有用,它却认为基数太低,进而选择全表扫描。
1.2 优化器真正关心的是“成本”
MySQL的优化器本质上是一个成本计算器。它会把每条SQL执行路径的成本估算出来,然后挑一个它认为最便宜的。全表扫描的成本很简单,就是“读多少页数据”;而走索引的成本要复杂得多,包括:
- 扫描索引页的成本,这取决于你查询条件能过滤掉多少数据,也就是选择率(selectivity)。
- 回表查询的成本,也就是根据索引找到的主键,再去聚簇索引里读取完整行。
- 排序和临时表的成本,如果索引不能避免
ORDER BY或GROUP BY,优化器还要考虑额外排序的开销。
基数就是计算选择率的输入参数。一个字段基数越高,优化器就认为“每个值匹配的行数越少”,因此走索引后回表的次数也越少,路径成本就越低。反之,如果基数被估算得很低,优化器就会嘀咕“这索引筛不出多少数据,还不如直接全表扫”,于是把索引方案扔进垃圾桶。
这里有个特别容易踩的认知误区:很多人以为“建了索引就一定会走”,但实际上,索引只是给了优化器一个“选项”,用不用得由代价模型说了算。基数统计失真,就会让这个“选项”被错误地估高成本,直接导致索引弃用。
2. 优化器是怎么把基数算错的
2.1 统计信息的采集机制
MySQL的基数估算依赖的是InnoDB的统计信息,这些信息存储在mysql.innodb_table_stats和mysql.innodb_index_stats两张系统表里。它们的更新时机主要有两个:
ANALYZE TABLE手动触发。- 表上的数据量变化超过阈值,自动触发。这个阈值是当前表行数的10%左右,也就是说,如果一张表有100万行,大概要新增10万行数据,统计信息才会自动重新计算。
这个机制在实际生产环境中非常容易出问题。比如一张表每天只插入几千行,但都是新值,字段的实际基数变化非常大,可从行数看远达不到10%的阈值,统计信息就一直不更新。MySQL以为这个字段只有80个不同值,实际上已经涨到8000个了,结果就是优化器低估索引价值,走上全表扫描。
2.2 采样算法带来的误差
MySQL计算基数时,并不是全量扫描索引,而是随机采样。InnoDB默认会选取N个索引页进行采样,这个N在旧版本里是一个固定值,跟索引页总数无关。采样页数越少,估算误差越大。
特别是对于数据分布不均匀的字段,采样恰好抽到重复度高的页,就会严重低估基数。举个例子,有一个字段大部分值都是重复的“default”,但其中夹杂着几十万条唯一值,采样时如果抽到的页面里全是“default”,MySQL就会认为这个字段基数极低,索引约等于废的。
这个场景非常经典,我见过不止一次:程序里有个状态字段,90%的行是0,10%的行分布在1~5,查询条件偏偏是WHERE status IN (1,2,3),这种查询走索引其实收益很大,但优化器一看基数统计,觉得status全是重复值,直接全表扫描。
2.3 关联字段的独立性假设
优化器计算多条件联合查询的基数时,还会做一个在数学上不太成立的假设:各字段之间相互独立。
比如你有两个字段city_id和member_level,事实上高等级会员集中在一线城市,但优化器不知道这个相关性,它会认为两个字段的选择率可以简单相乘。这种假设在大多数情况下够用,可一旦字段间存在强相关性,估算出的行数就会和实际严重偏离,进而出现“索引组合明明能捞到极少数据,优化器却认为结果集很大”的误判。
3. 典型场景复现:基数怎样把索引“废”掉
3.1 场景一:数据倾斜
我们先做一个模拟。假设有一张订单表orders,包含status字段,取值范围是0, 1, 2,其中0代表已完成,占99%,剩下1%分布在1和2。
你执行下面的查询:
SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 10;这条查询会怎么走?如果你只建了status单列索引,优化器大概率会选择全表扫描,理由很简单:在它的估算里,status=1可能匹配几十万行,再回表排序,成本极高。但实际上,status=1只有1万行,走索引后加filesort完全在可接受范围内。
这种场景下,仅仅依赖索引往往不够,我通常的做法是改用联合索引(status, create_time),让排序也在索引内完成。优化器一看到索引已经有序,扫描范围又小,就会老老实实选索引了。
3.2 场景二:统计信息过期
这是生产环境里最隐蔽的问题。一张表数据量很稳定,但某些字段的取值每天都在变。比如会话表sessions,字段token_hash的基数其实非常高,但因为表里每天都有大量过期数据被删除,当表的总行数稳定在100万,而每日增删量只有3万行时,始终触发不了10%的自动更新阈值。token_hash的统计信息一直停留在一个月前的状态,基数可能只显示了实际值的十分之一。
在这种状态下,如果有人按token_hash做关联查询,优化器会判断这个索引的选择性太差,于是走了全表扫描。大表全表扫描的后果不用多说,接口直接超时。
干预方法很简单:收到慢查询后,先执行一次ANALYZE TABLE sessions,再重新EXPLAIN。如果执行计划从全表扫描变成了索引扫描,就说明问题确实是统计信息过期。后续可以写一个定时任务,每天凌晨对这类表执行ANALYZE。
3.3 场景三:索引失效的隐含原因
还有一种情况容易被忽视——函数操作和隐式类型转换对基数的影响。比如:
SELECT * FROM users WHERE DATE(created_at) = '2024-01-01';即使created_at字段上建了索引,索引基数再高也没用,因为DATE()函数让索引列无法直接比较,优化器只能放弃索引。这类问题和基数无关,但经常和“基数误判”混在一起出现,排查时要先区分清楚。
另有隐式类型转换,比如字符串字段和整型比较,也可能导致优化器放弃索引。这类问题靠force index都没用,得改SQL写法,或者用生成列解决。
4. 怎么判断你的SQL是不是“死于基数误判”
4.1 第一步:看懂EXPLAIN输出
当你发现SQL走了全表扫描,先跑一遍:
EXPLAIN SELECT ...;重点看以下几列:
type:如果是ALL,说明全表扫描;如果是index或range,说明走了索引。rows:这是优化器估算的需要扫描的行数,如果这个数字和实际行数差了几个数量级,就有很大概率是统计信息失真。filtered:表示在扫描行中,有多少比例能满足查询条件。如果rows很大但filtered很小,说明优化器认为“扫很多行但筛出的很少”,这也会影响它对索引的判断。
举个例子,一个查询type=ALL,rows显示150万,filtered=1,意思是优化器认为要全表扫150万行,最后筛出1.5万行。这时你可以顺手执行一下真实的COUNT(*),如果发现实际上扫100行就能筛出100行,那就实锤了优化器被错误的统计信息带偏。
4.2 第二步:看执行计划的走向
EXPLAIN之外,更进阶的工具是优化器追踪(Optimizer Trace)。开启方式:
SET optimizer_trace="enabled=on"; SELECT * FROM orders WHERE status = 1 AND create_time > '2024-01-01'; SELECT * FROM information_schema.OPTIMIZER_TRACE;输出的JSON里有一段"considered_execution_plans",它会列出优化器比较过哪些路径、每个路径的成本是多少。比如你会看到类似的记录:
{ "plan_prefix": [], "table": "`orders`", "best_access_path": { "considered_access_paths": [ { "access_type": "ref", "index": "idx_status", "rows": 50000, "cost": 208713.2, "chosen": false }, { "access_type": "ALL", "index": null, "rows": 980000, "cost": 198342.1, "chosen": true } ] } }这里很关键:idx_status的估算行数是5万,回表成本加起来比全表扫描高,所以没被选上。但如果你实际测试发现status=1只有2000行,那就说明基数的估算值出了问题。
4.3 第三步:核对统计信息
直接查系统表,看统计信息的详细数据:
SELECT table_name, index_name, cardinality, stat_value FROM mysql.innodb_index_stats WHERE table_name = 'orders';而当前真实基数可以通过下面这条SQL估算:
SELECT COUNT(DISTINCT status) FROM orders; SELECT COUNT(DISTINCT create_time) FROM orders;把两个数字对比一下,如果有巨大差异,就说明统计信息确实滞后了。以我的经验,这种对比是排查基数误判最直接的方法,比猜来猜去有效率得多。
5. 修正基数误判的几种手段
5.1 最基础的手段:手动更新统计信息
ANALYZE TABLE orders;执行后,SHOW INDEX FROM orders里的Cardinality值就应该刷新了。但我必须提醒一句,ANALYZE TABLE会获取表的元数据锁,虽然通常很快,但在访问高峰执行仍可能导致短暂阻塞。线上操作要挑低峰期,或者用ANALYZE TABLE NO_WRITE_TO_BINLOG,避免它在从库重复执行。
这张表如果更新频繁,手动一次不够,要配合定期任务。比如我维护的某个系统里,对几张核心大表每2小时执行一次ANALYZE,并把结果监控起来,数字化地跟踪基数的变化情况。
5.2 让统计更精准:调大采样页数
在MySQL 8.0里,可以通过参数控制采样页数,最核心的是innodb_stats_transient_sample_pages和innodb_stats_persistent_sample_pages。
默认情况下innodb_stats_persistent_sample_pages是20,这个值对于小表够用,但大表或数据倾斜严重的表,20页采样远远不够。把它调大,比如调到100,统计精度会明显提升,代价是ANALYZE TABLE执行时间变长。
SET GLOBAL innodb_stats_persistent_sample_pages = 100;另外两个参数也很重要:
innodb_stats_auto_recalc:控制表数据变更超阈值后是否自动重算。默认是ON,如果你在高并发环境下被自动重算的性能抖动困扰,可以考虑关掉,改成完全手动管理。innodb_stats_method:这个参数很有意思,控制统计基数时如何处理NULL值。默认是nulls_equal,也就是所有NULL值算作同一个值。如果一个字段大量数据是NULL,少量有值,用这个默认设置就会低估基数。改成nulls_unequal或者更合适的策略,可以让估算更贴近实际。
nulls_unequal的意思是每个NULL都当作不同值,如果NULL占比很高,会导致基数虚高。所以实际情况要根据业务字段的NULL比例来选择,不要无脑改。
5.3 用直方图解决“老顽固”
有些字段即使统计信息是准的,优化器依然会选错路径,那就是典型的“数据分布不均”问题。这时候,MySQL 8.0的直方图(Histogram)就该出场了。
直方图能记录字段的数据分布情况,优化器可以据此做出比“单个基数”精确得多的估算:
ANALYZE TABLE orders UPDATE HISTOGRAM ON status;和普通ANALYZE只统计基数不同,直方图保存的是“每个值的出现频率”。优化器看到status=1对应的频率是0.1%,就知道这个值只有极少数行,走索引非常划算,而不是简单粗暴地看索引平均基数。
直方图有两种类型:singleton(单例直方图)适合基数小的字段,equi-height(等高直方图)适合基数大、分布较广的字段。大部分场景singleton就够用。要注意的是,直方图信息和索引统计信息是独立的,即使字段上没有索引,直方图也能帮助优化器选择更好的连接顺序和路径。
5.4 紧急干预:force index和ignore index
如果统计信息暂时修不好,线上又急需救火,可以用FORCE INDEX强制执行计划:
SELECT * FROM orders FORCE INDEX (idx_status) WHERE status = 1 AND create_time > '2024-01-01';但要清楚,这是治标不治本的土办法。一来,SQL上线后如果表结构或数据分布变了,这个强制索引未必还合适;二来,FORCE INDEX是写在SQL里的,后续维护很麻烦,我还见过因为FORCE INDEX选错索引导致慢查询更慢的案例。所以我的建议是:force index 只用于临时救火,后续还是要通过更新统计信息或调整索引从根本上解决。
5.5 终极解法:改写SQL和索引设计
说白了,优化器不够聪明的时候,提升SQL本身的选择性才是王道。
比如最典型的场景:WHERE status = 1 ORDER BY create_time LIMIT 10,上面我提过可以直接建(status, create_time)联合索引。这就让排序不再需要filesort,也大大减少了扫描行数。
再有一种方式是“SQL改写”:
SELECT * FROM orders WHERE status = 1 AND create_time > (SELECT MAX(create_time) FROM orders WHERE status = 0)这种写法的意思是直接告诉优化器“我只要最近的数据”,比让它拿索引碰运气要靠谱得多。
6. 排查实录分享
6.1 一次真实的统计信息陷阱
我之前在某电商系统排查过一个慢接口,现象是订单查询接口从平均80毫秒变成2秒。看了慢SQL日志,定位到一条按user_id + order_status查询的语句。
当时EXPLAIN显示type=ALL,扫描行数估算82万。而user_id上明明建了索引,按常理不可能全表扫。我第一时间查了SHOW INDEX,发现索引的Cardinality竟然只有5。可这表里的user_id少说也有10万个不同值。
接着我看了information_schema.statistics和实际COUNT(DISTINCT user_id),确认差距巨大,是统计信息过期了。二话不说跑了一次:
ANALYZE TABLE orders;再EXPLAIN,执行计划从ALL变成了ref,扫描行数估算8400,接口耗时回到100毫秒以里。
6.2 为什么一定要关注“统计信息更新策略”
完了之后,我在这个系统里加了两个预防措施:
- 核心大表建了每天凌晨自动执行
ANALYZE TABLE的定时任务。 - 把一张关键表的
innodb_stats_persistent_sample_pages从默认值调到了64,主要为了降低采样误差。
一句话总结:不监控统计信息的有效期,等于埋雷。数据量的波动可能不会触发自动重算,但字段的基数早就变了。
7. 常见问题速查与避坑清单
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| EXPLAIN显示全表扫描,rows被高估 | 统计信息过期 | ANALYZE TABLE,建立定期任务 |
| 小字段查询也全表扫描 | 基数被采样低估 | 调大innodb_stats_persistent_sample_pages |
| 多个条件能筛出极少数据但仍走全表 | 字段间有相关性 | 建联合索引,或使用直方图 |
| 大量NULL值字段误判基数 | nulls_equal导致的 | 调整innodb_stats_method |
| 排序查询走了全表扫描 | 索引无法覆盖排序 | 建(条件字段, 排序字段)联合索引 |
| 高并发时执行计划突然变化 | 统计信息刚被自动重算 | 固定执行计划或调整统计策略 |
避坑的经验还有几条,写在这里提醒大家:
- 不要太信任
System Lock状态,ANALYZE TABLE高峰期执行可能堵塞业务,这一定要盯住。 - 统计信息的更新并不能立刻解决所有问题,如果实际数据分布极度不均匀,务必配合直方图。
- 在MySQL 5.7和8.0之间,统计信息机制差异很大,升级版本前后都要重新检查执行计划。
8. 一条实用的收尾经验
关于“基数导致索引弃用”这个问题,我的体会是,不要试图和优化器较劲,而是要理解它的判断依据。优化器永远在追求“最低预估成本”,基数只是它用来评估成本的一个输入信号。既然信号可能失真,我们就要去校准它,这对DBA或后端开发者来说都是一项基本功。
最后分享一个排查SOP:遇到全表扫描先看type和rows,再对比SHOW INDEX里的Cardinality和真实COUNT(DISTINCT),差距大就ANALYZE TABLE,还不行就查OPTIMIZER_TRACE看成本计算细节,最后再考虑FORCE INDEX或改索引设计。这套流程走下来,绝大部分基数误判问题都能定位清楚。