1. 一个让我半夜爬起来看慢查询日志的案例事情发生在周三晚上十一点我刚准备合上电脑手机上的监控告警就响了。某条线上接口的 P99 耗时从平时的 80ms 直接飙到 2.3 秒数据库侧同步亮起了慢查询告警。我拉出慢查询日志一眼就看到了罪魁祸首——一条加了LIMIT 1的查询。先说结论这不是 SQL 写错了也不是LIMIT这个关键字本身有毛病而是LIMIT就像一把扳手在某些情况下会彻底改变优化器对执行计划的选择。加了LIMIT 1以后MySQL 会默认我只想要一行那我是不是可以用一个最暴力的方式赶紧碰到一行——问题就出在这个最暴力的方式上。这篇文章我想把这个问题彻底拆开从执行计划、排序成本、优化器估算三个角度讲清楚为什么加LIMIT 1反而变慢遇到这种情况怎么排查最终怎么解决不说空话全是实操经验。2. 先看一个具体的慢查询LIMIT 1 前后的执行计划对比2.1 现场还原业务场景与表结构线上是一个订单场景核心表结构大概长这样CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, buyer_id bigint NOT NULL, status tinyint NOT NULL DEFAULT 0, amount decimal(10,2) NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_buyer_status_created (buyer_id, status, created_at) ) ENGINEInnoDB;业务需求很简单查某一个买家最近创建的一条订单。正常情况下大家都会这么写SELECT * FROM orders WHERE buyer_id 12345 ORDER BY created_at DESC LIMIT 1;加了LIMIT 1天经地义我只取一行难道还要我把所有订单都查出来但现实就是这么打脸——这条 SQL 在某个买家身上跑了 2 秒多。2.2 EXPLAIN 对比同一个 SQL加不加 LIMIT 走完全不同的路我把慢查询语句去掉LIMIT 1重新执行一遍执行计划是这样的id: 1 select_type: SIMPLE table: orders type: ref possible_keys: idx_buyer_status_created key: idx_buyer_status_created rows: 382000 filtered: 100.00 Extra: Using index condition很合理优化器选择联合索引根据buyer_id定位到 38 万行然后走Using index condition做条件过滤。而加上LIMIT 1之后执行计划变成了这样id: 1 select_type: SIMPLE table: orders type: ALL possible_keys: idx_buyer_status_created key: NULL rows: 8693421 filtered: 0.00 Extra: Using where; Using filesort看到了吗加了LIMIT 1优化器反而选择全表扫描直接放弃了联合索引。整张表 800 多万行全部扫一遍再做一个filesort排序然后把第一条返回。这不慢才怪。2.3 为什么优化器敢在 LIMIT 1 时选全表扫描关键就在rows这一列filtered: 0.00优化器估算这里能命中的行数极其少。既然它认为我全表扫一遍可能在第 1 行就遇到符合条件的了那么全表扫描的成本就足够低。个中逻辑非常反直觉优化器做的不是最坏情况评估而是平均期望估算。它觉得扫全表配合 filesort预期成本是找到第一个满足条件的行 对少量数据排序而走联合索引则需要先在二级索引上扫 38 万行、再逐行回表平均成本明显更高。这就是LIMIT 1变慢的第一个核心机制LIMIT 让优化器对提前终止扫描抱有极高期望从而改变了对索引的选择。3. 优化器的算盘与陷阱为什么全表扫描的估算会胜出3.1 成本模型是怎么算这笔账的MySQL 的成本模型大致可以用两条公式概括全表扫描成本 IO 成本 CPU 成本 总行数 × 单行读取成本 总行数 × 单行比较成本 索引扫描成本 索引 IO 成本 回表 IO 成本 CPU 成本 索引扫描行数 × 每行索引读取成本 回表行数 × 每行随机读取成本 各环节比较成本对于 800 万行的表全表扫描的成本是固定的、可预期的。而联合索引扫描的成本取决于优化器对rows的估算。这里出现了一个诡异的现象由于 WHERE 条件里buyer_id的基数分布严重不均比如一个超卖大买家占了整表 30% 的数据优化器拿到的统计信息其实是滞后的。我当时查了一下MySQL 优化器在使用联合索引idx_buyer_status_created时估算的rows是 382000而实际需要扫描的行数高达 380 多万。估算误差整整 10 倍。成本模型算出来索引扫描总成本约 47 万全表扫描约 32 万——于是优化器就选了全表扫描。3.2 为什么统计信息会偏差这么大InnoDB 的索引统计信息是通过随机采样的方式收集的默认采样 8 个页面。数据量大、数据分布不均时基于 8 个页面算出来的平均值很容易被少数超大用户带偏。更隐蔽的是ANALYZE TABLE往往救不了这个问题。因为一旦某个buyer_id的数据行数本身就占绝对大头即使重新采样算出来的还是高估而你恰好就是查这个超大买家那就一直走全表扫描。3.3 用一条 COUNT(*) 验证优化器的判断有多离谱我在现场用一条聚合 SQL 验证了实际数据分布SELECT buyer_id, COUNT(*) AS cnt FROM orders WHERE buyer_id 12345 GROUP BY buyer_id; -- 结果cnt 3,812,556380 万行。也就是说即使LIMIT 1提前终止了扫描它至少也要在联合索引上完成 380 万行的范围扫描才可能回表取到第一条完整记录。这跟全表扫 800 万行的成本完全是同一量级。4. 排序把 LIMIT 1 的优势吃掉了不可忽视的 filesort 代价4.1 ORDER BY created_at DESC 是一切的导火索如果你只是简单写SELECT * FROM orders WHERE buyer_id 12345 LIMIT 1没有ORDER BY那么走全表扫描可能真的会在几十行内命中问题不会这么严重。但一旦加上ORDER BY created_at DESC事情就变了。即便查询只需要返回一行MySQL 也无法确定当前扫到的第一行就是 created_at 最大的那一行所以它必须把所有满足 WHERE 条件的行全都找出来排序之后再取第一条。全表扫描 filesort 的执行流程大致是把 800 万行记录全部走一遍过滤出buyer_id 12345的行这几行进入排序缓冲sort buffer如果排序缓冲不够用就要写临时文件做归并排序排序完成取出第 1 行。这个流程里最可怕的是第 3 步。在执行计划里Using filesort这个字眼的实际含义并不是真的在磁盘上排序它可能走的是内存排序也可能走的是磁盘归并。当符合条件的数据量达到百万级sort buffer默认 256KB根本装不下就会在磁盘上产生大量临时文件——IO 开销瞬间爆炸别说 LIMIT 1就是 LIMIT 0 也救不回来。4.2 为什么走联合索引就不需要 filesortidx_buyer_status_created (buyer_id, status, created_at)这个索引在buyer_id确定的前提下created_at在索引内部是有序的。所以理论上WHERE buyer_id 12345 ORDER BY created_at DESC LIMIT 1完全可以采用索引逆序扫描在联合索引中直接定位到buyer_id 12345的最后一个叶子节点往回取一条就能直接得到答案。整个过程只需要访问索引的一个叶子节点交换约等于 O(1)。这是一个典型的索引有序性匹配场景。但问题在于这个索引的第一个字段是buyer_id第二个字段是status第三个字段才是created_at。如果WHERE子句中只有buyer_id而没有把status也作为等式条件写进去那么优化器就会认为在status这个维度还没被钉死的情况下created_at的有序性无从保证。4.3 一个关于索引顺序的经典误区很多 DBA 会告诉你排序字段放在联合索引最后面这句话只对了一半。准确的说法是如果status也作为等值条件出现比如WHERE buyer_id ? AND status ? ORDER BY created_at LIMIT 1那么created_at放在第三位是完美匹配的如果status并没有在 WHERE 中限定那么created_at在索引里的有序性就是相对 status 分组内的有序整体上对排序没有帮助。在我们这个案例里实际业务 SQL 的 WHERE 只写了buyer_id没写status。这就导致索引无法直接用于排序优化器权衡后认为走全表再排序更划算——由此踩中了 4.1 的陷阱。4.4 调整索引后验证排序是否被消除我把索引改成idx_buyer_created (buyer_id, created_at)重新看执行计划id: 1 select_type: SIMPLE table: orders type: ref possible_keys: idx_buyer_created key: idx_buyer_created rows: 1 filtered: 100.00 Extra: Using index condition; Backward index scanBackward index scan同样来自 MySQL 8.0 的逆序索引特性不需要 filesort。同样的数据量原本跑 2.3 秒的 SQL现在稳定在 12ms。5. 同样加了 LIMIT 1还有几种更隐蔽的变慢场景5.1 LIMIT 1 IN 子查询半连接改写把索引干掉不是所有LIMIT 1都会走全表扫描但有些写法藏着更深的坑。举个典型场景SELECT * FROM orders WHERE buyer_id IN ( SELECT buyer_id FROM black_list WHERE expire_at NOW() LIMIT 1 ) LIMIT 1;MySQL 优化器会尝试将IN子查询做半连接优化semi-join把子查询转换为具体的驱动表。此时子查询里的LIMIT 1会被下推或改写最终执行计划可能变成先扫black_list全表或者没用对索引再对每一个buyer_id去orders表上做探测。这个过程中子查询的LIMIT 1根本没有发挥作用——因为半连接的语义是需要把全部匹配结果集做去重之后再限制行数。最终LIMIT 1并不会带来提前终止反而因为半连接引入的物化/去重步骤整体成本更高。我的经验是遇到LIMIT 1IN 子查询的组合先想办法把子查询拆出来单独执行拿到具体值再传给外层查询。5.2 LIMIT 1 是最容易掩盖深分页问题的地方还有一种情况很多人给分页查询加了LIMIT 1之后以为高枕无忧比如SELECT * FROM orders WHERE buyer_id 12345 ORDER BY id LIMIT 100000, 1;这种写法不是只取一行吗其实LIMIT 100000, 1等价于 MySQL 内部先扫描 100001 行再丢弃前 100000 行。如果辅助索引无法覆盖需要返回的所有列InnoDB 就要对那 10 万行逐一做随机回表。扫描行数一点没少只是最后显式返回了 1 行。实际工作中如果要翻到很深的页码我建议改成基于游标的方式先记录上一页最后一条id然后用WHERE id 上一页最大id ORDER BY id LIMIT 1这样就能利用主键索引的定位能力直接跳到目标位置。5.3 LIMIT 1 配合 FOR UPDATE行锁范围反而可能扩大SELECT ... LIMIT 1 FOR UPDATE也是个高频踩坑点。曾经有位同事为了只锁一行写了SELECT * FROM orders WHERE buyer_id 12345 AND status 0 ORDER BY created_at LIMIT 1 FOR UPDATE;乍看很严谨只锁一行。但问题在于当 WHERE 和 ORDER BY 无法充分利用索引时InnoDB 的锁定范围会按照扫描过程中碰到的记录来定。全表扫 800 万行里面符合条件但没有被LIMIT 1截断掉的行扫描到它们时也都会被加上锁在某些隔离级别下是 next-key lock。等执行完毕后可能锁定的行数远超预期甚至影响整表大部分数据。线上出现过因为这么一条 SQL 导致整个订单表的插入操作集体阻塞的事故。加锁查询加 LIMIT 1 之前必须用 EXPLAIN 确认它实际扫描了多少行。5.4 GROUP BY LIMIT 1临时表与文件排序的叠加最后补充一个搜索热词里频繁出现的场景sharding groupby 改写了 limit。在分布式分库分表环境下GROUP BY后接LIMIT 1会被中间件改写成LIMIT 每分表1 行其实不对——正确改写往往是LIMIT 总限制数由中间件合并后再排序取前 1 行。但如果底层每张分表都没有合适的索引原本只需要 1 行的查询最终会演变成每张表全量分组、全局排序、再截断。在单库 MySQL 里也一样GROUP BY往往伴随临时表而LIMIT 1的提前终止作用在临时表排序之前是完全失效的因为它必须等 GROUP BY 的整个结果集生成完毕才能取第一行。这类 SQL 我一般建议先查聚合的最小/最大值用MIN/MAX 索引再回表取完整行而不是用GROUP BY ... LIMIT 1。6. 针对这类问题的通用排查方法论6.1 用 FORMATJSON 看优化器的成本估算遇到LIMIT 1变慢第一步永远是看执行计划但只看传统的EXPLAIN文本还远远不够。推荐用 JSON 格式EXPLAIN FORMATJSON SELECT * FROM orders WHERE buyer_id 12345 ORDER BY created_at DESC LIMIT 1\G重点关注这几个字段{ cost_info: { read_cost: 987351.50, eval_cost: 326.40, prefix_cost: 987677.90, data_read_per_join: 4.20G }, rows_examined_per_scan: 8693421, rows_produced_per_join: 1, filtered: 0.00, using_filesort: true }如果看到rows_examined_per_scan接近整表行数同时using_filesort为 true就可以基本断定优化器在选择执行路径时严重高估了 LIMIT 1 的提前终止能力。6.2 用 performance_schema 定位真正的时间消耗执行计划只是解释为什么这样走如果要确认时间到底消耗在哪个阶段可以开 performance_schema 的语句事件分析SELECT EVENT_NAME, TIMER_WAIT/1000000000 AS ms FROM performance_schema.events_statements_history_long WHERE THREAD_ID 12345 ORDER BY TIMER_WAIT DESC LIMIT 20;我排查那个案例时看到stage/sql/After collect和stage/sql/Sorting result两个阶段占掉了总耗时的 90%。这就说明时间几乎全耗在了 filesort 上而不是扫描本身。有了这个证据调整索引就非常有针对性。6.3 用 FORCE INDEX 做快速验证但别长期用为了快速验证走联合索引是不是更快可以直接在 SQL 里强制指定索引SELECT * FROM orders FORCE INDEX (idx_buyer_status_created) WHERE buyer_id 12345 ORDER BY created_at DESC LIMIT 1;配合执行计划确认rows降到 38 万以内耗时会从 2 秒降到 200ms 以内。这基本验证了索引本身没问题是优化器选错了。但注意FORCE INDEX 只能用来做实验不能直接上生产长期使用。原因有两个数据分布变化后强制索引可能会比全表扫描更差一旦索引名变更SQL 会直接报错。正确的做法是通过调整索引结构、统计信息或 SQL 写法让优化器自然选择最优路径。6.4 数据倾斜严重时考虑拆查询逻辑如果业务确实存在超级大买家一个用户几百万单任何索引都救不了ORDER BY LIMIT。这时候要从业务层面拆解思路一订单表按buyer_id做分片把大买家单独分片思路二为获取用户最近订单这类高频场景建独立的汇总表应用写入时同步更新查询直接读汇总表的 1 行思路三适当放宽业务约束比如查最近一小时的订单再排序候选集从 380 万降到几千。7. 写在最后加 LIMIT 1 之前先问自己三个问题回过头看这个问题的本质不是LIMIT 1慢而是优化器在 LIMIT 的诱导下做出了错误的执行计划选择。这提醒我在写 SQL 时不能凭直觉认为限制返回行数 一定更快。我现在遇到任何LIMIT相关 SQL都会先问三个问题是否涉及排序如果ORDER BY的字段和索引顺序不匹配LIMIT再小也可能触发完整 filesort优化器估算的扫描行数是多少用EXPLAIN FORMATJSON看rows_examined_per_scan如果接近全表行数就要警惕数据分布是否均匀一个值占了全表 30% 以上的数据时索引不一定比全表扫描快。最后再分享一个排查小技巧所有慢 SQL 分析先做ANALYZE TABLE再跑一次 EXPLAIN。很多时候不是 SQL 有什么问题而是统计信息长期没更新优化器一直拿着过期的数据在算账。我遇到过好几个案例ANALYZE TABLE之后执行计划自动恢复正常连索引都不用改。这条命令成本低、收益快值得每次排查慢查询时都顺手做一次。