☰
50 万订单表深分页治理实录:从 741ms 到 0.13ms,顺便收录优化器走的 5 个弯路
2026/9/28 4:39:38 网站建设 项目流程

开场:先看结果

写法耗时一句话原因
裸跑 LIMIT 400000,10741ms选择性 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 看门狗勘误》(分布式锁压测取证),见文末链接。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询