去年冬天接手一个订单管理后台,客户的第一句话是"点开订单列表要转三秒"。服务器配置不算低——8 核 16G,MySQL 单机,订单表四百二十万行。运维的第一反应是加内存、调参数、把能加的索引都加上,折腾了两天,三秒变成了两秒八。

真正解决问题的是另一条路:看执行计划、删掉两个没用的索引、补一个联合索引、改三条 SQL 的写法。最终响应时间从 3200 毫秒降到 78 毫秒,硬件一行没动,业务代码只改了六个地方。

这篇文章把这套排查方法完整写下来:慢查询该从哪里入手、执行计划里哪几个字段真正决定成败、索引应该怎么排序、哪些看起来无害的写法会让索引直接失效,以及上线之后怎么让慢查询不再悄悄长回来。适合手里有几十万到几百万行数据、正在被"页面越来越慢"困扰的开发同学。

先给结论:绝大多数"系统变慢"不是硬件问题,而是索引和数据访问方式的问题。优化的顺序永远是:先定位到具体 SQL,再看执行计划,再改索引或改写法,最后才考虑加机器。顺序颠倒,钱花了、问题还在。

一、慢查询从哪里来:先定位,再优化

没有定位就动手,是性能优化里最常见的浪费。数据库慢有两个完全不同的来源,处理方式截然相反:

  • 单条 SQL 慢:某条语句扫了几十万行,占满了 IO。特征是慢查询日志里反复出现同一条 SQL。
  • SQL 都不慢,但架不住多:单条 20 毫秒看似正常,一秒执行两千次就把 CPU 打满。特征是 QPS 高、单条耗时正常、连接数堆积。

区分这两类只需要三步。第一步打开慢查询日志,把阈值设成 1 秒,跑一天:

SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = ON;

-- 排查完记得关掉,not_using_indexes 会写爆磁盘

第二步,把日志里出现次数最多的前十条 SQL 按"总耗时 = 单次耗时 × 执行次数"排序。这一步经常有意外收获:真正拖垮系统的往往不是那条最慢的报表 SQL,而是某个高频接口里一条看起来只有 30 毫秒的查询。

第三步,拿真实参数在测试库上 EXPLAIN 一遍。注意要用真实参数——参数不同,优化器可能选择完全不同的执行路径,用 WHERE id = 1 测出来的计划和用真实时间范围测出来的计划可以差出两个数量级。

现象常见成因优先动作
列表页加载慢,数据量越大越慢缺少联合索引,全表扫描按查询条件顺序建联合索引
只有某一类筛选条件慢索引列上做了函数或类型转换改写 SQL,让索引列"裸奔"
翻到后面几页明显变慢LIMIT 深分页改游标分页或延迟关联
白天正常,整点卡顿定时统计任务与业务抢资源错峰执行或预聚合
数据量没变却越来越慢索引碎片、统计信息过期ANALYZE TABLE、重建索引

二、读懂 EXPLAIN:五个字段就够了

执行计划有十几列,日常排查真正需要盯的只有五个:type、key、rows、filtered、Extra。

type:访问类型,决定量级

从好到坏大致是:const → eq_ref → ref → range → index → ALL。看到 ALL 就是全表扫描,看到 index 是全索引扫描——这两个都意味着"行数有多少就扫多少",在百万级表上基本等于事故。

key 与 rows:索引有没有用上、用了多少行

key 为空说明根本没走索引。rows 是优化器估算的扫描行数,注意是估算——它基于统计信息,可能偏差很大,但数量级通常可信。如果 rows 是八十万而结果只有二十条,那索引设计一定有问题。

Extra:隐藏问题都写在这里

  • Using index:好信号,覆盖索引,不需要回表。
  • Using where:拿到索引后又做了一次过滤,说明索引没完全命中条件。
  • Using filesort:额外排序,数据量大时是主要瓶颈,通常意味着 ORDER BY 的列不在索引顺序里。
  • Using temporary:用了临时表,常见于 GROUP BY 与 DISTINCT。

判断标准很简单:一个理想的查询,type 应该在 ref 或 range,Extra 里最好只有 Using index,不该出现 filesort 与 temporary。

三、索引设计的四条硬规则

规则一:联合索引的列顺序,按"等值在前、范围在后"排

最左前缀原则大家都知道,但真正落地时容易排错。正确的排序逻辑是:先把所有等值条件放前面,再放范围条件,最后放排序字段(如果排序能借上索引,filesort 就消失了)。

-- 查询:某门店、某状态下的订单,按创建时间倒序翻页
SELECT id, order_no, amount, created_at
FROM orders
WHERE store_id = 12 AND status = 2
ORDER BY created_at DESC
LIMIT 20;

-- 索引按 等值 → 等值 → 排序 排列
ALTER TABLE orders ADD INDEX idx_store_status_time (store_id, status, created_at);

很多人会把 created_at 放第一位"因为要排序",结果 store_id 用不上索引,直接退化成全表扫描加排序。顺序错了,索引等于白建。

规则二:能覆盖就不要回表

二级索引的叶子节点存的是主键值。SELECT * 会让数据库先查索引拿到主键,再回主键索引取整行——这叫回表。数据量大、命中行多时,回表的随机 IO 比顺序扫描还慢。把查询字段收敛到索引里,让 Extra 出现 Using index,是最省力的提速手段。

规则三:索引列上不要做运算、不要套函数

-- 索引失效:created_at 被函数包住了
WHERE DATE(created_at) = '2026-10-01'

-- 改写为范围查询,索引可用
WHERE created_at >= '2026-10-01 00:00:00'
  AND created_at <  '2026-10-02 00:00:00'

同理,WHERE amount + 10 > 100、WHERE LEFT(phone, 3) = '138'、WHERE id - 1 = 100 都会让索引失效。原则只有一句:等号左边的列保持"裸奔"。

规则四:索引不是越多越好

每个索引都是一份额外的写入成本。见过一张表建了十一个索引,写入吞吐只有同规格表的三分之一,而真正被用到的只有三个。定期用 sys.schema_unused_indexes 或 performance_schema 找出从未命中的索引,该删就删。

经验值:单表索引数量控制在 5 个以内,联合索引优先于多个单列索引。一张表如果超过 8 个索引,多半是历史遗留没清理。

四、三个真实坑:隐式转换、回表、深分页

坑一:字符串字段用数字去查,索引直接失效

订单号字段是 varchar(32),代码里传了个数字进去。MySQL 会把字段隐式转成数字比较,等价于对索引列做了函数运算,索引彻底失效。这类问题最阴险的地方在于"结果是对的",只是慢——所以能活很久不被发现。

-- 索引失效
WHERE order_no = 202610280001

-- 正确写法
WHERE order_no = '202610280001'

排查方法:字段类型和参数类型必须严格一致。尤其注意小程序端和 Java 端传参时 JSON 里数字与字符串的差异,这类问题在联调阶段几乎不会暴露,上线后数据量一上来才发作。

坑二:SELECT * 引发的回表风暴

一个列表接口 SELECT * 查出四十个字段,其中三十五个前端根本不用。优化后只取六个字段并建成覆盖索引,同一查询从 640 毫秒降到 45 毫秒。改动的代码量不到十行。

坑三:LIMIT 100000, 20 的深分页

LIMIT offset, size 的语义是先扫描 offset + size 行再丢弃前 offset 行。翻到第一万页时,数据库要白扫二十万行。两种解法:

-- 方案一:延迟关联,先在索引里定位主键,再回表取数据
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders WHERE store_id = 12 ORDER BY created_at DESC LIMIT 100000, 20) t
  ON o.id = t.id;

-- 方案二:游标分页,把 offset 变成条件(推荐,O(1) 定位)
SELECT id, order_no, amount, created_at
FROM orders
WHERE store_id = 12 AND created_at < '2026-10-20 10:00:00'
ORDER BY created_at DESC
LIMIT 20;

后台管理系统适合方案一,改造成本低;面向用户的无限下拉列表适合方案二,代价是不能跳页——但移动端本来也不需要跳页。

五、统计报表:别让实时查询扛分析负载

另一类慢查询来自报表。营业额按天汇总、会员增长曲线、商品销量排行,这些查询天然要扫大量行,加索引也救不了——SUM() 必须读数据。硬优化的结果通常是把业务库拖垮。

务实的做法是预聚合:建一张按天、按门店汇总的统计表,每天凌晨跑一次定时任务写入。报表查汇总表,几十行数据,毫秒级返回。代价是数据有延迟,但对经营分析来说,"昨天的数据今天早上看到"完全可以接受。

  • 汇总粒度按业务需要定:日汇总够用就不要做小时汇总。
  • 定时任务放在业务低峰,并且限制单次执行时长,避免和早高峰撞车。
  • 保留明细表的原始数据,汇总表可重算——出错了能重建,这是兜底能力。

六、把慢查询变成可运营的指标

优化是一次性的,退化是持续的。数据从四百万涨到四千万,今天完美的索引明天就可能不够用。所以最后一件事是把治理机制建起来:

机制具体做法频率
慢查询监控阈值 500 毫秒,超过即告警,记录 SQL 指纹去重实时
慢查询复盘按总耗时排序取 Top 10,逐条给出处理结论每周
索引体检查未使用索引、重复索引、区分度过低的索引每月
SQL 上线评审新增查询必须附 EXPLAIN 结果,type=ALL 不允许上线每次迭代

其中投入产出比最高的是最后一条。在代码评审阶段拦住一个全表扫描,成本是五分钟;等它在生产环境跑三个月再回头治理,成本是三天,还要承担一次线上变更的风险。

一句话总结:慢查询治理不靠灵感,靠流程。先看执行计划再动手,索引按"等值在前、范围在后、排序收尾"排列,让查询字段尽量被索引覆盖,把深分页换成游标,把报表负载挪到预聚合表——这五件事做到位,绝大多数中小项目的数据库性能问题都能在不动硬件的前提下解决。

性能问题从来不是"服务器不行",而是"我们没告诉数据库该怎么找数据"。