直接用limit offset, size是性能定时炸弹,因mysql需扫描并丢弃前offset行,导致io和cpu开销线性增长;应改用延迟关联(子查询只查主键+外层join)或游标分页(where id > last_id)。

直接用 LIMIT offset, size 就是性能定时炸弹
存储过程里写 LIMIT p_offset, p_size 看似封装干净,实则把慢查询藏得更深。MySQL 存储过程每次调用都会重新解析 SQL,不缓存执行计划;一旦 p_offset 超过 10 万,优化器大概率放弃走索引,退化为全表扫描或文件排序。实测千万级订单表上,LIMIT 100000, 20 查询从 0.1 秒飙升到 3 秒以上,且波动剧烈——你根本没法压测、难定位、监控也看不到真实慢 SQL 来源。
- 别信“只是封装逻辑”的说法:它掩盖了扫描前 N 行的硬伤
- 禁止在存储过程中用
CONCAT+EXECUTE拼动态 SQL,预编译失效,执行计划不可控 - 子查询若写成
SELECT *而非只查主键,会导致回表放大,IO 翻倍
延迟关联必须这么写才有效
核心是“子查询只拿主键 + 外层 JOIN 原表”,绕过大偏移扫描和无效回表。这不是语法问题,而是执行计划能否真正走索引的关键。
- 子查询必须只返回
id(或唯一主键),且WHERE和ORDER BY字段要落在同一个联合索引里,例如(status, create_time) - 外层必须用
INNER JOIN ... USING(id),不能写成IN (SELECT id ...)—— 后者在千万级数据下仍可能触发临时表或文件排序 - 示例正确写法:
SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders WHERE status = p_status ORDER BY status, create_time LIMIT p_offset, p_size ) AS tmp USING(id);
游标分页更适合存储过程的天然状态能力
存储过程有 INOUT 参数,天生适合维护上一页最后一条的游标值。它不依赖计算 OFFSET,也就彻底避开 MySQL 扫描前 N 行的瓶颈。
- 排序字段必须单调可比较:优先用自增
id;若用create_time,必须补id防止毫秒重复,写成ORDER BY create_time, id -
WHERE条件要写成WHERE create_time > @last_time OR (create_time = @last_time AND id > @last_id),否则漏/重数据 - 用
INOUT参数传游标值,而不是让应用层拼好 SQL 再传进来——后者破坏参数化,易触发隐式转换
参数类型不匹配会让索引完全失效
看起来只是传几个 IN 参数,但稍不注意,索引就悄悄失效。这不是报错,而是执行计划变糟,你根本察觉不到。
- 如果字段是
TINYINT,但参数声明为VARCHAR,MySQL 会做隐式转换,idx_status_created直接失效 - 别在
WHERE中对字段用函数,比如DATE(create_time) = @date,哪怕create_time有索引也没用 - 联合索引顺序必须匹配:建的是
(status, create_time),那WHERE create_time > ? AND status = ?只能用到status的等值部分,create_time的范围条件被忽略
游标分页虽稳,但它不支持“跳到第 1000 页”这种需求;延迟关联虽快,但要求严格对齐索引和字段顺序。真正难的不是写出来,而是让每一步都落在 MySQL 优化器愿意走索引的路径上——而这个路径,往往藏在参数类型、索引定义、子查询结构这些不起眼的地方。











