开场:先看结果
| 写法 | 耗时 | 一句话原因 |
|---|---|---|
| 裸跑 LIMIT 400000,10 | 741ms | 选择性 96%,优化器正确拒绝索引 |
| 延迟关联 + 旧索引 | 464ms | 覆盖失败,仍全表扫 |
| 新复合索引 + 值写错 | 364ms | 隐式转换废掉索引一级目录 |
| 新复合索引 + 类型对齐 | 197ms | 覆盖 + 索引有序免排序 |
| keyset 游标(OR 写法) | 0.13ms | 只扫需要的 10 行 |
| 低区分度列单独索引 | 比全表扫慢 30% | 成本模型算错账,优化器被骗 |
全文所有数字来自 MySQL 8 EXPLAIN ANALYZE 实测(warm 值),表为 50 万行订单表。
一、实验条件与造数
表:order_info,50 万行。48 万行 create_time 落在 2026 年内、2 万行 2025 年;order_status='1' 占 90%,条件命中 43.2 万行——保证 offset 40 万落在命中集内部,深分页是真的"深"。列类型以 SHOW FULL COLUMNS 为准:order_status varchar(20)、is_deleted tinyint、create_time timestamp。原有索引 idx_order_time(is_deleted,create_time,id) 等五个。
〔图1:造数验收 total=500000 / cond_hits=432000〕
〔图2:SHOW FULL COLUMNS 全列"验伤报告"〕
造数先踩了一个坑,值得单说:我一开始想用 JMeter 打 50 万线程"造"订单,1 分 48 秒 0/500000 卡死,最终只出 1000 单。两个原因:业务接口有库存封顶(stock=1000,卖光后全部业务拒绝);50 万线程远超单机 JMeter 能力。概念纠偏一句话:造数模拟的是几个月业务累积态,用 SQL 批量写;压测模拟的是瞬时峰值,才用 JMeter。改用递归 CTE 生成序列表 + 一条 INSERT...SELECT,分钟级完成。
二、进化 1:裸跑 741ms——优化器拒绝索引是对的
EXPLAIN ANALYZE ... WHERE is_deleted=0 AND order_status=1 AND create_time>='2026-01-01' ORDER BY create_time LIMIT 400000,10;
计划:Table scan 500000 行 + Filter 432000 + Sort 400010,actual 741ms。
〔图3:t1 全表扫 EXPLAIN〕
别骂优化器傻。时间范围命中 48 万/50 万=96%,走索引意味着 48 万次回表,比顺序全表扫更贵。有索引≠该用索引,选择性决定一切。
三、进化 2:延迟关联 + 旧索引 464ms——覆盖失败
子查询只取 id,本想吃覆盖索引的红利,但 order_status 不在 idx_order_time 里,每行仍要回表判断状态 → 覆盖失败 → 优化器继续选全表扫。延迟关联不是万能药:子查询的过滤列不在索引里,等于白写。
〔图4:t2 仍全表扫 EXPLAIN〕
四、进化 3:新复合索引但值写错 364ms——skip scan 与两次抢救失败
新建 idx_del_status_ct(is_deleted, order_status, create_time),理想情况是三级目录顺翻:等值+等值+范围,且第三级天生有序免排序。实测却出现 Index skip scan + Sort 40 万行,364ms。
〔图5:skip scan + Sort EXPLAIN〕
两次抢救均失败:ANALYZE TABLE 刷新统计后计划不变;FORCE INDEX 按着头用仍 skip scan。这两次失败是重要证据——不是优化器选错路,是路本身断了一级。
断在哪?order_status 是 varchar(20),SQL 里写 order_status = 1(数字),每行要先隐式转成数字再比较,转换加在列上=该列无法做范围键=复合索引第二级整级作废。skip scan 是 MySQL 的补救动作:跳过坏掉的那一级,把 0/1/2 三个取值各扫一遍时间范围,拼出 48 万行再过滤、再排序。所有多余耗时都来自这套补救。
〔图5:t3 仍全表扫 EXPLAIN〕
五、进化 4:加一个引号 197ms——覆盖 + 有序
order_status = '1' 后重跑:Covering index range scan,Sort 节点消失,197ms。
〔图6:covering range 无 Sort EXPLAIN〕
三级目录通顺后,顺着读就是排序结果,40 万 offset 只是顺读 40 万索引项,不回表不排序。一个引号的差距:1.9 倍耗时 + 整个排序开销。
六、进化 5:keyset 游标 0.13ms——行构造器的语法卫生
offset 型分页的本质浪费:先捞前 40 万行再扔掉。游标型只问"上一页最后一条之后是谁"。
坑一:(create_time, id) > (x, y) 行构造器在本表未被推进索引范围,838~2382ms;坑二:游标条件接管后若保留 offset 时代的旧下界,范围合并失败。
改用 OR 展开并删除旧下界:
WHERE is_deleted=0 AND order_status='1' AND (create_time > '2026-08-28 07:24:00' OR (create_time = '2026-08-28 07:24:00' AND id > 344604)) ORDER BY create_time, id LIMIT 10;Index range scan,rows=10,actual 0.13ms。与页深无关,这才是生产写法。
〔图7:OR 写法 range rows=10 EXPLAIN〕
七、隐式转换专案:坑位在手写入口,不在 DDL
varchar 存状态是合法的企业约定(状态本质是字典码)。本项目实体 OrderInfo.orderStatus 为 String,与列类型天然对齐,ORM 路径全程安全——踩坑的是手写测试脚本的数字字面量。
所以治理结论不是"改列类型",而是类型对齐原则:列类型服从团队字典约定;所有入口(ORM 实体、手写 SQL、报表脚本、DBA 临时查询)传参类型必须与列类型对齐;对手写 SQL 做评审与 EXPLAIN 抽查。识别隐式转换 (复合索引断路导致速度下降) 的三个签名:skip scan 字样、Filter 里残留索引列条件、估算行数与实际差数倍;FORCE 与 ANALYZE 无效可佐证"路断了"。
〔图8:information_schema 三行类型实锤〕
八、低区分度列实验:优化器被骗了一次
新增 gender tinyint(1男2女各 50%)与单独索引 idx_gender。
g1 默认计划:优化器主动选 idx_gender,25 万次回表,786~1043ms;g2 IGNORE INDEX 全表扫基准:570~609ms;g3 FORCE:与 g1 同计划,786ms。
〔图9:g1/g3 Index lookup 25 万回表 EXPLAIN〕〔图10:g2 全表扫基准 EXPLAIN〕
成本模型里索引路 cost=26428 < 全表路 cost=50330,但回表单价被低估,实测反转。结论:低区分度列的单独索引是双层负资产——最好情况被无视(白付写入成本),最坏情况被误选(本例),比没索引还慢 30%。它的正确位置是复合索引的首列等值列:对照 is_deleted 同样只有两个取值,却在 idx_del_status_ct 中层层裁剪立功。(实验后已 DROP INDEX idx_gender 清场。)
九、总结:索引方法论三看 + 弯路清单
三看:看选择性(命中占比)、看位置(首列等值/范围列)、看覆盖(子查询能否免回表)。三者皆失,索引即负资产。
弯路清单:① 96% 选择性弃索引(它是对的);② 覆盖失败延迟关联空转;③ 隐式转换断索引一级(skip scan 签名);④ 行构造器未推范围 + 旧下界污染;⑤ 低区分度单独索引骗过成本模型。
防坑三件套:建表评审定死列类型与参数对齐约定;核心 SQL 的 EXPLAIN 断言进 CI;批量导数后 ANALYZE TABLE(本次虽非根因,但它是排除嫌疑的第一步)。
十、复现清单
造数:递归 CTE 序列表 50 万 + INSERT...SELECT(48 万近期、status 9:0.5:0.5、分钟级时间戳);索引:原有 idx_order_time(is_deleted,create_time,id),新增 idx_del_status_ct(is_deleted,order_status,create_time);测量纪律:EXPLAIN ANALYZE 连跑 3 次取第 3 次;计划形态与耗时同录。
同系列前篇:《Redisson 看门狗勘误》(分布式锁压测取证),见文末链接。