多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

加了LIMIT 1反而变慢?优化器选错索引的坑与根治方案

加了LIMIT 1反而变慢?优化器选错索引的坑与根治方案 DBA这行干久了什么鬼事都能见到。这个标题里的场景我至少有三次在同事的工位前见过一条订单查询SQL本来跑得好好的几百毫秒返回为了减少接口返回的数据量加了个 LIMIT 1结果反而变成秒级别的慢查询监控群里直接炸锅。更迷惑的是你把这个 LIMIT 1 去掉它又快得飞起——所以这不是玄学也不是数据库抽风而是优化器在 LIMIT 面前打了一手自以为很聪明的牌。背后的逻辑其实不复杂一旦SQL加了 LIMIT优化器就会认为我最多只要返回一行于是它的决策目标从让整个查询的总代价最低悄悄变成了让第一行出现得最快。这个目标的转变会彻底改写执行计划。偏偏在尽快找到第一行这个新目标下优化器的成本估算极度依赖统计信息统计信息一旦不准它就敢给你选一个看起来快、实际上最坑的索引路径。今天这篇我用一台测试库把现象、原理、执行计划、优化器内心、修复药方全部拆开讲清楚。无论你是业务开发、后端还是专职DBA看完都能少踩几个坑。1. 现象还原一条SQL加了 LIMIT 1为什么反而炸了1.1 一个真实得不能再真实的翻车现场先说表结构。这是一张很普通的支付订单表线上几千万行业务上经常要查某个商户最近一笔已支付订单。我简化一下CREATE TABLE pay_order ( id bigint NOT NULL AUTO_INCREMENT, merchant_id int NOT NULL COMMENT 商户ID, pay_status tinyint NOT NULL COMMENT 0-待支付 1-已支付 2-已退款, pay_amount decimal(10,2) NOT NULL, channel_type tinyint NOT NULL COMMENT 支付渠道, create_time datetime NOT NULL COMMENT 创建时间, PRIMARY KEY (id), KEY idx_merchant_id (merchant_id), KEY idx_pay_status_create_time (pay_status, create_time) ) ENGINEInnoDB;数据量大约 2000 万行其中pay_status 1已支付的记录占了 95%约 1900 万行。merchant_id 88888是某个重点商户他在这个表里的订单只有 500 条但比较特殊的是这 500 条订单全部集中在一年多以前最近半年他几乎没有新订单。业务需求查该商户最近一笔已支付订单。SELECT id, pay_amount, create_time FROM pay_order WHERE merchant_id 88888 AND pay_status 1 ORDER BY create_time DESC LIMIT 1;你按照惯性思维觉得加 LIMIT 1 天经地义结果实测一下怀疑人生不加LIMIT 1返回该商户全部 500 条已支付订单平均耗时 82ms。加上LIMIT 1只返回 1 条平均耗时 6.4 秒。你没看错限制返回行数之后性能反而恶化了接近两个数量级。我第一次遇到的时候第一反应是缓存失效了第二反应是索引被删了结果都不是。真正的原因全写在执行计划里。1.2 先别急着骂数据库看看执行计划对这两条SQL分别做EXPLAIN差异非常明显。先看不带 LIMIT 的那条--------------------------------------------------------------------------------------------------------- | id | select_type | table | type | possible_keys | key | key_len | rows | Extra | --------------------------------------------------------------------------------------------------------- | 1 | SIMPLE | pay_order | ref | idx_merchant_id| idx_merchant_id | 4 | 500 | Using where; Using filesort | ---------------------------------------------------------------------------------------------------------优化器沿着idx_merchant_id索引精准定位到该商户的 500 条订单回表过滤pay_status 1然后对这 500 行做一次内存排序Using filesort整个过程行数可控自然快。再看加了LIMIT 1的版本--------------------------------------------------------------------------------------------------------------------------------------- | id | select_type | table | type | possible_keys | key | key_len | rows | Extra | --------------------------------------------------------------------------------------------------------------------------------------- | 1 | SIMPLE | pay_order | index | idx_merchant_id,idx_pay_status_create_time | idx_pay_status_create_time | 5 | 19000000 | Using where | ---------------------------------------------------------------------------------------------------------------------------------------注意两个核心变化一是type从ref变成了index二是rows从 500 变成了 1900 万。type index意味着优化器决定对idx_pay_status_create_time这个二级索引做全索引扫描从索引的一头开始一行一行往下找直到凑齐第一条符合条件的记录才停。也就是说优化器在加了LIMIT 1之后把idx_merchant_id这个明明更精准的索引丢在一边选择了一个需要扫 1900 万行索引项的路径。你说它傻吗也不全是它心里有小算盘只是这把算盘打崩了。2. 灵魂拷问优化器为什么做出这么离谱的选择2.1 LIMIT 1 的诱惑优化器的我要第一行博弈要理解这个坑得先跳出全表扫描最慢的直觉。数据库优化器的目标函数从来不是扫描行数最少而是预估总代价最低。这个总代价包括 CPU 代价、IO 代价、内存排序代价等等。但一旦SQL带了LIMIT n情况会变。优化器会把提前终止作为一个极大的优惠条件算进成本里反正我只要 n 行理论上扫到第 n 行满足条件的记录就可以停了后面的行根本不用看。于是成本模型的重心从处理完全部数据变成了快速找到第一批结果。这个策略在大多数情况下是理性的。比如查WHERE user_id ?然后ORDER BY create_time DESC LIMIT 1优化器如果有一个(user_id, create_time)的索引它往索引里一钻找到该用户的最后一条记录直接返回扫描量几乎为零这才是 LIMIT 的意义所在。问题出在预估两个字上。优化器认为它能快速找到第一条但它的预估基于统计信息而统计信息不包含数据分布细节。用一个生活类比你丢了钥匙知道它在某个房间里。本来你计划把整个房间从头到尾翻一遍最多一小时就翻完。后来你嫌慢决定只要找到钥匙就停于是先翻最顺手的书桌抽屉。结果钥匙偏偏在床底你翻完书桌翻衣柜翻完衣柜翻床底花了三个小时才找到。不是翻桌子的动作慢而是你对哪一块区域最容易命中的判断错了。2.2 基数的魔法统计信息一错全盘皆输那么优化器的判断到底依赖什么主要是两部分索引的基数Cardinality和表的行数。InnoDB 通过随机采样数据页来估算某个索引里有多少个不同值进而推断WHERE 条件能过滤掉多少行。在我们的例子里优化器知道pay_status 1大约有 1900 万行merchant_id 88888只有 500 行。它把这两个条件当成互相独立的事件来估算在 1900 万行pay_status 1的数据里均匀分布着 500 个目标商家的订单也就是说平均每隔 3800 行就会出现一条。于是它预期走idx_pay_status_create_time索引从最新时间开始反向扫描大约扫 3800 行左右就能碰到第一条merchant_id 88888的记录然后回表取出完事。这个预期一旦成立执行计划的成本大概是扫描 3800 个索引项因为索引不包含merchant_id必须回表 3800 次平均约 3800 次随机 IO。看起来完全可控甚至比走idx_merchant_id然后排序 500 行还要便宜。于是它选了这条路径。现实是什么这 500 条目标订单全部集中在一年前的某个时间段而索引是(pay_status, create_time)优化器从create_time最新的记录开始反向扫描等于要把最近一年新增的近 1900 万条已支付记录几乎全部扫完才碰到第一条该商户的历史订单。实际回表次数不是 3800 次而是接近 1900 万次。这就是统计信息只告诉你平均水位没告诉你哪里是深坑的经典事故。2.3 四两拨千斤索引选择性差引发的回表灾难有人会问就算扫 1900 万条索引项MySQL 扫索引不是很快吗这就要说到 InnoDB 二级索引的致命弱点回表。idx_pay_status_create_time这个二级索引里只存了pay_status、create_time和主键id并没有merchant_id。优化器每扫到一个索引项就要拿着主键id到聚簇索引主键索引里找到完整行读出merchant_id判断是否等于 88888。聚簇索引的行数据在磁盘上是按主键顺序排列的但二级索引的顺序和主键顺序完全不一致。也就是说每回表一次基本上就是一次随机磁盘IO。机械硬盘上单次随机IO的延迟在 5~10 毫秒就算你用 NVMe SSD几千次随机读也够喝一壶的。1900 万次回表是什么概念就算命中 page cacheCPU 开销也极其可观一旦有部分数据落到磁盘6 秒钟的慢查询就是这么来的。顺带一提如果这条SQL查的列刚好都在idx_pay_status_create_time索引里比如SELECT id, pay_status, create_time那优化器直接走覆盖索引根本不用回表这个坑也就不存在了。但现实业务查询往往带着一堆业务字段比如pay_amount、merchant_id、channel_type覆盖不全回表就躲不掉。2.4 排序的悖论ORDER BY LIMIT 1 不等于快排只取一条还有一个容易被误解的点很多人以为ORDER BY create_time DESC LIMIT 1是先对全表排序然后取第一条所以担心排序很贵。实际上 MySQL 对ORDER BY LIMIT是有 top-N 堆排序优化的排序过程只需要在内存里保留 N 个最小值/最大值并不会把全部数据完整排序一遍。这也是为什么不带 LIMIT 时走idx_merchant_id filesort 也只要 80ms——它只排了 500 行。真正的问题是因为有了 LIMIT优化器错误地选择了顺着索引找第一条的路径结果把排序省了却引入了数量级更大的回表操作。这是典型的捡了芝麻丢了西瓜——省下一件小事赔上一件大事。换句话说ORDER BY LIMIT 1本身不是坏东西坏的是它诱导优化器去依赖一个不成立的数据均匀假设。3. 动手实验用 EXPLAIN 和 optimizer_trace 定位真凶3.1 实验环境与准备想复现这个问题不需要动线上库一台 MySQL 8.0 测试实例就够了。我推荐用 8.0.18 以上的版本因为后面要用到EXPLAIN ANALYZE这个神器。造数脚本可以直接用递归 CTE快速插入 2000 万行数据INSERT INTO pay_order (merchant_id, pay_status, pay_amount, channel_type, create_time) WITH RECURSIVE seq AS ( SELECT 1 AS n UNION ALL SELECT n 1 FROM seq WHERE n 20000000 ) SELECT FLOOR(RAND() * 10000), IF(RAND() 0.95, 1, IF(RAND() 0.6, 0, 2)), ROUND(RAND() * 10000, 2), FLOOR(RAND() * 5), NOW() - INTERVAL FLOOR(RAND() * 365 * 12) DAY FROM seq;注意这个造数方式很关键。它让merchant_id和create_time之间没有相关性数据在时间轴上大致均匀分布。然后手动把目标商户的订单时间全部改成一年前UPDATE pay_order SET create_time 2022-01-01 00:00:00 WHERE merchant_id 88888;这一步是刻意制造数据倾斜。优化器不知道那 500 条订单全挤在过去它还天真地以为它们均匀散落在时间轴上。3.2 第一步EXPLAIN 对比执行计划先执行不带 LIMIT 的版本EXPLAIN SELECT id, pay_amount, create_time FROM pay_order WHERE merchant_id 88888 AND pay_status 1 ORDER BY create_time DESC;再执行带 LIMIT 的版本EXPLAIN SELECT id, pay_amount, create_time FROM pay_order WHERE merchant_id 88888 AND pay_status 1 ORDER BY create_time DESC LIMIT 1;两张执行计划放一起结论一目了然指标不带 LIMIT带 LIMIT 1访问类型 typerefindex使用的索引 keyidx_merchant_ididx_pay_status_create_time预估扫描行数 rows50019000000是否回表是500次是预估数万至千万次ExtraUsing where; Using filesortUsing wheretypeindex和rows19000000放在一起基本已经告诉你这条SQL要顺着 1900 万行的索引从头摸到尾。如果只做到这一步就下结论优化器傻了那还是不够的。我们要继续问它为什么这么选3.3 第二步用 optimizer_trace 看优化器的内心戏MySQL 的optimizer_trace能输出优化器在选择执行计划时的详细思考过程。开trace、执行SQL、查看输出三个步骤SET optimizer_trace enabledon; SELECT id, pay_amount, create_time FROM pay_order WHERE merchant_id 88888 AND pay_status 1 ORDER BY create_time DESC LIMIT 1; SELECT * FROM information_schema.OPTIMIZER_TRACE\G输出里有一大段 JSON最关键的是considered_execution_plans和cost。我截取简化后大概是这种感觉{ considered_execution_plans: [ { plan_prefix: [], table: pay_order, best_access_path: { considered_access_paths: [ { access_type: ref, index: idx_merchant_id, rows: 500, cost: 98.7, chosen: false }, { access_type: index, index: idx_pay_status_create_time, rows: 19000000, cost: 1.2, chosen: true, limit_cost: early_stop_expected } ] } } ] }注意那个对比走idx_merchant_id的成本是 98.7走idx_pay_status_create_time的成本只有 1.2。为什么一个要扫 1900 万行的路径成本反而低因为优化器把LIMIT 1的提前终止算进去了它认为扫到第一条就能停所以成本模型按预计扫描 3800 行就命中来算最终成本只有 1.2。这不是 bug而是特性——只是它的预计踩空了。3.4 第三步用 EXPLAIN ANALYZE 验证预估和现实的差距MySQL 8.0.18 之后可以直接用EXPLAIN ANALYZE看到真实执行过程这是整场排查里最让人头皮发麻的一步EXPLAIN ANALYZE SELECT id, pay_amount, create_time FROM pay_order WHERE merchant_id 88888 AND pay_status 1 ORDER BY create_time DESC LIMIT 1;输出结果关键字段简化- Limit: 1 row(s) (actual time6412.541..6412.541 rows1 loops1) - Filter: (pay_order.merchant_id 88888) (actual time6400.336..6400.336 rows1 loops1) - Index scan on pay_order using idx_pay_status_create_time (actual time0.047..6100.485 rows19245678 loops1)看到了吗预估扫描 1900 万行实际扫描了 1924 万行左右基本是把这个索引段从头到尾翻了一遍。优化器心里想的扫 3800 行就停彻底破产。而如果不带 LIMIT走idx_merchant_id的真实执行时间是 82ms实际扫描 500 行。这两个数字摆在一起比任何解释都有说服力。3.5 第四步ANALYZE 之后的变化这时你可能会想是不是统计信息不准确那我跑一下ANALYZE TABLE pay_order;会不会就好了实测下来ANALYZE TABLE只能让优化器对pay_status 1的基数估算更准确但解决不了merchant_id在时间轴上倾斜分布的问题。因为优化器压根没有merchant_id和create_time之间的联合分布信息。执行完ANALYZE TABLE再EXPLAIN你大概率还是看到idx_pay_status_create_time被选中执行时间也不会改善。这说明一个残酷的事实有些慢查询不是统计信息过期造成的而是统计信息天生的局限造成的。优化器能拿到单列分布、单索引基数但它拿不到某个商家的订单在时间轴上如何分布这种多列相关性信息。想根治只能从索引设计和SQL改写下手。4. 这类问题的药方与根治思路4.1 先给能用的几种SQL改写方案面对这种加了 LIMIT 1 反而慢的查询按优先级排序有以下几种方案。方案一强制走正确索引。如果你想快速止血不改表结构可以这样SELECT id, pay_amount, create_time FROM pay_order FORCE INDEX (idx_merchant_id) WHERE merchant_id 88888 AND pay_status 1 ORDER BY create_time DESC LIMIT 1;FORCE INDEX能强制优化器走idx_merchant_id让查询回到 80ms 的路径。但这个方案治标不治本因为真实业务的查询条件可能更复杂一旦SQL里多了其他条件强制索引可能会误伤其他场景。线上使用要谨慎最好配合详细注释说明原因。方案二重写SQL缩小排序范围。把先确定候选集再排序取第一条拆成两步SELECT id, pay_amount, create_time FROM ( SELECT id, pay_amount, create_time FROM pay_order WHERE merchant_id 88888 AND pay_status 1 ORDER BY create_time DESC LIMIT 100 ) t ORDER BY create_time DESC LIMIT 1;内层加上一个稍大的LIMIT 100让优化器意识到候选集很小然后外层再取第一条。这种写法在某些版本里能骗过优化器但本质还是侥幸不推荐作为通用方案。更可靠的做法是直接建一个合适的索引让优化器无脑选对。方案三建立联合索引。这才是根治方案ALTER TABLE pay_order ADD KEY idx_merchant_status_time (merchant_id, pay_status, create_time DESC);有了(merchant_id, pay_status, create_time DESC)这个联合索引查询条件里的等值列merchant_id和pay_status都在索引前缀中排序字段create_time也在索引里优化器可以直接通过索引定位到该商户的已支付订单并且按时间倒序拿到第一条几乎不需要排序和回表。实测执行时间可以压到 1ms 以内。4.2 更根本的索引设计与统计信息维护很多人把这种问题归咎于MySQL 优化器太蠢但更准确的说法是我们在建索引的时候没有考虑到数据分布和查询模式的匹配度。联合索引设计有一条朴素经验等值条件放前面排序条件放中间/后面需要查询的列能覆盖就尽可能覆盖。比如这个案例merchant_id和pay_status都是等值条件放前面create_time是排序条件放后面。这样索引天然满足按商户过滤后按时间排序的需求。另外InnoDB 的统计信息是采样估算的默认innodb_stats_persistent_sample_pages是 20 页。对于超大表、数据倾斜严重的表20 页采样很容易产生偏差。可以适度调大SET GLOBAL innodb_stats_persistent_sample_pages 100;大批量 DML 之后养成手动ANALYZE TABLE的习惯不要等后台自动重算。还有一点很多人忽略冗余索引会放大优化器选错路径的概率。像案例里的idx_pay_status_create_time如果你没有真正常用(pay_status, create_time)这个组合条件这种索引在倾斜数据下就是个定时炸弹。定期用sys.schema_unused_indexes查一下哪些索引从未被使用该删就删。4.3 什么时候 LIMIT 1 是好的优化什么时候是坑场景LIMIT 1 的收益典型风险查询列全部在索引内覆盖索引扫描极少行即可返回非常快低等值条件选择性极强如唯一键、主键必然命中直接返回低无 ORDER BY只取任意一条任意一条即可容易提前终止低等值条件选择性弱 没有合适的联合索引优化器可能误选低效索引高ORDER BY 字段不在联合索引内排序回表双重代价高数据倾斜严重 多条件关联统计信息无法感知分布高一句话总结LIMIT 1只有在优化器对命中第一条的位置判断准确时才安全。只要这个判断建立在错误的均匀分布假设上它就是慢查询的帮凶。5. 实战复盘一次线上事故的完整排查过程5.1 事故时间线有一次线上告警某商户后台的最近一笔订单查询接口 P99 延迟从 200ms 飙到 30 秒监控平台连续报警。那条SQL通过慢查询日志捞出来加了 LIMIT 1。第一反应是又有人加错索引了结果看了一圈表结构和索引都没人动过。翻了下最近的上线记录发现前一天有个定时任务批量更新了历史订单的pay_status把大量已收货订单标记成了已支付。执行完大概更新了 1000 万行而且没有触发统计信息自动重算。这个操作让pay_status 1的比例瞬间从 50% 涨到 95%而优化器还在用旧的统计信息做决策。5.2 排障过程实录排查过程其实是前面实验的翻版我按现场操作顺序记录一下关键步骤第一步看慢查询日志确认SQL文本发现LIMIT 1存在。第二步EXPLAIN一看typeindex走的是idx_pay_status_create_time预估 rows 只有几万——因为统计信息还停留在pay_status1 占 50%的旧时代而实际需要扫描的行数已经接近全表。第三步EXPLAIN ANALYZE实测实际 scan rows 高达 1800 万优化器预估和现实差了三个数量级。第四步查看SHOW INDEX FROM pay_order发现idx_pay_status_create_time的 Cardinality 明显偏低且last_update时间戳还是批量更新之前。第五步临时用FORCE INDEX (idx_merchant_id)把SQL救回来接口恢复。第六步深挖业务特征才发现不是因为单纯的统计信息过期而是批量更新让数据分布完全变了形旧的统计信息低估了pay_status1的行数而merchant_id的订单在时间轴上分布又极不均匀。两个因素叠加优化器彻底迷失。5.3 根因与改进根因有三层批量 UPDATE 后统计信息滞后导致基数估算失真存在一个(pay_status, create_time)的低选择性冗余索引给了优化器一个看起来很诱人的错误选项业务查询的merchant_id条件在时间维度上严重倾斜而优化器没有多列分布感知能力。最终修复不是简单ANALYZE完事而是把表和索引一起治理-- 1. 删除诱导优化器的冗余索引 ALTER TABLE pay_order DROP KEY idx_pay_status_create_time; -- 2. 新建符合查询模式的联合索引 ALTER TABLE pay_order ADD KEY idx_merchant_status_time (merchant_id, pay_status, create_time DESC); -- 3. 对相关列建立直方图辅助优化器理解单列分布可选 ANALYZE TABLE pay_order UPDATE HISTOGRAM ON pay_status, channel_type;重新上线后该接口延迟稳定在 1ms 左右。这个案例后来被我们写进了团队内部的索引设计规范建索引前先问自己这个索引到底为哪条SQL服务它的条件列和排序列能不能被索引完整覆盖。如果只是个感觉有用的索引那它大概率有一天会坑你。6. 常见问题速查与避坑经验6.1 问题速查表典型现象可能原因排查建议加了 LIMIT 1 后走错索引统计信息滞后或者索引选择性问题先ANALYZE TABLE再用EXPLAIN ANALYZE看实际扫描行数ORDER BY LIMIT 1 触发 filesort 且很慢排序字段不在索引里且过滤条件弱建立(等值列, 排序列)联合索引条件列选择性极差如状态字段低基数索引 大量回表不要迷信状态索引优先等值列排序列的联合索引子查询里的 LIMIT 1 没有下推优化器限制重写为 JOIN 或使用派生表数据倾斜严重但 EXPLAIN 预估正常统计信息无法感知多列相关性靠联合索引根治不要依赖统计信息自动变准6.2 三个亲测有效的排查技巧第一个技巧遇到离奇慢查询先看EXPLAIN ANALYZE不要急着加索引。它最直接的价值是让你看到优化器预估的 rows和实际扫描的 rows之间的差距。这个差距一旦超过一个数量级基本就是统计信息或者成本模型决策出了问题。先补统计信息再考虑加索引顺序不要反。第二个技巧善用optimizer_trace看成本对比。有时候 EXPLAIN 只告诉你它选了 A 索引但不告诉你为什么没选 B 索引。optimizer_trace里的cost数值会诚实地告诉你优化器是因为认为 A 更便宜才选的。而这个更便宜往往建立在错误的 LIMIT 提前终止假设上看穿这一点问题就解决了一半。第三个技巧数据倾斜场景下直方图是很好的辅助工具。MySQL 8.0 的ANALYZE TABLE ... UPDATE HISTOGRAM ON 列名可以给单列建立分布直方图优化器能更准确地估算单列过滤性。但它解决不了多列之间的相关性比如某个商户的订单集中在过去这种相关性只能靠联合索引来兜底。还有一个很多人忽视的细节LIMIT 1 OFFSET 0和LIMIT 1在部分版本里的执行计划都有可能不一样排查的时候如果发现同样的SQL在不同环境表现不同可以检查一下写法是否完全一致。我遇到过因为代码生成器自动加上OFFSET 0导致执行计划漂移的案例查了一下午才定位到是这一个字符的差异。最后再分享一个小经验说真的做了这么多年数据库运维我越来越觉得优化器选错索引这件事不能完全怪优化器。它和我们人一样只能基于已知信息做决策。你不能指望一个只知道平均水位的决策者自动绕开数据分布里的深坑。所以真正靠谱的做事方式是先把表结构和查询模式匹配好把可能诱导误判的冗余索引清掉把统计信息喂准然后再谈 SQL 写法优化。如果你下次再遇到加了 LIMIT 1 反而慢了的情况别急着删 LIMIT也别急着FORCE INDEX。按这个思路来一遍EXPLAIN ANALYZE看实际扫描量optimizer_trace看决策依据再回头看索引设计。十次里面有九次答案都写在预估 vs 实际这几个数字的悬殊差距里。剩余一次把联合索引建上世界就清净了。
返回列表