在 SQL 查询中,排序操作是否高效,往往取决于优化器能否利用索引天然的有序性。很多人认为只要在 ORDER BY 后面的列上建了索引,排序就不会产生额外开销,但实际上索引能否服务于排序,取决于查询条件、索引列顺序、排序方向和表达式等多种因素。如果无法利用索引顺序,MySQL 等数据库会启动文件排序 filesort,将数据读到内存或磁盘进行额外排序,数据量一大性能会明显下降。下面围绕排序与索引的匹配规则展开。

一、索引有序性如何支撑排序
要理解排序与索引的关系,先要回到 B+ 树的结构。以 InnoDB 为例,一个普通二级索引会按照索引列的值从小到大组织叶子节点,同一层的页之间通过双向链表连接。也就是说,索引本身就是一种有序结构。当查询需要返回的数据恰好与索引顺序一致时,优化器可以沿着叶子页的链表正向或反向扫描,直接拿到排好序的结果,省去额外的排序动作。
这种能力有一个前提:查询过滤条件必须能“锁定”索引顺序。比如表 orders 中包含 id、user_id、order_time、amount 等字段,并建有联合索引 idx_user_time(user_id, order_time)。当查询写成 WHERE user_id = 100 ORDER BY order_time 时,索引先按 user_id 排序,再按 order_time 排序。由于 user_id 是等值条件,所有满足条件的行在索引中连续存放,并且 order_time 已经有序,因此优化器可以直接读取索引返回结果,不需要再执行 filesort。
CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT NOT NULL, order_time DATETIME NOT NULL, amount DECIMAL(10,2), KEY idx_user_time (user_id, order_time) ); SELECT id, amount FROM orders WHERE user_id = 100 ORDER BY order_time;
反过来,如果排序字段换成了 amount,即使 user_id 上有索引,也无法为 amount 提供有序性。因为这条索引只保证相同的 user_id 下 order_time 有序,并不保证 amount 有序。此时优化器只能扫描满足条件的数据,再单独执行一次排序。数据量较小时可能看不出差异,但一旦达到几十万行,filesort 就会明显拖慢响应时间。
二、排序不走索引的典型场景
索引排序失效的原因通常不是索引不存在,而是查询写法与索引顺序不匹配。最常见的一类场景是:联合索引中排序列没有作为索引的连续前缀。例如索引为 idx_a_b_c(a,b,c),查询条件是 WHERE a = 1 ORDER BY c。表面上看排序列 c 在索引里,但由于中间跳过了 b,在 a 相同的情况下,b 的值并不相同,c 也不会全局有序。优化器无法从这条索引中直接获得按 c 排序的结果,只能退化为 filesort。
第二个常见问题是排序方向不一致。经典联合索引默认使用升序存储,如果查询写成 ORDER BY a ASC, b DESC,一升一降会让优化器很难直接顺着索引扫描。MySQL 8.0 之前基本只能额外排序,8.0 虽然支持降序索引,但也需要建索引时显式声明 DESC。如果业务查询固定使用 create_time DESC,但索引仍然按默认升序建立,优化器可以选择反向扫描索引,这通常也能避免 filesort;但如果同时混合了不同方向的排序列,问题就会变得复杂。
对排序列使用函数或表达式是另一类高频坑。即使字段本身有索引,一旦写在 ORDER BY 里的不是原始列,索引顺序就无法对应。例如 ORDER BY YEAR(order_time) 或 ORDER BY amount + 0,优化器没有办法根据 order_time 的有序性推导出 YEAR(order_time) 的有序性,只能把数据取出来重新计算并排序。类似的还有隐式类型转换、排序时使用 COLLATE 改变字符集比较规则等。
范围查询中断索引排序也很常见。联合索引 (status, create_time) 中,如果 status 是等值条件,create_time 可以依赖索引顺序;但如果写成 WHERE status > 1 ORDER BY create_time,由于 status 是范围条件,返回的多行中 status 值会发生变化,此时 create_time 在各 status 分组内部虽然有序,但整体来看并不有序。优化器为了给出全局有序的结果,依然需要执行额外排序。
EXPLAIN SELECT id, create_time FROM orders WHERE status > 1 ORDER BY create_time;
上述 SQL 的执行计划中,通常会在 Extra 列看到 Using filesort。这意味着 MySQL 无法借助索引顺序完成排序,需要把数据行读出来,再在排序缓冲区中完成排序。当排序列很长或结果集很大时,排序缓冲区可能不够用,数据会被写入磁盘临时文件,性能会进一步恶化。
三、优化排序查询的实用方法
优化的核心思路是让 ORDER BY 列尽可能落到联合索引的连续后缀上。设计索引时,应把 WHERE 中的等值条件列放在前面,ORDER BY 列紧跟其后。比如查询模式为 WHERE status = ? ORDER BY create_time DESC,就可以建立 (status, create_time) 联合索引。如果业务只查最近创建的数据,MySQL 可以在索引中反向扫描 create_time,从而直接返回有序结果。
覆盖索引也是非常有用的手段。假设查询只需要返回 id 和 create_time,如果这两个字段都包含在索引中,优化器可以通过覆盖索引扫描同时完成过滤、排序和取数,不需要回表。回表会打乱有序扫描的节奏,尤其当二级索引指向的主键值分散在不同数据页时,回表随机 IO 往往会消耗大量资源。因此,如果排序查询频繁出现,可以考虑扩展联合索引,把 SELECT 中的相关列一并加入索引。
ALTER TABLE orders ADD INDEX idx_status_time_id (status, create_time DESC, id); SELECT id, create_time FROM orders WHERE status = 1 ORDER BY create_time DESC;
对于无法直接改写索引的场景,可以尝试延迟关联。先通过索引扫描拿到满足条件的主键集合,并完成排序,再根据排序后的主键回表取出完整数据。这样可以让排序操作在小数据量的主键列表上执行,降低排序缓冲区和临时文件的压力。延迟关联通常适用于结果集较大但需要返回的完整行很少的情况。
另外要注意避免对排序列使用函数和表达式。需要按年或按月排序时,可以在表中增加冗余字段,例如 order_month,并在写入时维护好这个字段,排序直接使用 ORDER BY order_month。如果 MySQL 版本支持生成列,也可以使用生成列加上索引,既能保留原始数据,又能让排序条件命中索引。
四、用 EXPLAIN 验证排序是否走索引
判断一个排序查询是否真正利用了索引,不能只看 SQL 写得多像能走索引,最终要以执行计划为准。MySQL 提供 EXPLAIN 命令,可以展示优化器实际选择的执行方式。重点关注 Extra 列是否出现 Using filesort。如果看到这个标记,说明排序没有通过索引完成,而是触发了额外排序操作。
EXPLAIN SELECT id, amount FROM orders WHERE user_id = 100 ORDER BY order_time;
执行计划中如果没有 Using filesort,并且访问类型是 ref 或 range,同时 key 列指向了联合索引,通常说明索引排序生效。如果 Extra 中出现 Using index,则说明查询使用了覆盖索引,排序和取数都发生在索引内部,性能通常较好。MySQL 8.0 还可能在 Extra 中显示 Backward index scan,表示优化器正在反向扫描索引以满足降序排序。
有时优化器会出于成本考虑放弃索引排序。比如查询条件的选择性很低,扫描的行数非常多,优化器可能认为先扫描再排序比走索引更划算。此时即使索引设计合理,执行计划中也可能出现 filesort。遇到这种情况,可以通过强制使用索引的方式测试对比,但更推荐的方式是结合真实数据分布调整查询或索引,而不是盲目添加 FORCE INDEX,因为成本估算会随着数据量变化而改变。
除了执行计划,还可以使用 SHOW STATUS LIKE 'Sort_merge_passes' 查看排序是否发生过多磁盘合并。这个值如果持续增长,说明排序缓冲区频繁不足,需要适当调大 sort_buffer_size,或者优化 SQL 避免 filesort。调大缓冲区能缓解磁盘排序压力,但不能从根本上解决索引利用问题,最终还是要回到索引与查询的匹配上来。

