在MySQL性能排查中,“全表扫描”经常被当成查询缓慢的代名词。实际上,全表扫描本身并不一定是错误。对于只有几百行的小表,直接读取整个表可能比走索引更加高效;即使表很大,如果查询最终需要返回绝大多数记录,优化器也可能主动选择全表扫描。
真正需要解决的问题不是“为什么没有使用索引”,而是MySQL当前选择的访问路径是否适合这个查询。理解这一点,首先要从EXPLAIN执行计划开始。
全表扫描到底是什么
InnoDB表的主数据组织在聚簇索引中。所谓全表扫描,对于没有合适索引可用于定位数据的查询,通常意味着存储引擎需要遍历大量甚至全部聚簇索引记录,再判断哪些记录符合WHERE条件。
例如:
SELECT *
FROM users
WHERE name LIKE '%张三%';
如果name只有普通B-tree索引,搜索模式前面存在%,MySQL通常无法利用这个索引直接定位“以张三开头”的连续索引范围,因此可能选择扫描大量记录。
这里需要注意,“没有使用索引”与“全表扫描”不是完全相同的概念。EXPLAIN中的key=NULL只能说明该表访问没有选择一个索引作为访问路径;真正典型的全表扫描应结合type=ALL判断。
EXPLAIN首先应该看什么
可以执行:
EXPLAIN
SELECT *
FROM users
WHERE name LIKE '%张三%';
执行计划中比较重要的字段包括type、possible_keys、key、key_len、rows、filtered和Extra。
其中,type描述表访问方式。ALL通常表示全表扫描;const、eq_ref、ref等通常代表更有针对性的访问方式,但不能简单把某一个type机械地定义成“快”或者“慢”。例如range可能扫描大量索引记录,而ref也可能因为返回数据量巨大而产生较高成本。
possible_keys表示优化器认为可能使用的索引,key则是最终选择的索引。如果possible_keys有值而key=NULL,说明存在候选索引,但优化器最终没有选择它。
rows是优化器估算需要检查的行数,而不是实际已经读取的精确行数。估算值和实际执行情况可能存在较大差异,因此复杂查询不能只凭rows下结论。
为什么优化器有时故意选择全表扫描
假设一个表只有1000行:
SELECT *
FROM users
WHERE status = 1;
即使status存在索引,如果90%以上记录的status都是1,使用索引后还需要读取大量索引记录,再回表读取大量数据。此时直接扫描聚簇索引可能更加经济。
因此,“有索引却没有使用”并不一定意味着索引失效。
真正需要关注的是:表有多大、查询返回多少记录、数据是否具有选择性、索引是否能够减少需要读取的数据,以及访问路径的实际I/O和CPU成本。
LIKE并不是简单的“有索引或没有索引”
对于:
WHERE name LIKE '张三%'
普通B-tree索引通常可以帮助定位符合前缀条件的索引范围。
但:
WHERE name LIKE '%张三%'
前面存在通配符时,普通B-tree索引通常无法进行同样有效的范围定位。
如果业务真正需要大量“包含某段文字”的搜索,就不应该只是不断增加普通索引,而应该根据搜索需求考虑全文索引或专门的搜索系统。究竟采用哪种方案,需要结合语言、搜索模式、数据规模以及业务实时性要求决定。
函数条件可能影响索引访问
例如:
SELECT *
FROM orders
WHERE YEAR(create_time) = 2025;
这里对列执行函数,会改变优化器利用普通索引进行范围定位的方式。
通常可以改写成:
SELECT *
FROM orders
WHERE create_time >= '2025-01-01'
AND create_time < '2026-01-01';
这样可以直接利用时间范围进行索引访问。
但也不能简单总结为“WHERE中使用函数就一定无法使用索引”。MySQL版本、索引设计以及是否存在合适的函数索引或生成列都会影响最终执行计划。因此最终还是应该用EXPLAIN验证。
OR也不意味着必须改成UNION ALL
原稿中“使用OR连接不同字段,除非使用UNION ALL”的说法过于绝对。
例如:
SELECT *
FROM users
WHERE email = 'a@example.com'
OR phone = '1234567890';
MySQL优化器可能根据索引情况选择合适的访问方式,包括某些情况下使用Index Merge。
是否改写成:
SELECT ...
FROM users
WHERE email = 'a@example.com'
UNION ALL
SELECT ...
FROM users
WHERE phone = '1234567890';
需要通过实际执行计划和数据分布进行比较,而且还必须处理两个条件可能匹配同一行时的重复问题。不能把UNION ALL当成解决所有OR查询问题的固定规则。
组合索引不能只记住“最左前缀”
例如:
CREATE INDEX idx_status_time
ON users(status, create_time);
对于:
SELECT *
FROM users
WHERE status = 1
AND create_time > '2025-01-01';
这个索引通常具有较好的适用性。
但组合索引设计不能只根据WHERE字段出现顺序决定。还应该考虑等值条件、范围条件、JOIN条件、ORDER BY、GROUP BY、选择性、数据分布以及是否能够减少回表。
例如:
WHERE status = 1
ORDER BY create_time DESC
LIMIT 50;
和:
WHERE create_time > '2025-01-01'
AND status = 1;
虽然都涉及status和create_time,但最优索引设计未必完全相同。
因此,索引设计应该从实际查询模式和执行计划出发,而不是机械地给每个WHERE字段分别建立索引。
覆盖索引可以减少回表,但不是“避免全表扫描”的同义词
假设存在:
CREATE INDEX idx_status_time
ON users(status, create_time);
而查询只需要:
SELECT status, create_time
FROM users
WHERE status = 1
AND create_time > '2025-01-01';
索引本身已经包含查询需要的字段,存储引擎可能直接从索引中取得结果,从而减少访问聚簇索引的成本。
这就是覆盖索引的价值。
但覆盖索引不是所有查询的通用解决方案。如果查询使用SELECT *,需要返回大量字段,就很难通过一个索引覆盖所有数据,而且为了覆盖查询而加入大量字段还会增加索引体积和写入成本。
用EXPLAIN ANALYZE验证实际执行情况
普通EXPLAIN主要告诉我们优化器准备采用什么执行计划。
对于需要进一步调查的查询,可以使用:
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE status = 1
AND create_time > '2025-01-01';
它会实际执行查询,并提供实际执行过程中的时间、循环次数以及实际行数等信息。
这对于发现“优化器估算错误”尤其有价值。
例如EXPLAIN估算某一步只需要检查1000行,但实际执行发现需要处理数百万行,那么问题可能出现在统计信息、数据分布、相关列之间的数据相关性或者查询条件估算上。
这时候,与其继续盲目增加索引,不如先研究为什么优化器估错了。
一个百万级数据表的实际案例
假设users拥有100万条记录,经常执行:
SELECT *
FROM users
WHERE status = 1
AND create_time > '2025-01-01';
第一步应该查看执行计划:
EXPLAIN
SELECT *
FROM users
WHERE status = 1
AND create_time > '2025-01-01';
如果查询需要处理大量记录,而现有索引无法有效降低访问成本,可以考虑:
CREATE INDEX idx_status_time
ON users(status, create_time);
然后重新执行EXPLAIN。
但不能因为创建了索引,就要求key必须显示这个索引。如果优化器判断目标数据占表中很大比例,或者其他访问路径成本更低,它仍然可能选择其他方案。
更可靠的验证方法是继续使用EXPLAIN ANALYZE,对优化前后的实际执行时间、实际读取行数以及执行计划进行比较。
大数据量分页也需要观察执行计划
下面这种分页方式:
SELECT *
FROM users
ORDER BY id
LIMIT 1000000, 50;
随着OFFSET增加,数据库可能需要扫描或跳过大量记录后才能取得最后50行。
如果业务允许,可以使用基于上一页最后一个键值的分页方式:
SELECT *
FROM users
WHERE id > 1000000
ORDER BY id
LIMIT 50;
这种Keyset Pagination通常能够避免随着页码增加而不断跳过大量记录。
但前提是排序字段和查询条件存在适合的索引,并且业务能够接受这种分页方式。
不要把分区表当成解决全表扫描的万能工具
对于大型表,分区可能非常有价值,尤其是按时间等明确分区键进行数据生命周期管理和分区裁剪的场景。
但“表超过100万行就应该分区”并没有通用依据。
一个拥有数千万行、查询模式非常简单且索引设计合理的表,不一定需要分区;一个数据量没有那么大的表,如果存在明确的时间范围查询和数据归档需求,也可能适合分区。
是否使用分区,应根据查询模式、数据生命周期、维护需求和实际执行计划决定。
现代MySQL性能优化还需要关注系统资源
SQL本身只是数据库性能的一部分。
对于InnoDB系统,还需要观察Buffer Pool命中情况、磁盘I/O、CPU、内存压力、锁等待、事务持续时间以及并发连接数。
例如,一个看似简单的索引查询,如果返回大量数据或者产生大量回表读取,同样可能造成较高I/O。
如果查询等待的是行锁,那么继续优化索引可能并不能直接解决当前问题。
因此,性能诊断不能只停留在EXPLAIN,而应该把执行计划、慢查询日志、数据库状态以及操作系统资源结合起来分析。
慢查询日志和统计信息
生产环境可以使用MySQL Slow Query Log记录超过设定阈值的查询,再结合EXPLAIN和EXPLAIN ANALYZE逐个分析。
当表的数据分布发生明显变化后,还需要关注优化器统计信息是否准确。必要时可以使用:
ANALYZE TABLE users;
更新相关统计信息。
MySQL 8.x已经移除了Query Cache,因此不应该再把:
SELECT SQL_CACHE ...
作为现代MySQL查询优化方案。
同样,SHOW PROFILES也不是当前MySQL性能诊断应该依赖的主要工具。现代排查更应该围绕EXPLAIN、EXPLAIN ANALYZE、Performance Schema、慢查询日志以及系统和InnoDB运行状态展开。
全表扫描真正应该怎么看
全表扫描并不是看到ALL就立即创建索引。
正确的判断方法应该是先确认:扫描了多少数据、返回多少数据、查询执行多久、是否存在可用索引、优化器为什么选择当前访问路径,以及这个执行计划在真实负载下是否造成了问题。
对于小表,全表扫描可能是最合理的选择;对于需要返回大部分数据的分析查询,全表扫描也可能比索引访问更有效;真正危险的是大型表上的高频查询反复进行大量无意义的数据读取。
因此,EXPLAIN的意义并不是寻找一个“绝对正确”的执行计划,而是帮助开发人员理解MySQL为什么选择当前路径,再结合真实数据量、选择性、I/O和业务访问模式判断是否需要改变。
数据库性能优化最终不是“消灭全表扫描”,而是让查询访问路径与实际数据分布和业务需求相匹配。索引、组合索引、覆盖索引、分页优化、统计信息和数据库硬件都只是工具,真正有效的优化必须建立在执行计划和实际运行数据之上。
MySQL 全表扫描意味着什么?从 EXPLAIN 认识数据库性能问题
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP