在后端开发中,接口响应慢并不一定是代码逻辑的问题。很多时候,真正拖慢系统的是数据库查询。
一个接口可能只写了几行代码,但背后执行的 SQL 如果没有走索引,或者索引设计不合理,就可能让数据库扫描大量数据,最终导致接口响应时间从几十毫秒变成几秒甚至十几秒。
慢查询通常会带来以下问题:
接口响应时间变长;
数据库 CPU 占用升高;
连接池被占满;
业务高峰期请求堆积;
用户体验明显下降;
严重时可能拖垮整个服务。
本文将从实战角度出发,围绕 MySQL 慢查询优化展开,重点讲清楚:
什么是慢查询;
如何定位慢 SQL;
如何使用 EXPLAIN 分析执行计划;
索引为什么能提升查询性能;
联合索引如何设计;
哪些写法会导致索引失效;
慢查询优化的常见思路。
一、什么是慢查询?
慢查询,简单来说,就是执行时间超过预期的 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表示实际使用的索引。
如果key为NULL,说明没有使用索引。
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 = 10001WHERE user_id = 10001 AND status = 1WHERE 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_id和status过滤,如果索引以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 时不能只考虑“功能能不能跑”,还要考虑:
数据量变大后还能不能跑得快 查询是否能走索引 排序是否可控 分页是否稳定 索引维护成本是否合理一个优秀的后端开发者,不仅要会写业务代码,也要能看懂执行计划,理解索引结构,并根据真实业务场景设计可持续的数据库查询方案。