在后端开发中,接口响应慢并不一定是代码逻辑的问题。很多时候,真正拖慢系统的是数据库查询。

一个接口可能只写了几行代码,但背后执行的 SQL 如果没有走索引,或者索引设计不合理,就可能让数据库扫描大量数据,最终导致接口响应时间从几十毫秒变成几秒甚至十几秒。

慢查询通常会带来以下问题:

  • 接口响应时间变长;

  • 数据库 CPU 占用升高;

  • 连接池被占满;

  • 业务高峰期请求堆积;

  • 用户体验明显下降;

  • 严重时可能拖垮整个服务。

本文将从实战角度出发,围绕 MySQL 慢查询优化展开,重点讲清楚:

  1. 什么是慢查询;

  2. 如何定位慢 SQL;

  3. 如何使用 EXPLAIN 分析执行计划;

  4. 索引为什么能提升查询性能;

  5. 联合索引如何设计;

  6. 哪些写法会导致索引失效;

  7. 慢查询优化的常见思路。


一、什么是慢查询?

慢查询,简单来说,就是执行时间超过预期的 SQL。

在 MySQL 中,可以通过慢查询日志记录执行较慢的 SQL。

比如系统中有一个查询用户订单的接口:

SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;

如果 orders 表只有几千条数据,查询可能非常快。

但如果 orders 表已经有几千万条数据,并且没有合适的索引,这条 SQL 就可能变成慢查询。

数据库需要扫描大量记录,再进行排序,最后取出前 20 条数据。

对于线上系统来说,一条慢 SQL 的影响不只是“这一次查询慢”,更严重的是它会占用数据库资源,影响其他正常请求。


二、开启慢查询日志

在 MySQL 中,可以通过以下命令查看慢查询日志是否开启:

SHOW VARIABLES LIKE 'slow_query_log';

查看慢查询时间阈值:

SHOW VARIABLES LIKE 'long_query_time';

如果 long_query_time 设置为 1,表示执行时间超过 1 秒的 SQL 会被记录为慢查询。

可以临时开启慢查询日志:

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

查看慢查询日志文件位置:

SHOW VARIABLES LIKE 'slow_query_log_file';

需要注意的是,线上环境修改这些参数要谨慎,最好结合实际运维规范进行配置。


三、准备测试表

为了方便说明,假设有一张订单表:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    order_no VARCHAR(64) NOT NULL,
    status TINYINT NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);

常见查询场景包括:

-- 查询某个用户的订单列表
SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;

-- 查询某个状态下的订单
SELECT *
FROM orders
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20;

-- 根据订单号查询订单详情
SELECT *
FROM orders
WHERE order_no = '202605220001';

-- 查询某个用户某种状态的订单
SELECT *
FROM orders
WHERE user_id = 10001
  AND status = 1
ORDER BY created_at DESC
LIMIT 20;

这些 SQL 看起来都很简单,但如果索引设计不合理,数据量变大后都会变慢。


四、使用 EXPLAIN 分析执行计划

优化 SQL 前,不要凭感觉猜测。

正确做法是先使用 EXPLAIN 查看执行计划。

例如:

EXPLAIN
SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;

执行结果中常见字段包括:

id
select_type
table
type
possible_keys
key
key_len
ref
rows
Extra

其中最关键的是以下几个字段。

1. type

type 表示访问类型,常见值从好到差大致如下:

const
eq_ref
ref
range
index
ALL

如果看到 ALL,通常表示全表扫描,需要重点关注。

例如:

type: ALL
rows: 5000000

这说明 MySQL 可能要扫描 500 万行数据,查询性能通常不会好。

2. key

key 表示实际使用的索引。

如果 keyNULL,说明没有使用索引。

3. rows

rows 表示 MySQL 预计需要扫描的行数。

这个值越大,查询成本通常越高。

4. Extra

Extra 中可能出现一些重要信息。

常见内容包括:

Using where
Using index
Using filesort
Using temporary

其中需要特别注意:

Using filesort
Using temporary

这通常意味着排序或临时表成本较高,可能需要优化索引或 SQL。


五、给 user_id 添加索引

对于下面这条 SQL:

SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;

如果没有索引,数据库需要扫描整张表。

可以先添加一个普通索引:

CREATE INDEX idx_user_id ON orders(user_id);

再次执行:

EXPLAIN
SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;

此时可能看到:

type: ref
key: idx_user_id
rows: 1000
Extra: Using where; Using filesort

这说明 MySQL 使用了 user_id 索引,扫描行数明显减少。

但仍然可能出现 Using filesort

原因是索引只帮助数据库快速找到了 user_id = 10001 的数据,但这些数据仍然需要按照 created_at 排序。

如果某个用户有大量订单,排序成本仍然可能比较高。


六、使用联合索引优化排序

针对这个查询:

SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;

更合适的索引是:

CREATE INDEX idx_user_created ON orders(user_id, created_at);

这个联合索引的含义是:

先按 user_id 排序
在相同 user_id 内,再按 created_at 排序

这样,MySQL 可以先定位到某个用户的订单范围,然后直接按照索引顺序读取数据。

如果执行计划中不再出现 Using filesort,说明排序成本得到了优化。

更进一步,如果业务明确要求倒序查询,也可以创建降序索引:

CREATE INDEX idx_user_created_desc ON orders(user_id, created_at DESC);

不过是否需要显式创建降序索引,要结合 MySQL 版本和实际执行计划判断。


七、联合索引的最左前缀原则

联合索引有一个非常重要的规则:最左前缀原则。

例如创建索引:

CREATE INDEX idx_user_status_created
ON orders(user_id, status, created_at);

这个索引可以支持以下查询:

WHERE user_id = 10001
WHERE user_id = 10001 AND status = 1
WHERE user_id = 10001 AND status = 1
ORDER BY created_at DESC

但不能很好支持:

WHERE status = 1

因为 status 不是联合索引的最左列。

联合索引可以理解为一本多级排序的字典:

先按 user_id 排
user_id 相同,再按 status 排
status 相同,再按 created_at 排

如果查询条件跳过了最左边的 user_id,后面的索引列就很难被高效利用。


八、索引列顺序如何设计?

联合索引不是把查询字段随便放进去就可以。

索引列顺序通常要结合以下因素设计:

等值条件
范围条件
排序字段
字段区分度
查询频率

例如有一条常见 SQL:

SELECT *
FROM orders
WHERE user_id = 10001
  AND status = 1
ORDER BY created_at DESC
LIMIT 20;

可以考虑索引:

CREATE INDEX idx_user_status_created
ON orders(user_id, status, created_at);

原因是:

  • user_id 是等值条件;

  • status 是等值条件;

  • created_at 用于排序;

  • 查询需要按照时间取最新记录。

如果换成:

CREATE INDEX idx_created_user_status
ON orders(created_at, user_id, status);

通常效果就不如前者。

因为查询首先要根据 user_idstatus 过滤,如果索引以 created_at 开头,可能无法高效定位目标数据范围。


九、范围查询对联合索引的影响

假设有如下索引:

CREATE INDEX idx_user_created_status
ON orders(user_id, created_at, status);

查询:

SELECT *
FROM orders
WHERE user_id = 10001
  AND created_at >= '2026-05-01'
  AND status = 1;

这里 created_at 是范围查询。

在联合索引中,范围查询之后的字段,通常无法继续充分利用索引进行精确过滤。

也就是说,status 的索引利用效果可能受到影响。

所以如果查询更常见的是:

WHERE user_id = ?
  AND status = ?
  AND created_at >= ?

那么索引顺序更适合设计为:

CREATE INDEX idx_user_status_created
ON orders(user_id, status, created_at);

一般经验是:

等值条件字段尽量放前面
范围查询字段尽量放后面
排序字段结合查询条件设计

十、覆盖索引:减少回表

假设有查询:

SELECT id, order_no, created_at
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;

如果创建索引:

CREATE INDEX idx_user_created_order
ON orders(user_id, created_at, order_no);

查询所需字段都能从索引中拿到,不需要再回到主键索引查询完整行数据。

这种情况称为覆盖索引。

执行计划的 Extra 中可能会出现:

Using index

覆盖索引的优点是减少回表,提高查询性能。

但需要注意,覆盖索引不是字段越多越好。

如果索引包含太多列,会带来以下问题:

  • 占用更多磁盘空间;

  • 插入和更新成本上升;

  • 索引维护成本变高;

  • 缓存命中率可能下降。

所以覆盖索引适合高频、核心、字段较少的查询场景。


十一、哪些写法会导致索引失效?

1. 对索引列使用函数

错误示例:

SELECT *
FROM orders
WHERE DATE(created_at) = '2026-05-22';

这里对 created_at 使用了 DATE() 函数,可能导致索引无法正常使用。

更推荐写成范围查询:

SELECT *
FROM orders
WHERE created_at >= '2026-05-22 00:00:00'
  AND created_at < '2026-05-23 00:00:00';

2. 对索引列进行计算

错误示例:

SELECT *
FROM orders
WHERE amount + 10 > 100;

更推荐写成:

SELECT *
FROM orders
WHERE amount > 90;

3. LIKE 前缀使用通配符

可以使用索引的情况:

SELECT *
FROM orders
WHERE order_no LIKE '202605%';

通常难以使用普通 B+Tree 索引的情况:

SELECT *
FROM orders
WHERE order_no LIKE '%0001';

因为 B+Tree 索引是按前缀有序的,前置通配符会破坏索引的有序查找能力。

4. 隐式类型转换

假设 order_no 是字符串类型:

order_no VARCHAR(64)

错误示例:

SELECT *
FROM orders
WHERE order_no = 202605220001;

更推荐:

SELECT *
FROM orders
WHERE order_no = '202605220001';

字段类型和查询值类型不一致时,可能发生隐式类型转换,影响索引使用。

5. OR 条件使用不当

例如:

SELECT *
FROM orders
WHERE user_id = 10001
   OR status = 1;

如果两个字段没有合适索引,或者优化器判断成本较高,可能导致全表扫描。

这类 SQL 可以根据场景改写为 UNION

SELECT *
FROM orders
WHERE user_id = 10001

UNION

SELECT *
FROM orders
WHERE status = 1;

但是否一定更快,需要结合执行计划和数据量测试。


十二、不要滥用 SELECT *

很多慢查询中都存在一个问题:

SELECT *

SELECT * 会带来几个问题:

  • 读取不必要的字段;

  • 增加网络传输量;

  • 更容易触发回表;

  • 表结构变化可能影响接口稳定性;

  • 不利于覆盖索引优化。

更推荐只查询需要的字段:

SELECT id, order_no, status, amount, created_at
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;

尤其是表中包含大字段时,例如:

TEXT
LONGTEXT
BLOB
JSON

更应该避免在列表查询中直接 SELECT *


十三、分页查询优化

很多系统中会使用如下分页:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 100000, 20;

这种深分页查询非常容易变慢。

原因是 MySQL 需要先扫描并跳过前 100000 条数据,然后再取 20 条。

更好的方式是使用游标分页。

例如第一页:

SELECT id, order_no, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;

下一页带上上一页最后一条记录的时间和 ID:

SELECT id, order_no, created_at
FROM orders
WHERE (created_at < '2026-05-22 10:00:00')
   OR (created_at = '2026-05-22 10:00:00' AND id < 123456)
ORDER BY created_at DESC, id DESC
LIMIT 20;

同时创建索引:

CREATE INDEX idx_created_id
ON orders(created_at, id);

游标分页的优点是性能更稳定,适合信息流、订单列表、日志列表等场景。

缺点是不适合直接跳转到第 N 页。


十四、ORDER BY 优化

排序是慢查询中很常见的性能消耗点。

例如:

SELECT *
FROM orders
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20;

可以考虑索引:

CREATE INDEX idx_status_created
ON orders(status, created_at);

这样 MySQL 可以先定位到 status = 1 的数据,再按 created_at 顺序读取。

如果没有合适索引,执行计划中可能出现:

Using filesort

这并不一定代表一定很慢,但当数据量较大时,需要重点关注。

常见优化方向包括:

让 WHERE 条件和 ORDER BY 字段组成联合索引
减少排序数据量
避免对大结果集排序
分页时尽量使用索引顺序读取

十五、COUNT 查询优化

很多列表接口会同时查询数据总数:

SELECT COUNT(*)
FROM orders
WHERE status = 1;

当数据量很大时,COUNT(*) 也可能变慢。

优化思路包括:

1. 给过滤字段加索引

CREATE INDEX idx_status ON orders(status);

如果查询条件能走索引,统计成本通常会降低。

2. 避免高频实时精确统计

对于一些列表页,不一定每次都需要实时精确总数。

可以考虑:

缓存统计结果
异步更新计数
只展示是否有下一页
限制最大可翻页范围

3. 业务上接受近似值

在一些非强一致场景中,可以使用近似统计,而不是每次执行精确 COUNT(*)


十六、索引不是越多越好

索引可以提升查询性能,但也会带来成本。

每增加一个索引,都会带来以下影响:

  • 插入数据时需要维护索引;

  • 更新索引列时需要调整索引结构;

  • 删除数据时也要维护索引;

  • 索引会占用额外磁盘空间;

  • 优化器需要在多个索引中选择执行路径。

所以索引设计需要克制。

不建议看到查询慢就盲目加索引。

更合理的流程是:

定位慢 SQL
分析执行计划
确认扫描行数和索引使用情况
结合业务查询频率设计索引
上线前测试效果
持续观察慢查询日志

十七、慢查询优化的一般流程

实际项目中,可以按照以下步骤排查慢查询:

1. 从慢查询日志中找到高频慢 SQL
2. 查看 SQL 执行时间和扫描行数
3. 使用 EXPLAIN 分析执行计划
4. 判断是否使用了合适索引
5. 检查是否出现 Using filesort 或 Using temporary
6. 分析 WHERE、ORDER BY、GROUP BY 字段
7. 设计或调整联合索引
8. 改写不合理 SQL
9. 使用真实数据量测试
10. 上线后持续观察

这个流程比单纯“加索引”更可靠。

因为慢查询的原因可能不止一个,可能是索引问题,也可能是 SQL 写法、数据分布、分页方式、排序方式、表结构设计等问题。


十八、一个完整优化案例

假设慢 SQL 如下:

SELECT *
FROM orders
WHERE user_id = 10001
  AND status = 1
ORDER BY created_at DESC
LIMIT 20;

执行计划显示:

type: ALL
key: NULL
rows: 8000000
Extra: Using where; Using filesort

这说明:

没有使用索引
扫描了大量数据
还进行了额外排序

第一步,避免 SELECT *

SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = 10001
  AND status = 1
ORDER BY created_at DESC
LIMIT 20;

第二步,创建联合索引:

CREATE INDEX idx_user_status_created
ON orders(user_id, status, created_at);

再次查看执行计划,理想情况下可能变成:

type: ref
key: idx_user_status_created
rows: 50
Extra: Using where

如果查询字段能被索引覆盖,还可以进一步调整为:

CREATE INDEX idx_user_status_created_cover
ON orders(user_id, status, created_at, order_no, amount);

但是否需要覆盖索引,要看查询频率和写入成本,不能盲目增加。


十九、总结

MySQL 慢查询优化不是简单地给字段加索引,而是一个系统性的分析过程。

核心思路可以概括为:

先定位慢 SQL
再分析执行计划
再判断索引是否合理
最后结合业务场景优化 SQL 和表结构

在实际开发中,最常见的优化点包括:

避免全表扫描
合理设计联合索引
遵守最左前缀原则
避免索引列上使用函数或计算
减少 SELECT *
优化深分页
减少不必要的排序
谨慎使用 COUNT
避免滥加索引

对于大多数业务系统来说,数据库性能问题往往不是一开始就出现的,而是在数据量增长后逐渐暴露出来。

因此,写 SQL 时不能只考虑“功能能不能跑”,还要考虑:

数据量变大后还能不能跑得快
查询是否能走索引
排序是否可控
分页是否稳定
索引维护成本是否合理

一个优秀的后端开发者,不仅要会写业务代码,也要能看懂执行计划,理解索引结构,并根据真实业务场景设计可持续的数据库查询方案。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐