职位列表分页,前 20 页嗖嗖的,翻到 50 页以后明显卡顿,接口从 90ms 涨到 900ms。同样的 SQL,LIMIT 0, 20和LIMIT 980, 20差距能到十倍。以前觉得"不就是 limit 吗",直到线上被打脸,才认真把深分页的账算明白。这篇用实测数据说话。
先看慢在哪:LIMIT 大偏移的真实开销
表还是那张 300 万行的job_post,列表查询长这样:
SELECTid,company_id,salary_min,post_timeFROMjob_postWHEREcity_code='440300'ANDstatus=1ORDERBYpost_timeDESCLIMIT980,20;执行计划看着还行——有索引、没全表扫。但LIMIT 980, 20的实际动作是:InnoDB 先把前 980 行全部扫描出来丢掉,再返回最后 20 行。偏移越大,白白扫描和丢弃的行越多,行数是指数级上涨的:
LIMIT 0, 20 → 扫描 20 行 LIMIT 980, 20 → 扫描 1000 行 LIMIT 99980, 20 → 扫描 100000 行更狠的是排序。ORDER BY post_time DESC如果没吃到索引,MySQL 要把符合条件的所有行都放进 filesort 排序,排完再切分页——数据量大起来,filesort 直接吃掉几秒。深分页慢的本质:不是查 20 行慢,是"排序 + 扫描丢弃前 N 行"慢。
方案一:延迟关联,先取 id 再回表
思路:先只查主键,跳过回表,等把该返回的 20 个 id 定下来,再 join 回原表取完整字段。分页子查询里的扫描依然存在,但扫描的是覆盖索引(只读 id 和排序列),不碰行数据,省掉大量回表 IO:
SELECTt.id,t.company_id,t.salary_min,t.post_timeFROMjob_post tINNERJOIN(SELECTidFROMjob_postWHEREcity_code='440300'ANDstatus=1ORDERBYpost_timeDESCLIMIT980,20)tmpONt.id=tmp.idORDERBYt.post_timeDESC;实测:同样LIMIT 980, 20,延迟关联把耗时从 900ms 压到 210ms 左右。代价是写法丑,MyBatis-Plus 里要写自定义 SQL,PageHelper 帮不了你。适合"偏移不是特别大,但回表量明显"的场景,是性价比最高的第一步。
方案二:游标分页,把"偏移"换成"起点"
列表是"按时间倒序往下翻"的话,可以不用页码,改用"上次最后一条的位置"继续翻。MySQL 里最朴素的实现就是带 WHERE 的 LIMIT:
-- 第一页SELECTid,company_id,salary_min,post_timeFROMjob_postWHEREcity_code='440300'ANDstatus=1ORDERBYpost_timeDESCLIMIT20;-- 下一页:传上次最后一条的 post_time 和 idSELECTid,company_id,salary_min,post_timeFROMjob_postWHEREcity_code='440300'ANDstatus=1AND(post_time<'2026-10-08 12:00:00'OR(post_time='2026-10-08 12:00:00'ANDid<102456))ORDERBYpost_timeDESCLIMIT20;关键在post_time相同的情况——时间可能重复,所以必须加 id 做第二排序键,否则同一秒发布的职位会翻页丢失或重复。游标分页不管翻多少页,扫描量恒等于"本页该读的行",100 页和 1 页耗时基本一样,这才是根治。
踩坑记录:游标分页把"倒序"翻成了"正序"
现象:游标分页上线后,用户反馈列表越翻越乱,翻几页后出现重复数据,而且新发布的职位不出现。
排查过程:先查接口入参,前端传的 lastPostTime 和 lastId 都正常。再看 SQL,发现问题在排序方向——列表要post_time DESC(新的在前),游标条件写的却是post_time > lastPostTime(继续找更新的),倒序列表配正序游标,永远找不全。
定位思路:游标条件和排序方向必须配套。DESC排序要往下翻,游标条件是"比上一条更小(或相等且 id 更小)";写成"更大"就整个反了。代码里两个地方不一致——接口层排序写 DESC,SQL 拼接游标条件时复制错了模板,方向没跟着改。
最终解决:游标条件生成收敛成一个方法,方向参数化,杜绝手拼:
// direction = -1 表示倒序(新的在前),游标取更小值publicstaticStringbuildCursorCondition(intdirection,StringtimeCol,StringidCol,ObjectlastTime,ObjectlastId){if(lastTime==null){return"";// 第一页无游标}if(direction<0){return"("+timeCol+" < ? OR ("+timeCol+" = ? AND "+idCol+" < ?))";}return"("+timeCol+" > ? OR ("+timeCol+" = ? AND "+idCol+" > ?))";}方案三:别翻那么深——产品侧限深
说到最后,还有个大实话:业务上没几个用户真会翻到第 100 页。列表 + 搜索场景,产品侧把翻页上限卡在 50 页,超出提示"条件太窄,换个筛选",配合搜索条件本身就能过滤大部分深分页流量。技术方案做得再好,也不如产品上不让它发生。
可直接复用的清单
- 深分页慢的本质:排序 + 扫描丢弃前 N 行,不是查 20 行慢。
- 中浅偏移先上延迟关联(子查询先取 id 再回表),改动小见效快。
- 无限滚动列表直接游标分页:排序键 + 唯一键双条件,扫描量恒定。
- 游标方向必须和排序方向配套:DESC 配"小于",ASC 配"大于",条件生成统一收口。
- 时间戳可能重复,游标必带 id 第二排序键,否则翻页丢数据。
- 产品侧限深分页(上限 50 页),技术 + 产品双管齐下。