慢SQL里的“大量数据排序”,我接下来说的事情,应该是很多后端同学都踩过的坑。它表面上看是数据库慢查询,实际上背后牵涉到索引设计、缓存利用、SQL改写甚至业务逻辑取舍。这篇文章,我想从一次真实的生产事故开始讲起,把排序场景下的慢SQL原理、定位手段和优化方案一次说透,内容偏MySQL,但思路对PostgreSQL、OceanBase这类关系型数据库同样适用。无论你是正在排查线上告警,还是在做SQL评审,这篇文章都值得往下看。
1. 一次真实的生产教训:排序慢不是慢在排序本身
1.1 现象与第一反应
某天线上监控突然报出一条慢SQL,高峰期执行时间飙到5秒多,查询语句长这样:
SELECT order_id, user_id, pay_amount, status, created_at FROM orders WHERE status = 2 ORDER BY pay_amount DESC LIMIT 20;表本身不大,当时数据量才2000多万行,即使过滤出来也有300多万行参与排序。第一反应是“给排序列加索引”。于是直接执行:
ALTER TABLE orders ADD INDEX idx_status_pay_amount (status, pay_amount);以为万事大吉,跑了一遍发现耗时反而更差了,有的请求直接到10秒。执行计划显示走了索引,还用了Using filesort。当时一头雾水,后来静下心把执行计划完整看了一遍,才意识到问题出在索引字段的选择顺序以及回表成本上。
1.2 教训:先看执行计划,再看数据特征
那次事故给我的教训非常直接:在讨论排序优化之前,必须先把执行计划看清楚,把数据分布摸清楚。
上面这个索引没生效,本质原因是status = 2这条件里,符合条件的数据量占了三分之一,优化器估算回表加排序的成本后,选择了全表扫描加文件排序。也就是说,过滤性不够强,加再多索引都可能是负优化。
从那以后,我遇到任何带ORDER BY的慢SQL,都会先问自己这几个问题:
- 排序字段是什么类型?长度多少?
- WHERE条件筛选出来的结果集有多大?
- 需要返回哪些字段?能否全部走覆盖索引?
- 排序方向是升序还是降序?索引能否匹配?
- 分页的
LIMIT offset, sizeoffset是否很大?
把这些问题回答完,基本就能确定优化方向。大多数时候,大量数据排序的慢,不是“排序动作”本身慢,而是为了排序要搬运的数据太多,或者排序完拿到结果后还要回表,这双重成本叠加,才让SQL慢到不能忍。
2. 深入理解:排序发生在哪,为什么这么慢
2.1 文件排序(filesort)到底做了什么
很多开发知道Extra里出现Using filesort就紧张,但真正理解它的人并不多。filesort并不一定表示“在磁盘上排序”,它只是MySQL官方对“无法使用索引排序时,必须额外做一次排序操作”的统一称呼,排序过程既可能在内存中完成,也可能溢出到磁盘。
当你看到Using filesort时,MySQL大致是这样工作的:
- 根据查询条件把满足条件的行读取出来,对于每行记录,提取排序需要的字段和查询返回字段;
- 如果排序字段和返回字段较短,则直接放入
sort buffer(sort_buffer_size指定大小)中; - 如果
sort buffer放不下,就把中间结果分块写到磁盘临时文件中,每个块内部排好序,最后对多个有序块做归并排序; - 排序完成后,如果采用“全字段排序”,直接返回结果;如果采用“rowid排序”,则根据排好序的主键或rowid再回表获取完整行数据。
这里有两种排序模式,值得单独讲讲:
- 全字段排序:把SQL里SELECT需要的所有字段都放进sort buffer。优点是排序完直接返回,不需要回表;缺点是如果字段很多、值很大,buffer很快装满,导致更多磁盘临时文件操作。
- rowid排序:只在sort buffer里放“排序字段”和“主键”,排序完成后再根据主键去聚簇索引查整行。优点是sort buffer可以装载更多行,减少磁盘归并;缺点是需要额外回表,可能产生大量随机I/O。
MySQL到底选哪种模式,通常由max_length_for_sort_data参数和查询字段总长度共同决定。字段总长度超过max_length_for_sort_data(默认1024字节),则倾向于使用rowid排序。
这就是为什么“大量数据排序”场景,会同时出现“磁盘临时文件”和“回表随机读”两种开销。你看到的慢,其实是这两种动作叠加的累积。
2.2 什么时候会用到索引排序,什么时候不会
MySQL使用索引来避免filesort,前提是排序字段满足“索引最左前缀”的规则。最典型的情况:
-- 联合索引 idx_age_salary (age, salary) SELECT * FROM employee WHERE age = 30 ORDER BY salary; -- 这个SQL可以直接用索引完成排序,因为age用到了索引,salary作为第二列正好满足最左前缀。 SELECT * FROM employee WHERE age > 30 ORDER BY salary; -- 这个SQL就不能用索引排序,因为age用了范围查询,后面的salary列无法保证全局有序,只能filesort。除了范围查询导致索引排序失效之外,下面这些情况也会让优化器放弃索引排序:
- ** ORDER BY字段方向不一致,例如索引是
(a ASC, b ASC),SQL是ORDER BY a DESC, b ASC,排序方向冲突(MySQL 8.0对方向处理比旧版好,但不是所有场景都能用上); - **排序字段出现在表达式或函数中,例如
ORDER BY YEAR(create_time); - **排序字段不满足索引最左前缀,例如索引
(age, salary),SQL却ORDER BY salary,前面没有age等值条件; - **强制用不上索引排序的统计信息问题,比如优化器认为全表扫描比走索引更快。
所以,设计“可以避免排序”的索引,核心原则是:让ORDER BY字段紧跟WHERE条件里已经匹配的等值字段后面,且不要跨过一个范围条件。这句话值得反复记。
2.3 内存排序 vs 磁盘排序:sort_buffer_size不是万能药
遇到大量数据排序,很多人第一时间想到调大sort_buffer_size。这确实有效,但效果有限。
sort_buffer_size是MySQL每个排序线程私有的内存区域。如果设置成4MB,排序数据量小于4MB时全部内存排序,快得很;但一旦超过这个值,多出来的数据就需要写临时文件,分段排序后归并。临时文件路径、大小都能通过状态变量Sort_merge_passes看到。
我见过很多人把sort_buffer_size调到64MB甚至更大,期望彻底消灭磁盘排序。但要知道,这个参数是每个连接独占的,高并发下直接导致内存爆炸。假设线上同时有100个排序请求,每个分配64MB,瞬间就是6.4GB内存消耗,这比慢SQL本身还危险。
那么,正确的做法是什么?是多管齐下:
- 优先让排序走索引,完全避免filesort;
- 即使需要filesort,也要减少参与排序的行宽;
- 通过
max_length_for_sort_data引导使用rowid排序,避免全字段占用巨大内存; - 最后才结合并发量,谨慎微调
sort_buffer_size。
我之前优化一个订单报表接口时,调大sort_buffer_size确实把耗时从5秒压到2秒,但并发一高数据库内存告警。后来通过改索引和延迟关联,把SQL压到200毫秒以内,这段经历告诉我:内存参数是止痛药,不是治病根的药。
3. 大量数据排序场景,核心优化套路
3.1 优化1:设计能覆盖排序与过滤的联合索引
这是最根本的优化手段。目标很简单:让排序字段直接走索引顺序,让MySQL连排序算法都不用跑。设计时需要同时覆盖WHERE条件和ORDER BY字段。
举个例子,业务表payment_record有这些字段:
CREATE TABLE payment_record ( id BIGINT NOT NULL AUTO_INCREMENT, merchant_id BIGINT NOT NULL, channel VARCHAR(32) NOT NULL, pay_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_merchant_channel (merchant_id, channel) ) ENGINE=InnoDB;业务场景是:某个商户在某个渠道下,按支付金额排序分页获取记录:
SELECT id, pay_amount FROM payment_record WHERE merchant_id = 1001 AND channel = 'wxpay' ORDER BY pay_amount DESC LIMIT 20;优化前索引是idx_merchant_channel(merchant_id, channel),能满足过滤条件,但排序只能filesort。改进方案是把主查询改成覆盖索引:
ALTER TABLE payment_record ADD INDEX idx_merchant_channel_amount (merchant_id, channel, pay_amount);这个索引能同时服务WHERE和ORDER BY,执行计划里Extra不再出现Using filesort,而是显示Using index condition甚至直接Using index,速度会有数量级的提升。
这里有个小细节:ORDER BY pay_amount DESC,而索引默认是ASC,MySQL 5.7里如果建的是(merchant_id, channel, pay_amount),需要额外处理DESC方向的排序。MySQL 8.0支持降序索引,可以直接建pay_amount DESC。旧版本里,单字段排序反序读取索引即可,成本不算高,但如果多字段方向不一致,就另说了。
3.2 优化2:减少排序字段和行宽度,让一个sort buffer装下更多候选数据
如果无论如何都要filesort,那就尽量让“挤在sort buffer里的每行数据”短一点。sort buffer容量固定,能装下的行数越多,需要写临时文件的概率就越低。
具体操作包括:
- SELECT只保留必要字段,不要把不需要的
TEXT、BLOB、超长VARCHAR字段一下子全选出来。大字段会直接推高max_length_for_sort_data判定,导致MySQL选择rowid排序,后面还得回表。 - 拆分宽表字段,如果业务允许,把很少用到的大字段放到附属表;或者把超长内容放到对象存储,数据库只留引用。
- 排序字段尽量使用数字/日期等定长类型,少用长字符串排序。字符串排序的排序字节序规则也复杂,性能和数字类型不是一个量级。
- 考虑使用
LATERAL DERIVED或子查询来做聚合排序,不过这属于高级玩法,普通场景不推荐。
举一个我实际改过的例子:原业务SQL返回了30多个字段,其中还包括一个remark列,类型VARCHAR(2000)。加上排序字段后,每行数据超过4KB。SQL执行时,338万行候选数据全部进磁盘归并,平均耗时8.7秒。后来把查询改成“先查主键和排序字段,分页后再回表”,一行sort buffer只占几十字节,内存基本能装下,耗时降到了0.9秒。这还没换硬件、没加内存,纯粹是“行宽瘦身”带来的效果。
3.3 优化3:延迟关联,先取主键再回表
大量数据排序场景里,延迟关联(deferred join)是极其常用的一招。它的思想是:
- 先用过滤条件和排序所需字段,获取满足条件的主键ID集合(这一步尽量走覆盖索引);
- 对ID集合排序并分页;
- 拿到最终需要的ID列表后,再通过主键关联原表,获取完整业务字段。
说起来简单,但收益巨大。对比一下:
-- 原方案(全字段排序+回表) SELECT * FROM payment_record WHERE merchant_id = 1001 AND channel = 'wxpay' ORDER BY pay_amount DESC LIMIT 200 OFFSET 100000; -- 延迟关联方案 SELECT * FROM payment_record INNER JOIN ( SELECT id FROM payment_record WHERE merchant_id = 1001 AND channel = 'wxpay' ORDER BY pay_amount DESC LIMIT 200 OFFSET 100000 ) tmp USING(id);子查询tmp里只取id和排序字段,所有数据都能塞进内存排序,甚至走覆盖索引;外层再根据20条ID回表查询,随机I/O只有20次。相比原方案直接把300多万行全字段搬运、排序、再回表,代价天差地别。
注意,MySQL优化器有时候会因为统计信息和成本估算,自动把子查询改成半连接(semi-join),导致延迟关联失效。检查执行计划时,一定要确认子查询里仍然只取主键。必要时可以用STRAIGHT_JOIN或者加上NO_MERGE提示,锁住执行计划,确保延迟关联真正生效。
3.4 优化4:分页排序的深坑与极限优化思路
大量数据排序里,最容易被忽视的陷阱是大偏移量分页。
LIMIT 100000, 20,这个写法看起来很省事,实际上MySQL会先把前面100020行全部排好序,然后丢弃前100000行,只返回最后20行。前面那些数据的工作全部白费。
这类慢SQL的优化思路有几个方向:
方案A:通过“上次读取到的位置”替代大偏移量
如果业务允许,把分页方式从“页码”改成“游标”:
-- 第一页:每页20条,按id降序 SELECT * FROM payment_record WHERE id < 100000 ORDER BY id DESC LIMIT 20; -- 第二页:以上次返回的最小id作为游标 SELECT * FROM payment_record WHERE id < 100050 ORDER BY id DESC LIMIT 20;如果排序字段不是唯一的,可以拼上主键作为游标,确保稳定排序:
WHERE (pay_amount, id) < (100.50, 100000) ORDER BY pay_amount DESC, id DESC LIMIT 20;这种“seek method”的效率极高,因为每次都直接从索引定位到目标位置,不需要扫描无关数据。缺点是不支持传统页码跳转,只能一页一页往下翻。好在绝大多数业务都是连续翻页,能接受。
方案B:延迟关联+覆盖索引组合
即便非要用大偏移量,也可以通过延迟关联把“排序+分页”限制在覆盖索引内部:
SELECT * FROM payment_record INNER JOIN ( SELECT id FROM payment_record WHERE merchant_id = 1001 ORDER BY pay_amount DESC LIMIT 200 OFFSET 100000 ) tmp USING(id);方案C:从业务层禁止深分页
对管理后台、导出任务这类场景,可以直接限制最大页数,或者改用“加载更多”的交互。合理的产品设计能消灭最棘手的SQL,文本优化只是兜底。
4. 实战复盘:一个报表接口从5.2s到180ms
4.1 业务场景与SQL原文
有一次做电商运营数据报表接口,按“大区门店销售额”排序展示某促销期间的数据。原SQL如下:
SELECT o.id, o.shop_id, o.shop_name, o.region, o.sale_amount, o.order_count, o.customer_cnt, o.settlement_status, o.created_at FROM sales_summary o WHERE o.biz_date = '2024-05-10' ORDER BY o.sale_amount DESC LIMIT 100 OFFSET 5000;sales_summary表当时已经积累了1.2亿行,按天拆了分区。biz_date='2024-05-10'分区过滤后,还剩约220万条记录需要排序。执行计划显示分区范围扫描,然后Using filesort,完事还有回表。线上平均5.2秒,报表导出时并发一高,能把主库CPU打满。
4.2 分析与改造过程
第一步,看索引。当时表上只有主键和idx_biz_date(biz_date),排序只能filesort。我尝试加索引:
ALTER TABLE sales_summary ADD INDEX idx_biz_date_saleamount (biz_date, sale_amount DESC);执行计划确实从Using filesort变成了Using index condition,但耗时才降到4秒多,并没有质变。原因是:虽然排序走索引了,但SQL里SELECT了10个字段,这些字段都不在索引里,MySQL需要根据每一行记录的主键回表读取完整数据。220万次随机I/O,才是最大的耗时点。
第二步,改成延迟关联。先把查询拆成两步:
SELECT * FROM sales_summary o INNER JOIN ( SELECT id FROM sales_summary WHERE biz_date = '2024-05-10' ORDER BY sale_amount DESC LIMIT 100 OFFSET 5000 ) tmp ON o.id = tmp.id;这里的子查询只访问idx_biz_date_saleamount,索引里已经包含biz_date、sale_amount、id(主键),所以整个排序+分页过程可以在覆盖索引里完成,不需要回表。子查询取出来的100个ID,再回原表查完整数据,回表次数只有100次。
第三步,进一步删减报告字段。我发现shop_name其实没必要在数据库里存,改成关联维表获取,表里的shop_name字段去掉后,行宽下降,回表成本进一步降低。
改造后的执行计划,子查询显示Using index(覆盖索引),外层回表100行无关痛痒。最终耗时稳定在180ms左右,和之前5.2秒对比,性能提升接近30倍。
4.3 前后对比与方案取舍
| 项目 | 优化前 | 优化后 |
|---|---|---|
| 参与排序的数据量 | 220万行 | 220万行 |
| 排序字段是否走索引 | 否 | 是 |
| 是否回表 | 220万次随机I/O | 100次随机I/O |
| 执行时间 | 5.2s | 0.18s |
| 内存/磁盘压力 | 磁盘大量临时文件 | 内存排序,无临时文件 |
这个案例中,我没有调整任何MySQL参数,也没改分页逻辑,纯靠“联合索引+覆盖索引+延迟关联”就解决了问题。实际优化中,很多问题根本不需要上升到调参层面,SQL本身解决不了,才考虑参数和架构调整。
5. 慢SQL排序场景的排查思路与工具
5.1 从慢日志抓到候选语句
大部分数据库排障都从慢查询日志开始。MySQL里开启慢日志一般这样配置:
# 在my.cnf中配置 slow_query_log = ON slow_query_log_file = /var/log/mysql/mysql-slow.log long_query_time = 2 log_queries_not_using_indexes = OFF注意,log_queries_not_using_indexes建议关掉,否则大量小查询因为没有索引被记进日志,日志会迅速膨胀,反而干扰真实问题。
抓到慢SQL后,不要急着改。先看全貌:
- 这条SQL一天执行几次?每次执行多慢?执行频率高的慢SQL,优先级高;
- 候选集有多大?排序量级是几百行还是百万行?量级决定优化方案的复杂度;
- 数据库负载如何?如果是高峰期集中出现的批量任务,可能就是并发排队放大出来的问题。
5.2 用执行计划快速定位排序问题
执行计划是慢SQL排查的核心。对于排序相关的问题,重点看这几列:
- type:如果出现
ALL或index,说明全表扫描或用索引序遍历全索引,多半有问题; - key:实际用到的索引,是不是预期中的索引;
- rows:估算的扫描行数,和实际数据量对比,判断估算是否离谱;
- Extra:
Using filesort、Using temporary、Using index、Using index condition这些关键字一出现,排序问题就一目了然。
我用EXPLAIN加FORMAT=JSON比较多:
EXPLAIN FORMAT=JSON SELECT ...JSON格式会给出每条操作的成本评估,比如sort_cost、query_cost,可以对比不同改写方案的代价,避免按直觉瞎猜。当Using filesort出现在子查询里时,可以通过JSON看到具体哪一步在排序,方便定向优化。
5.3 优化排序的常见问题速查
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 明明有索引,还是Using filesort | ORDER BY字段不满足最左前缀,或索引字段存在范围条件 | 调整索引字段顺序,把等值条件放在前面 |
| 排序慢且内存暴涨 | sort_buffer_size过大,高并发导致内存爆炸 | 不要盲目调大sort_buffer_size,优先减少参与排序的行数和行宽 |
| 分页越深越慢 | LIMIT offset过大,扫描并丢弃大量已排序记录 | 改游标分页,或延迟关联限制offset深度的代价 |
| 排序结果与期望顺序不一致 | 多字段排序方向与索引定义不一致 | MySQL 8.0使用降序索引,或改写ORDER BY保证与索引方向一致 |
| filesort期间磁盘使用率飙升 | 排序数据量超过sort buffer内存容量 | 减少排序字段、使用覆盖索引、延迟关联,减少临时文件写入 |
| 查询返回字段太多,回表严重 | SELECT *,或返回了大量大字段 | 只返回业务必需字段,或将大字段拆出主表 |
还有两个容易被忽略的小技巧:
- 如果明确知道结果集只需要一条,可以直接在SQL末尾加
LIMIT 1。MySQL在排序时能提前终止部分流程,虽然对filesort来说停止不全,但某些场景下能优化一截。 - 排序字段允许时,尽量与“过滤条件”使用同一索引的最左前缀。例如
WHERE status IN (1,2) ORDER BY created_at DESC,最好建立(status, created_at)索引,并把IN改写成UNION ALL等值条件,让排序更可控。不过IN范围也会影响最左前缀,这里需要具体分析,不能一概而论。
在真实项目里,慢SQL优化不是一锤子买卖。我在一次大促前优化过一个排行榜接口,当时看执行计划、加索引、延迟关联都做了,线上稳定了一段时间。后来业务把过滤条件从“单城市”改成“多城市”,索引的最左前缀直接被多个城市IN条件打断,又重新触发filesort,性能掉回1秒多。最后花了很大力气把多城市拆成子查询再合并排序,才彻底解决问题。
这个案例告诉我:慢SQL排查不能只负责“把当前语句跑快”,还得考虑优化方案对索引结构的依赖,以及未来业务扩展可能带来的变化。提前留出弹性,好过SQL每次都炸在高峰上。