后台订单列表,一开始翻着挺快。
后来运营说,翻到几百页以后特别卡,有时候直接转圈。
我打开慢查询日志,看见这么一条:
SELECT*FROMordersORDERBYidLIMIT40000,20;这条 SQL 是 AI 写的。
offset 的代价是线性的
很多人对LIMIT offset, size的理解是“跳过 offset 行,取 size 行”。
数据库实际做的事是:
先按顺序读出 offset + size 行,然后把前 offset 行扔掉,返回剩下的。
LIMIT 40000, 20的意思不是“从第 40001 行开始读”,是“读 40020 行,扔掉 40000 行”。
所以你翻的页数越多,要读和要扔的行数越多。第 1 页读 20 行,第 1000 页读 20020 行,第 2000 页读 40020 行。
耗时基本和 offset 成正比。
注意这里的关键不是“扫描行数多”,而是这些行是有用的工作吗。不是。前面 40000 行读完就直接丢了,一点价值都没产生。
我拿一张三百万行的表试过,LIMIT 40000, 20和LIMIT 20的差距在几百毫秒量级。看着不多?但这一条查询占着连接、占着 CPU,几十个运营同时翻页,数据库就顶不住了。
为什么 AI 只会这一种写法
因为这是所有教程里的标准写法。
你搜“MySQL 分页”,前十条结果全是LIMIT offset, size。这个写法简单、通用、任何数据库都支持。教程要说的是“分页怎么做”,不是“分页怎么做才快”。
按 popularity 采样出来的结果,就是最普遍的写法,不是最优的写法。
解法一:游标分页(推荐)
不要用 offset,用“上一页最后一条的 id”来定位:
-- 第一页SELECT*FROMordersORDERBYidLIMIT20;-- 下一页:把上一页最后一条的 id 传进来SELECT*FROMordersWHEREid>40000ORDERBYidLIMIT20;这样每次都是“从 id 大于某个值开始,取 20 条”,走索引直接定位,代价是常数级。
代价是:只能顺序翻页,不能跳页。上一页怎么处理也得想清楚(要么缓存,要么反向查)。
对“无限下拉加载”这种场景,这是最优解。对“必须能跳到第 500 页”的后台管理,就得考虑下一条。
解法二:延迟关联
如果必须保留 offset 跳页,用这个:
SELECTo.*FROMorders oINNERJOIN(SELECTidFROMordersORDERBYidLIMIT40000,20)AStONo.id=t.id;原理:先在只查主键的子查询里做分页(主键索引是覆盖索引,扫描代价小),拿到 20 个 id 之后,再用主键回表取完整数据。
扫描行数还是那么多,但每行只读 id,不读所有字段,代价能降一大截。
注意这个方法的前提是有合适的主键或索引。如果排序字段本身没索引,那还是得全表扫,延迟关联救不了。
解法三:改产品设计
前两个都是技术解法。但还有个更直接的:
能不能别让用户翻这么深?
一个订单列表,翻到第 2000 页的用户,大概率不是“想看第 40000 条订单”,而是搜索条件没加。
后台列表正确的做法是逼用户加筛选条件:时间范围、状态、用户 ID,随便哪个都能把数据量降到几百条。翻页问题直接消失。
顺带说一个连带问题:COUNT
AI 写分页通常还会配一个总数查询:
SELECTCOUNT(*)FROMordersWHERE...;COUNT(*)在 InnoDB 里没法走索引直接得到结果(因为 MVCC,每行可见性不同),得实际扫。数据量大的时候,这个查询能和分页查询一样慢。
优化方案:
- 如果只是做 UI 上的“第 x / y 页”,用估算值就够(
EXPLAIN里的 rows,或者维护一张统计表) - 如果非要精确值,那就接受它慢,或者异步算、缓存起来
- 很多产品其实只需要“下一页”,根本不需要知道总数。先问产品这个数字给谁看。
最后一个提醒:分页插件也一样
很多人用 PageHelper 这类分页插件。要清楚它是怎么实现的:
它就是在你的 SQL 后面拼LIMIT offset, size。
你写pageNum=2000, pageSize=20,它拼的就是LIMIT 40000, 20。问题一模一样,只是你看不见那条 SQL 了。
所以用了插件反而更要小心。拼出来的 SQL 你平时不看了。
一句话
分页不是“取第几页”的问题,是“怎么不干无用功”的问题。
LIMIT 40000, 20干了 40000 行的无用功。能不用 offset 就不用。
你们项目里最深的分页是第几页?翻一下试试,看能不能复现。