灯下哥谭 灯下哥谭
首页
关于
  • Hermes Agent 平台
  • Claude Code
  • OpenClaw
  • GPU 推理节点运维
  • DeepSeek Harness
  • MySQL 运维知识地图
  • Elasticsearch 运维知识地图
  • Redis 运维知识地图
  • TiDB 体系
  • DBA 常用 SQL 与命令
  • Nginx 运维知识地图
  • Prometheus 监控
  • Docker
  • Systemd
  • Iptables
  • Firewalld
  • Sshd
  • MySQL8 运维 SOP 手册
  • MySQL 实战 45 讲(读书笔记)
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

灯下哥谭

灯还亮着
首页
关于
  • Hermes Agent 平台
  • Claude Code
  • OpenClaw
  • GPU 推理节点运维
  • DeepSeek Harness
  • MySQL 运维知识地图
  • Elasticsearch 运维知识地图
  • Redis 运维知识地图
  • TiDB 体系
  • DBA 常用 SQL 与命令
  • Nginx 运维知识地图
  • Prometheus 监控
  • Docker
  • Systemd
  • Iptables
  • Firewalld
  • Sshd
  • MySQL8 运维 SOP 手册
  • MySQL 实战 45 讲(读书笔记)
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • MySQL

    • MySQL 运维知识地图:从入门配置到高可用排障
    • MySQL8 配置文件 my.cnf 重要参数解读
    • MySQL 导出 CSV 中文乱码:字符集链路从头讲一遍
    • MySQL 角色管理
    • MySQL网络抓包审计
    • MySQL 性能压测:Sysbench 1.0 实战
    • MySQL Router 实现读写分离
    • Gh-ost重建表,清除表碎片率
    • MySQL MGR配合MySQL-router实现innodb-cluster
    • MySQL 快速分析binlog定位问题
    • MySQL执行计划分析
    • DBA常用SQL和命令整理备查
    • 单表数据同步方案选型:为什么不该用 mysqldump 做「实时同步」
    • MySQL的事务隔离级别
    • MySQL存储过程批量生成数据
    • MySQL insert on duplicate key update,replace into , insert ignore的理解
    • MySQL不同字符集之间的区别和选择
    • MySQL为什么有时候会选错索引
    • MySQL死锁问题
    • MySQL使用SQL语句查重去重
    • MySQLdump逻辑备份
    • MySQL 基于 GTID 主从复制:跳过异常事务的正确姿势
    • MySQL8快速克隆插件使用指南
    • MySQL8双1设置保障安全
    • MySQL锁
    • innodb cluster安装
    • OPTIMIZE TABLE 和 ANALYZE TABLE 的区别:用实测数据说话
    • MySQLReplicaSet 安装
    • MySQL 的 Left join、Right join 和 Inner join 的区别
    • ORDER BY 配合 LIMIT 触发的索引选择陷阱
      • 1. 两个索引,优化器只能选一个
      • 2. 数据空洞:这条街一家店都没开
      • 3. 复现与判读
        • 准备:造一张带"空洞日"的表
        • 执行:对比有数据日与空洞日
        • 验证:Extra 字段是唯一可信的告警灯
      • 4. 打开优化器的决策现场:optimizer_trace
        • 4.1 怎么开
        • 4.2 只看三段,其余全部略过
        • 4.3 有数据的那天,trace 有什么不同
      • 5. 五种解法,按推荐顺序
        • 解法一:建覆盖两端的联合索引(首选)
        • 解法二:FORCE INDEX 强制走过滤索引(应急)
        • 解法三:延迟关联,把回表次数压到 N 次
        • 解法四:刷新统计信息(先做,但别指望它兜底)
        • 解法五:关掉 prefer_ordering_index(对症下药)
      • 6. 可复用要点
        • 边界与局限
      • 7. 延伸阅读
  • Redis

  • 高性能KV

  • TiDB

  • Elasticsearch

  • 数据管道

  • 其他数据库

  • 数据库
  • MySQL
灯下哥谭
2026-08-07
目录

ORDER BY 配合 LIMIT 触发的索引选择陷阱原创

# ORDER BY 配合 LIMIT 触发的索引选择陷阱

同一条 SQL,只把 WHERE 里的日期换一天,一次 0.01 秒返回,另一次要 13.10 秒—— 而且两次的返回结果都是 0 条。

版本说明

本文基于 MySQL 8.0 InnoDB(prefer_ordering_index 开关需 8.0.21+)。文中执行计划与 optimizer_trace 片段均为按该场景构造的结构示意,字段名与层级结构真实,用于说明 type / key / Extra 与 trace 三段式的判读方法,非某次具体压测的原始抓取; 行数、代价与耗时会随你的数据分布浮动,请以自己环境的 EXPLAIN ANALYZE 和 trace 为准。

没有锁等待,没有资源争抢,表结构和索引一个字没动。慢的那次,EXPLAIN 里 rows 只估了 10 行,看上去比快的那次还"便宜"。

这不是玄学,是 ORDER BY + LIMIT 组合下优化器的一种典型误判:它会为了省掉排序, 主动放弃过滤性最好的索引。它以为自己抄了条近路,而当过滤条件恰好命中 0 行时, 这条近路会变成全表最长的那条弯路。

# 1. 两个索引,优化器只能选一个

先看这类查询的通用形状——一个按时间过滤、按另一个时间排序、再取前 N 条的分页查询:

SELECT id, created_at, updated_at, status, biz_type
FROM t_record
WHERE created_at >= '2026-08-01 00:00:00'
  AND created_at <  '2026-08-02 00:00:00'
  AND status   = 1
  AND biz_type = 2
ORDER BY updated_at DESC
LIMIT 10;
1
2
3
4
5
6
7
8

对应的表结构(与业务无关的抽象模型,下文所有实验都基于它):

CREATE TABLE t_record (
  id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  created_at  DATETIME        NOT NULL,
  updated_at  DATETIME        NOT NULL,
  status      TINYINT         NOT NULL DEFAULT 0,
  biz_type    TINYINT         NOT NULL DEFAULT 0,
  payload     VARCHAR(255)    NOT NULL DEFAULT '',
  PRIMARY KEY (id),
  KEY idx_created_at (created_at),
  KEY idx_updated_at (updated_at)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
1
2
3
4
5
6
7
8
9
10
11

关键在于:WHERE 用的列和 ORDER BY 用的列,分别落在两个不同的单列索引上, 而且没有任何一个索引能同时满足两者。 于是优化器面前只有两条路:

路线 走哪个索引 好处 代价
A:先过滤 idx_created_at 过滤性强,扫描量小 拿到的行是乱序的,必须额外排序(filesort)
B:先排序 idx_updated_at 索引天然有序,省掉排序 无法用索引过滤 created_at,只能逐行回表判断

LIMIT 10 让优化器倒向了 B。它的想法是:既然只要 10 条,那顺着 idx_updated_at 倒着摸,摸够 10 条就收工——听起来根本扫不了几行,还白赚一个免排序。

打个比方:你要在一条街上找 10 家还在营业的店。B 路线相当于沿街一家家推门看, 看够 10 家就回家。只要这条街上开着的店够多,走几十米就搞定了,比先去查营业名录再 挨个找要快得多。

但这条捷径有个没写出来的前提:这条街上真的有 10 家在营业。

# 2. 数据空洞:这条街一家店都没开

现在把前提抽掉。假设 2026-08-01 这天一条数据都没有(业务还没开始、数据延迟入库、 或者干脆就是个未来日期)——相当于整条街全部关门。走 B 路线会发生什么:

  1. 从全表 updated_at 最大的那头开始,倒序扫描整个 idx_updated_at
  2. 每拿到一个索引条目,回表取出整行,判断 created_at 是否落在目标区间、 status 和 biz_type 是否匹配
  3. 全部不匹配,LIMIT 10 的计数器永远停在 0
  4. 没有任何提前退出的机会,只能一路扫到索引末尾
  5. 最终宣布:"确实是 0 条"——代价是几乎整张表被倒着扫了一遍,外加百万次回表

回到街上那个比方:你推遍了整条街的每一扇门,才确认今天真的一家都没开。 "看够 10 家就回家"这个省事的规矩,在一家都开不出来的时候,一次也没能生效。

而 A 路线在同样的空洞日只需要:翻开 idx_created_at,定位到 2026-08-01, 发现区间为空,立刻返回——相当于先查一眼营业名录,发现今天全体歇业,掉头就走。 连排序都不用做——0 行没什么可排的。

一边是常数时间,一边是全表扫描 + 全表回表。1300 倍的差距就是这么来的。

反直觉的地方

LIMIT 通常被当作性能优化手段——"我只要 10 条,能有多慢?"。但 LIMIT 只在 能提前凑够数时才省事。凑不够的时候,它不但不省,反而会诱导优化器选一条 没有提前退出可能的执行路径,把小查询变成全表扫描。

返回行数少 ≠ 扫描行数少。 这两个数字在这个场景里可以差六个数量级。

# 3. 复现与判读

# 准备:造一张带"空洞日"的表

灌 100 万行,时间集中在 2026-06-01 ~ 2026-07-31,刻意跳过 2026-08-01:

SET SESSION cte_max_recursion_depth = 1000000;

INSERT INTO t_record (created_at, updated_at, status, biz_type, payload)
WITH RECURSIVE seq(n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1000000
)
SELECT
  DATE_ADD('2026-06-01', INTERVAL FLOOR(RAND() * 61) DAY),
  DATE_ADD('2026-06-01', INTERVAL n SECOND),
  FLOOR(RAND() * 3),
  FLOOR(RAND() * 3),
  REPEAT('x', 200)
FROM seq;

ANALYZE TABLE t_record;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 执行:对比有数据日与空洞日

-- 有数据的一天
EXPLAIN SELECT id FROM t_record
WHERE created_at >= '2026-07-01' AND created_at < '2026-07-02'
  AND status = 1 AND biz_type = 2
ORDER BY updated_at DESC LIMIT 10;

-- 空洞日
EXPLAIN SELECT id FROM t_record
WHERE created_at >= '2026-08-01' AND created_at < '2026-08-02'
  AND status = 1 AND biz_type = 2
ORDER BY updated_at DESC LIMIT 10;
1
2
3
4
5
6
7
8
9
10
11

# 验证:Extra 字段是唯一可信的告警灯

健康的计划(走过滤索引)大致长这样:

type: range
key:  idx_created_at
rows: 16384
Extra: Using index condition; Using where; Using filesort
1
2
3
4

踩坑的计划长这样:

type: index
key:  idx_updated_at
rows: 10
Extra: Using where; Backward index scan
1
2
3
4

判读要点,按可信度排序:

  • Extra 出现 Backward index scan(或正序的 Using index)而 key 是排序列的索引 ——最强信号。说明优化器已经切换到"先排序后过滤",指望着提前凑够数就收工。
  • type 从 range 退化成 index —— index 是全索引扫描,不是范围定位。 在有明确范围条件的查询里看到 index,基本就是出事了。
  • rows 变小反而更危险 —— 上面踩坑那份 rows: 10,看起来比 16384 好一个数量级。 这个 10 不是"要扫 10 行",而是"我打算扫到 10 行就停"。它是意图,不是成本。 单看 rows 挑执行计划,会挑中最慢的那个。

想看真实扫描量而不是估算值,用 EXPLAIN ANALYZE——它给的是实际执行后的 actual rows 和 actual time,空洞日那次会诚实地告诉你 actual rows = 1000000。

# 4. 打开优化器的决策现场:optimizer_trace

前面三节都是从结果反推动机——看到 key 变了、type 退化了,推断"优化器想抄近路"。 但这是推断,不是证据。要拿到证据,得让优化器自己把账本摊开:optimizer_trace。

# 4.1 怎么开

SET optimizer_trace       = 'enabled=on';
SET optimizer_trace_offset = -1, optimizer_trace_limit = 1;
-- trace 默认只给 1MB,复杂 SQL 会被截断,先放大
SET optimizer_trace_max_mem_size = 1048576 * 16;

-- 跑那条踩坑的查询(EXPLAIN 也会产生 trace,且不真正执行,更适合线上排查)
EXPLAIN SELECT id FROM t_record
WHERE created_at >= '2026-08-01' AND created_at < '2026-08-02'
  AND status = 1 AND biz_type = 2
ORDER BY updated_at DESC LIMIT 10;

SELECT TRACE FROM information_schema.OPTIMIZER_TRACE\G
SET optimizer_trace = 'enabled=off';
1
2
3
4
5
6
7
8
9
10
11
12
13

几个容易踩的前提

  • trace 是会话级的,且只保留最近 optimizer_trace_limit 条;换个连接就没了。
  • 拿到 trace 后立刻关掉。它对每条 SQL 都有额外开销,别在生产会话里长期挂着。
  • 若结果里出现 "missing_bytes_beyond_max_mem_size": 12345,说明被截断了, 调大 optimizer_trace_max_mem_size 重跑,否则你看到的是半本账。

# 4.2 只看三段,其余全部略过

一份 trace 动辄几千行,但对本文这个问题只有三段有意义。它们按时间顺序讲了一个 **"先算对、再改错"**的故事:

第一段 range_scan_alternatives——此时优化器还是对的

{
  "range_analysis": {
    "table_scan": { "rows": 1000000, "cost": 101342.5 },
    "potential_range_indexes": [
      { "index": "idx_created_at", "usable": true, "key_parts": ["created_at"] },
      { "index": "idx_updated_at", "usable": false, "cause": "not_applicable" }
    ],
    "analyzing_range_alternatives": {
      "range_scan_alternatives": [
        {
          "index": "idx_created_at",
          "ranges": ["0x0000-08-01 <= created_at < 0x0000-08-02"],
          "index_dives_for_eq_ranges": true,
          "rowid_ordered": false,
          "using_mrr": false,
          "index_only": false,
          "rows": 1,
          "cost": 1.11,
          "chosen": true
        }
      ]
    },
    "chosen_range_access_summary": {
      "range_access_plan": { "type": "range_scan", "index": "idx_created_at", "rows": 1 },
      "cost_for_plan": 1.11,
      "chosen": true
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

注意 "rows": 1 和 "cost": 1.11——index dive 实际下探了 B+ 树,发现这个区间是空的。 优化器此刻完全掌握真相:走 idx_created_at 只需要约 1 行的代价。它也确实选了它 ("chosen": true)。到这一步为止,一切正确。

第二段 considered_execution_plans——把范围计划确定下来

{
  "considered_execution_plans": [
    {
      "best_access_path": {
        "considered_access_paths": [
          { "access_type": "range", "index": "idx_created_at",
            "rows": 1, "cost": 1.36, "chosen": true }
        ]
      },
      "cost_for_plan": 1.36,
      "rows_for_plan": 1,
      "chosen": true
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

cost_for_plan: 1.36。这就是"正确答案"的价签,记住这个数。

第三段 reconsidering_access_paths_for_index_ordering——错误在这里发生

{
  "clause_processing": {
    "clause": "ORDER BY",
    "original_clause": "`t_record`.`updated_at` desc",
    "items": [ { "item": "`t_record`.`updated_at`" } ],
    "resulting_clause_is_simple": true,
    "resulting_clause": "`t_record`.`updated_at` desc"
  }
}
1
2
3
4
5
6
7
8
9
{
  "reconsidering_access_paths_for_index_ordering": {
    "clause": "ORDER BY",
    "steps": [],
    "index_order_summary": {
      "table": "`t_record`",
      "index_provides_order": true,
      "order_direction": "desc",
      "index": "idx_updated_at",
      "plan_changed": true,
      "access_type": "index_scan"
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

"plan_changed": true 就是全文的核心证据。翻译成人话:

我本来打算走 idx_created_at(代价 1.36),但我注意到 idx_updated_at 天然按 updated_at 有序,倒着扫就能直接满足 ORDER BY … DESC,还能省掉 filesort。 又因为只要 LIMIT 10,我假设扫一小段就能凑满。所以我改主意了, access_type 从 range 换成 index_scan。

这一步换算的代价模型大致是:总行数 × (LIMIT / 预估满足条件的行数)。 优化器用全表的选择性均值去估"多久能凑满 10 条",而空洞日的真实答案是 永远凑不满。分母被高估,估算代价被压到极低,于是它心安理得地丢掉了那个 代价只有 1.36 的正确计划。

判读口诀

range_scan_alternatives 里 chosen: true 的索引,和最终 EXPLAIN 的 key 对不上, 中间一定夹着一次 plan_changed: true。顺着这两个字段就能锁定优化器在哪一步改的主意。

# 4.3 有数据的那天,trace 有什么不同

同一条 SQL 换成 2026-07-01,plan_changed 依然可能是 true——优化器一样会切。 差别不在决策,而在这个决策的后果:有数据的日子里,倒扫 idx_updated_at 几百行 就凑满了 10 条,提前退出真的生效了,所以你根本不会注意到它抄了近路。

这解释了为什么这个坑只在边界条件下暴露:它不是偶发的"优化器抽风",而是一个 一直存在、只是平时不发作的决策。空洞日只是把它的代价放大了六个数量级。

顺带一提:EXPLAIN 的 rows: 10 从哪来,trace 也答了——它就是 index_order_summary 换计划之后按 LIMIT 填进去的数,是打算扫到的行数, 不是估算要扫的行数。第 3 节说的"rows 是意图不是成本",证据在这里。

# 5. 五种解法,按推荐顺序

# 解法一:建覆盖两端的联合索引(首选)

问题的根在于"没有一个索引同时满足过滤和排序"。那就造一个:

ALTER TABLE t_record ADD KEY idx_created_updated (created_at, updated_at);
1
  • 症状:优化器在过滤索引和排序索引之间反复横跳。
  • 原因:两个需求分属两个索引,必然牺牲一个。
  • 解药:联合索引把两个需求合并。等值条件在前、排序列在后时,MySQL 可以 用前缀定位范围、用后缀维持有序,一次扫描同时满足过滤和排序。

注意前缀顺序的限制

created_at 在本例是范围条件(>= / <)。范围列之后的索引列无法再用于消除排序, 所以 (created_at, updated_at) 只能省掉过滤代价,filesort 仍会保留——但排序对象已经 缩小到当天那几行,代价可以忽略。

若把查询改成按天等值匹配(例如额外维护一个 stat_date DATE 列), 则 (stat_date, updated_at) 可以做到过滤和排序全走索引,Extra 干净到只剩 Using index condition。低基数的 status / biz_type 也可以视选择性酌情加进来。

# 解法二:FORCE INDEX 强制走过滤索引(应急)

SELECT id, created_at, updated_at, status, biz_type
FROM t_record FORCE INDEX (idx_created_at)
WHERE created_at >= '2026-08-01' AND created_at < '2026-08-02'
  AND status = 1 AND biz_type = 2
ORDER BY updated_at DESC
LIMIT 10;
1
2
3
4
5
6
  • 症状:线上正在被慢查询打爆,来不及加索引。
  • 原因:优化器的成本模型在这个数据分布下算错账。
  • 解药:FORCE INDEX 直接剥夺它的选择权,无论查哪天都走范围定位。

代价要认清:索引提示是硬编码的技术债。它绑死了表名与索引名,日后索引改名或 重建会让 SQL 直接报错;数据分布变化后,它也可能从"救命"变成"拖累"。 当应急止血手段用,随后补上解法一,别让它在代码里长住。

# 解法三:延迟关联,把回表次数压到 N 次

SELECT r.id, r.created_at, r.updated_at, r.status, r.biz_type
FROM t_record r
JOIN (
  SELECT id FROM t_record
  WHERE created_at >= '2026-08-01' AND created_at < '2026-08-02'
    AND status = 1 AND biz_type = 2
  ORDER BY updated_at DESC
  LIMIT 10
) t ON t.id = r.id
ORDER BY r.updated_at DESC;
1
2
3
4
5
6
7
8
9
10
  • 症状:行宽很大(大 TEXT / 多列),回表本身就是主要开销。
  • 原因:外层每扫一行都要拉回完整行数据。
  • 解药:内层只在索引上跑出 10 个主键,外层只回表 10 次。

注意这不能单独解决本文的问题——内层子查询同样可能选错索引。它是减少回表放大的 配套手段,需要和解法一或解法二叠加使用。

# 解法四:刷新统计信息(先做,但别指望它兜底)

ANALYZE TABLE t_record;
1
  • 症状:索引选择时好时坏,重启或大批量写入后突然劣化。
  • 原因:InnoDB 的索引统计是采样估算,大批量写入后可能严重失真。
  • 解药:ANALYZE TABLE 重新采样。代价很低(只更新统计信息,不重建表, 区别于 OPTIMIZE TABLE),值得作为排查第一步。

但要清醒:本文的场景里,统计信息再准也救不了。优化器不知道 2026-08-01 是个空洞——直方图能刻画已有数据的分布,却无法预知一个区间外的日期返回 0 行。 ANALYZE 能修复的是"估算偏差",修不了"捷径没有出口"。

# 解法五:关掉 prefer_ordering_index(对症下药)

第 4 节的 trace 指出,出事的是 reconsidering_access_paths_for_index_ordering 这一步。 MySQL 8.0.21 起,这一步可以单独关掉:

-- 会话级,先验证
SET SESSION optimizer_switch = 'prefer_ordering_index=off';

EXPLAIN SELECT id FROM t_record
WHERE created_at >= '2026-08-01' AND created_at < '2026-08-02'
  AND status = 1 AND biz_type = 2
ORDER BY updated_at DESC LIMIT 10;
1
2
3
4
5
6
7

关掉之后再看 trace,index_order_summary 里会变成 "plan_changed": false, EXPLAIN 的 key 回到 idx_created_at,Extra 重新出现 Using filesort—— 这正是我们要的:宁可排序,也不要一条没有出口的近路。

  • 症状:确认是"为了省排序而换索引"导致的劣化(trace 里 plan_changed: true)。
  • 原因:优化器这一步的收益估算依赖"很快能凑满 LIMIT",该假设在空结果集下不成立。
  • 解药:关掉这个改写步骤,让它守住第二段算出来的那个 cost_for_plan。

比 FORCE INDEX 好的地方:它不绑死任何索引名,索引改名、重建都不会让 SQL 报错, 语义是"别做这个特定改写"而不是"必须走这个索引"。

代价同样要认清:它是行为开关而非单条 SQL 的修复。会话级只影响当前连接(可由应用在 连接初始化时下发);写成 SET GLOBAL 或进配置文件就会影响所有查询——那些真正靠这步 改写受益的分页查询会集体退化成 filesort。

优先级:能加索引就走解法一;加不了索引又要立刻止血,prefer_ordering_index=off 比 FORCE INDEX 更值得先试——同样是止血带,但它不会在索引重构时崩掉。

# 6. 可复用要点

  1. WHERE 列与 ORDER BY 列不在同一个索引上时,LIMIT 是风险放大器,不是优化器。 这是索引选择失误的头号高发区。设计分页查询时,第一件事是确认两者能否收进同一个索引。

  2. 判读执行计划要看 Extra 和 type,别看 rows。 Backward index scan + 排序列索引 = 优化器在抄近路;type: index 出现在有范围条件的 查询里 = 全索引扫描。而 rows 在 LIMIT 查询里表达的是意图不是成本, 越小可能越危险。

  3. EXPLAIN 只说结论,optimizer_trace 才说理由。 遇到"索引选得莫名其妙",别停在猜测:对比 range_scan_alternatives 里 chosen: true 的索引与最终 key,中间夹着的 plan_changed: true 就是改主意的那一步。

  4. 用返回 0 行的边界条件测你的分页接口。 常规测试都拿有数据的日期跑,恰好绕开了这个坑。空结果集是最坏情况—— 它让 LIMIT 永远无法提前退出。未来日期、刚上线的业务、被清理过的历史区间, 都是天然的测试用例。

  5. 索引提示是止血带,不是治疗方案。 FORCE INDEX 用来救火,联合索引用来治本。前者绑死索引名、随数据分布腐化, 不该长期留在代码里。

  6. 先 ANALYZE,但别指望它。 统计信息刷新成本低、值得作为排查第一步, 却修不了"区间内无数据"这类优化器根本无从预知的场景。

# 边界与局限

本文的结论有明确的适用边界,超出这几条就别照搬:

  • 只适用于「过滤列与排序列不在同一索引」的形态。 若两者已在同一联合索引里, 优化器根本不会走到 reconsidering_access_paths_for_index_ordering 那一步。
  • prefer_ordering_index 需要 8.0.21+。 更早的 8.0 小版本与 5.7 没有这个开关, 只能退回联合索引或 FORCE INDEX。5.7 的 trace 里也没有 reconsidering_access_paths_for_index_ordering 这一节,判读方式不同。
  • 空结果集是最坏情况,但不是唯一情况。 结果集非空但极稀疏(例如当天只有 3 行, 而 LIMIT 10)同样凑不满,一样会扫穿整个索引。判断标准是"能否凑满 LIMIT", 不是"有没有数据"。
  • trace 里的 cost 是优化器的内部单位,不是毫秒。 它只能用来做同一次决策内部的 横向比较(1.36 vs 换计划后的估值),不能跨查询、跨版本比。真实耗时看 EXPLAIN ANALYZE。
  • LIMIT 越大,这个坑越轻。 LIMIT 1000 时优化器估算的换计划收益本身就小, 更倾向于守住范围计划。这个坑最爱咬的是 LIMIT 10 这种"看起来最无害"的小分页。

# 7. 延伸阅读

  • 通论篇:MySQL为什么有时候会选错索引 ——本文是其中 ORDER BY + LIMIT 这一特定触发器的深入拆解。
  • 成本工具:optimize table 和 analyze table 的区别
  • 优化器决策现场的完整读法见本文第 4 节;官方文档参见 MySQL 8.0 Reference Manual 的 Tracing the Optimizer 与 ORDER BY Optimization(prefer_ordering_index 的说明在后者)。
🤖 Agent 可直接解析的元数据块(点击展开)
{
  "_meta": {
    "doc_version": "2026-08-25",
    "article_id": "mysql-33-orderby-limit-index-trap",
    "profile_context": "any",
    "estimated_setup_time": "20min"
  },
  "symptom_signature": {
    "description": "同一 SQL 换个日期慢 1000 倍,两次都返回 0 行",
    "explain_red_flags": [
      "Extra 含 'Backward index scan' 且 key 是 ORDER BY 列的索引",
      "type 从 range 退化为 index",
      "rows 异常小且约等于 LIMIT 值",
      "optimizer_trace 中 index_order_summary.plan_changed 为 true 且 access_type 变为 index_scan"
    ]
  },
  "quick_start": {
    "step_1": "EXPLAIN <慢查询>; 检查 key 是否为 ORDER BY 列的索引",
    "step_2": "EXPLAIN ANALYZE <慢查询>; 对比 actual rows 与 estimated rows",
    "step_3": "ANALYZE TABLE <表名>; 重新采样统计信息后复测",
    "step_4": "ALTER TABLE <表名> ADD KEY idx_filter_sort (<过滤列>, <排序列>);",
    "step_5": "SET optimizer_trace='enabled=on'; EXPLAIN <慢查询>; SELECT TRACE FROM information_schema.OPTIMIZER_TRACE; 查 index_order_summary.plan_changed",
    "step_6": "应急场景 SET SESSION optimizer_switch='prefer_ordering_index=off'; 不行再退到 FORCE INDEX (<过滤列索引>)"
  },
  "safety_rules": [
    "生产环境加索引前先评估表大小与 DDL 方式(pt-online-schema-change / gh-ost)",
    "FORCE INDEX 仅作应急,须登记为技术债并在补索引后移除",
    "不要用 OPTIMIZE TABLE 代替 ANALYZE TABLE,前者等价整表重建",
    "任何 EXPLAIN 结论都以本地实测为准,不要照搬文中示意输出",
    "optimizer_trace 有额外开销,排查完必须 SET optimizer_trace='enabled=off'",
    "prefer_ordering_index=off 只在会话级验证,写 GLOBAL 前须评估对全部分页查询的影响"
  ],
  "verification": {
    "check_1": "EXPLAIN 输出中 key 应为过滤列索引,Extra 不再含 Backward index scan",
    "check_2": "对空洞日期(无数据的日期)执行查询,耗时应与有数据日期同量级",
    "check_3": "EXPLAIN ANALYZE 的 actual rows 应远小于表总行数",
    "check_4": "修复后 optimizer_trace 的 index_order_summary.plan_changed 应为 false"
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

AI Agent 使用指南:

  • 当用户说"同一条 SQL 有时快有时慢" → 执行 quick_start.step_1、step_2, 按 symptom_signature.explain_red_flags 逐条比对
  • 当用户说"分页查询突然变慢" → 确认 WHERE 列与 ORDER BY 列是否分属不同索引, 命中则走 step_4
  • 当用户说"要紧急止血" → 先 step_6 的会话级 prefer_ordering_index=off, 再不行才用 FORCE INDEX;随后必须补 step_4 并移除临时手段
  • 当用户问"为什么优化器选这个索引" → 执行 step_5,按第 4 节三段式判读
#索引优化#性能优化
上次更新: 9/11/2026

← MySQL 的 Left join、Right join 和 Inner join 的区别 Redis 运维知识地图:从单机到 Cluster 排障→

最近更新
01
DeepSeek Harness 实战 06|学习笔记:插件、工具、技能不在同一个维度上 原创
09-11
02
DeepSeek Harness 实战 05|让两个编码 Agent 共用一份长期记忆 原创
09-09
03
DeepSeek Harness 实战 04|学习笔记:从「已知限制」里读出三处设计张力 原创
09-08
更多文章>
Theme by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式