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`) ) ENGINE=InnoDB;数据量大约 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 cache,CPU 开销也极其可观;一旦有部分数据落到磁盘,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 |
|---|---|---|
| 访问类型 type | ref | index |
| 使用的索引 key | idx_merchant_id | idx_pay_status_create_time |
| 预估扫描行数 rows | 500 | 19000000 |
| 是否回表 | 是(500次) | 是(预估数万至千万次) |
| Extra | Using where; Using filesort | Using where |
type=index和rows=19000000放在一起,基本已经告诉你这条SQL要顺着 1900 万行的索引从头摸到尾。如果只做到这一步就下结论"优化器傻了",那还是不够的。我们要继续问:它为什么这么选?
3.3 第二步:用 optimizer_trace 看优化器的"内心戏"
MySQL 的optimizer_trace能输出优化器在选择执行计划时的详细思考过程。开trace、执行SQL、查看输出,三个步骤:
SET optimizer_trace = "enabled=on"; 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 time=6412.541..6412.541 rows=1 loops=1) -> Filter: (pay_order.merchant_id = 88888) (actual time=6400.336..6400.336 rows=1 loops=1) -> Index scan on pay_order using idx_pay_status_create_time (actual time=0.047..6100.485 rows=19245678 loops=1)看到了吗?预估扫描 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一看,type=index,走的是idx_pay_status_create_time,预估 rows 只有几万——因为统计信息还停留在"pay_status=1 占 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_status=1的行数,而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 实际"这几个数字的悬殊差距里。剩余一次,把联合索引建上,世界就清净了。