1. 先从一次联调事故说起:第1000页数据为什么慢得离谱
我印象很深,去年做电商后台订单列表的时候,分页查询这个最不起眼的功能差点把联调搞崩。运营后台有个订单查询页面,默认按下单时间倒序,单表数据量大概五百多万行,过滤条件组合下来符合条件的订单也有两百多万条。测试同学在页面上连续翻页,翻到一千多页的时候,接口响应时间直接飙到1.2秒。前端转菊花转半天,运营那边截图质问"系统是不是挂了"。
其实页面上的分页组件就那么几个参数:当前页码pageNum、每页条数pageSize,后端对应的SQL基本长这样:
sql复制SELECT id, order_no, user_id, amount, status, create_time
FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 1000, 20;
这种写法在大多数业务系统里都见过,第一页第二页跑得飞快,深翻页就原形毕露。问题出在哪?LIMIT 1000, 20在MySQL里执行的逻辑并不是"找到第1000条之后直接取20条",而是老老实实把前1020行全部扫描出来,然后丢弃前1000行,只返回最后20行。数据量小的时候大家没感觉,一旦表里几百万行、排序字段还没走到理想的索引,扫描成本会随着偏移量线性增长,表越大翻得越深,性能衰减越明显。
我当时先做了一轮基础排查,发现这条SQL的执行计划实际走了status字段的二级索引,然后对create_time做文件排序。也就是说,MySQL要先在status索引上捞出两千多行,再到聚簇索引回表取完整数据,最后排序、掐头去尾。整个过程大部分开销都耗在了"被丢弃的那1000行"上——它们被读出来了、被排序了,最终却连返回给客户端的机会都没有。
这可能就是分页查询最让人忽视的地方:它看起来是个极简单的功能,但真正深挖起来,背后藏着索引选择、排序策略、回表成本、偏移量累积、缓存一致性等一系列问题。这篇文章我想把这几年做分页查询积累的东西系统整理一遍,从最基础的LIMIT机制讲起,到深度分页的优化方案,再到Redis缓存的几种落地模型,最后用一个完整的排查案例把整个思路串起来。适合刚接触后端开发、对MySQL执行计划还处于"看得懂但不会用"阶段的朋友,也适合被线上深分页慢查询折磨过的同学。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种主流分页写法的机制拆解:LIMIT、游标和覆盖索引
不同的人写分页查询,风格差异非常大。我见过老项目里全是LIMIT加偏移量的写法,也见过架构师把游标分页定成团队红线,还有一些偏激进的方案直接上Redis。这些写法没有绝对的好坏,关键要看业务场景和数据特征。先把三种主流写法的底层机制搞清楚,后面优化才有依据。
2.1 最常见的LIMIT OFFSET分页:为什么越翻越慢
LIMIT OFFSET分页的核心逻辑就是"跳过N行、取M行"。很多开发对它的理解是"MySQL会直接跳到第N行开始读",这其实是最大的误解。默认情况下MySQL Server层拿到SQL后,优化器会生成执行计划,存储引擎负责读取数据,Server层再对结果做过滤和排序。LIMIT的语义是在所有结果集确定了之后才执行的,OFFSET越大,要生成的结果集就越大,被白白丢弃的数据就越多。
具体到执行流程,大概分成这几步:
- 根据WHERE条件定位到满足条件的记录,可能走索引也可能全表扫描。
- 对命中的行按照ORDER BY指定的字段排序。如果排序字段恰好是索引覆盖的,顺序扫描就行;否则就要在内存或磁盘上做filesort。
- 排序完成后,从结果集的第OFFSET行开始,取需要的pageSize行返回。
- 其余的行虽然在内存里被构造出来了,但全部丢弃。
所以LIMIT 10000, 20和LIMIT 10, 20在写法上只是数字不同,实际执行成本可能相差几十倍。原因就在于前者需要生成至少10020行的中间结果。MySQL官方文档对这块也有说明:偏移量越大,查询开销越大。它甚至建议,如果确实需要深分页,考虑用其他方式替代大偏移量的LIMIT。
这个机制在地表最直观的测试里能看出明显差异。我拿一张四百万行的订单表测过,在相同过滤条件下只改LIMIT的偏移量,执行时间大致是这样的:
| 分页写法 | 扫描行数 | 返回行数 | 实测耗时(约) |
|---|---|---|---|
| LIMIT 0, 20 | 20 | 20 | 8ms |
| LIMIT 1000, 20 | 1020 | 20 | 25ms |
| LIMIT 10000, 20 | 10020 | 20 | 90ms |
| LIMIT 100000, 20 | 100020 | 20 | 420ms |
| LIMIT 500000, 20 | 500020 | 20 | 超过1.5s |
从这个表可以看到,偏移量从0涨到50万,耗时涨了将近200倍。扫描的行数翻倍,耗时基本也是翻倍的状态。所以当运营说"翻到第1000页变慢了",那不是错觉,是执行机制决定了这种写法在高偏移量下根本无法保持平稳性能。
2.2 游标分页:基于排序字段定位而不是跳行
游标分页的核心思想是:把"跳过N行"换成"从上一页最后一条数据继续往后找"。它不关心前面有多少行,只记住上一页最后一条记录的唯一标识,然后用WHERE条件直接定位到那条记录之后的数据。
最常见的写法是这样的:
sql复制-- 假设上一页最后一条记录的create_time是'2024-03-10 14:23:45',id是168888
SELECT id, order_no, user_id, amount, status, create_time
FROM orders
WHERE status = 1
AND (create_time < '2024-03-10 14:23:45'
OR (create_time = '2024-03-10 14:23:45' AND id < 168888))
ORDER BY create_time DESC, id DESC
LIMIT 20;
这里用CREATE_TIME加ID做联合定位,是为了处理排序字段重复的情况。如果只用create_time做游标,恰好同一秒有几十条订单,上一页结尾和下一页开头之间就可能漏数据或者重复数据。加上ID做二级排序条件,能保证游标的唯一性和顺序的确定性。
游标分页在索引设计合理的情况下,执行计划会变得非常好看:MySQL可以通过联合索引直接定位到游标位置,然后顺序向下扫描20行,不需要扫描和丢弃任何多余数据。它的时间成本与偏移量完全解耦——第1页和第10000页的查询耗时几乎一样。
有得必有失,游标分页也有自己不能覆盖的场景:用户没法随便跳页。如果你的产品需要一个页码导航,写死"跳到第58页看数据",游标方案就不适合了。但如果是移动端App的下拉加载更多、PC端的"加载历史订单"这类线性翻页场景,游标分页应该是最优解。
2.3 覆盖索引分页:先捞主键再聚簇索引回表
还有一类优化思路在LIMIT OFFSET的框架里微调,就是不直接SELECT所有业务字段,而是先只查主键ID,拿到主键列表后再去关联查询完整数据。这样做的价值在于:MySQL在扫描和排序阶段,只需要操作覆盖索引,不需要把每一行的整行数据都捞出来。
具体写法是这样的:
sql复制SELECT t.id, t.order_no, t.user_id, t.amount, t.status, t.create_time
FROM orders t
INNER JOIN (
SELECT id
FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 100000, 20
) tmp ON t.id = tmp.id
ORDER BY t.create_time DESC;
子查询里只查ID和排序字段,如果status + create_time + id能构成一个覆盖索引,那么这一步的扫描成本会大大降低。MySQL在排序时处理的每一行数据都很小,内存和磁盘的消耗都少了,后续再用20个ID回聚簇索引取完整数据,总共只需要20次主键查找。
实测下来,同样的四百万行表,直接LIMIT 100000, 20要430ms左右,用覆盖索引改完能压到150ms上下。虽然还是随着偏移量增大而变慢,但增长率比裸露的LIMIT写法平滑很多。
这个方案的适用场景很广,只要你能接受子查询的写法,几乎所有现有的LIMIT分页代码都能改造成这个模式。它不要求接口协议变化,前端页码照常传,后端SQL改成JOIN就行。所以如果遇到深分页问题但暂时不想动接口协议,这是一个非常值得优先尝试的优化手段。
3. 偏移量陷阱与排序不稳定:深分页的隐藏坑
把三种基础分页写法讲完之后,必须重点聊聊深分页场景里那些平时不会注意、线上偶发问题的隐藏坑。很多系统不是一开始就慢的,而是在数据量涨到一定程度之后突然冒出一堆诡异问题。这些问题如果不理解根源,排查起来特别烦人。
3.1 偏移量累积是如何吃掉数据库CPU的
"偏移量累积"这个词听起来有点抽象,其实说的是:偏移量越大,查询需要处理的数据量越大,但返回的数据量始终是固定的。这意味着数据库的CPU、IO和内存资源,大部分花在生成"被丢弃的数据"上。
想象一下你每天通勤走的高速公路有二十个收费闸口,你从第三闸口进去,每个闸口都要停下来缴费,哪怕你两分钟后就下高速。分页查询里那些被跳过的行就是这些"每个闸口都缴费但没过境"的车辆,数据库为了把它们过滤掉,必须在排序、临时表、内存缓冲上消耗资源。
线上数据库最怕的就是并发深分页。假设一个运营后台有二十个管理员同时在翻第几百页的数据,每个请求都要做一次大偏移量的扫描和排序,数据库的QPS可能不高,但每个请求的响应时间都很长,数据库的连接数很快被打满,最终表现就是"数据库突然卡死,CPU 100%,但是慢查询日志里又都是同一条SQL"。
做监控的时候建议给分页查询单独加监控项,不要只盯总的慢查询数量。深分页的SQL如果没被单独识别出来,很容易淹没在慢查询统计里。我在实际项目中是把SQL文本里LIMIT关键字后面的偏移量做了正则提取,单独统计平均偏移量、最大偏移量、总执行次数,配合慢查询日志,基本能做到提前发现偏移量累积问题。
3.2 翻页过程中数据变动导致的重读和漏读
分页还有一个隐含的"顺序一致性"问题,很多人根本没意识到。假设第一页查出10条数据,用户停留在页面上的这几秒钟里,有新的数据插入,或者已有数据被删除,用户点击下一页时拿到的是不是"原来应该出现的下一页"?
如果查询走的是LIMIT OFFSET,MySQL在每次查询时都是重新扫描和排序,所以当有新数据插入时,你翻到第二页,可能第一页的最后一条数据又在第二页重复出现了;当有数据删除时,可能第二页的第一条数据被"挤上去",导致漏读。
这个问题的根源是LIMIT OFFSET分页没有"稳定的分页锚点",每次查询都是以全量数据的当前状态为基准来偏移。线上有些业务对数据的顺序一致性要求很高,比如运营按时间倒序批量审核订单,如果翻页过程中有订单被其他同事变更了状态,列表排序变化,审核人员就会漏掉一些单子,或者重复审核同一个单子。
解决办法有两个方向:
- 对于顺序一致性要求严格的业务,用游标分页替代偏移量分页。游标分页锚定的是上一页最后一条数据的唯一标识,新插入的数据不影响下一页的起点,删除的数据最多导致游标指向的那条不存在,最多是边界少一条,但不会重读也不会漏读。
- 如果必须保留偏移量分页,可以在查询时把排序字段的值和ID一起作为查询条件,模拟"上一页最后一条"的语义,降低数据变动的影响。
3.3 排序字段不唯一带来的乱序和重复
排序字段不唯一导致的乱序问题也很有隐蔽性。很多业务的分页SQL用的是ORDER BY create_time DESC LIMIT 0, 20,而create_time只精确到秒。同一秒内可能有几十条甚至上百条数据,那么ORDER BY create_time完全无法确定这几十条之间的先后顺序。
MySQL在这种情况下并不会报错,它会按照自己认为合适的顺序返回,这个顺序可能是主键顺序、可能是插入顺序、也可能是临时表的物理存储顺序,而且不同页之间还不保证一致。结果就是:第一页显示ID=100在ID=200前面,第二页又变成ID=200在ID=100前面。用户会明显感觉到列表"闪了一下"。
推荐的做法是排序字段必须带上一个唯一字段做二级排序,一般是主键ID。ORDER BY create_time DESC, id DESC会保证同一秒内的数据也按照id倒序排列,顺序稳定,分页结果确定。配合游标分页使用,还能避免漏读问题。
我在代码审查时有一条硬性规则:所有分页SQL的ORDER BY子句,最后一个字段必须是主键或者唯一索引字段。这个规则简单粗暴,但能避免绝大部分顺序问题。
4. Redis优化分页查询的可行性边界:缓存什么、怎么缓存
分页查询慢,很多人第一反应就是"上Redis"。但Redis不是银弹,它有自己的适用场景和局限性。我在项目里实际压过几种方案,负责任地说一句:缓存全量列表到Redis,然后通过LRANGE做分页,是大流量场景的常见错误示范。
4.1 Redis能不能直接存全量分页列表
我见过有团队把所有订单数据的主键塞进一个Redis List,然后用LRANGE 0, 20、LRANGE 20, 40这种命令做分页。单看性能的话,Redis的LRANGE命令复杂度是O(offset + limit),内存操作,确实能到微秒级别,比MySQL快几个数量级。但问题在于这个列表的构建和维护。
订单表每天新增几万行,旧的订单可能被修改状态、被删除。把所有ID塞进Redis的代价是什么?第一,同步逻辑极其复杂,每次数据库变更都必须同步更新这个Redis List,漏一条ID就导致整个分页错位;第二,列表长度会随时间无限增长,几百万个ID占用几百MB甚至上GB内存,成本快速上升;第三,一旦Redis重启或缓存淘汰,需要重建整个列表,重建期间的查询全部压到数据库,可能出现雪崩。
我的建议是:直接用Redis存全量分页列表,只适合"数据量可控、变更不频繁、读多写少"的场景,比如配置列表、白名单列表、静态资源列表。对于订单、交易、日志这类高频写入的流水型数据,不要这么干。
4.2 缓存ID列表还是缓存结果集:两种缓存模型的选择
业界更稳妥的做法是:MySQL负责计算和排序,Redis缓存计算结果片段。也就是说,用MySQL查询出当前页的数据ID列表,然后把这一页的结果按页缓存到Redis里,下一页如果同一页被再次访问,直接命中缓存,不再打数据库。
这种方案的具体流程是:
- 第一次请求第N页,MySQL执行分页SQL,查出一页数据的ID和必要字段。
- 把结果序列化后存到Redis,key形如
page:orders:status:1:page:100:size:20,设置合理的过期时间,比如5分钟。 - 后续同一个用户或者不同用户点击第100页,直接读这个key,Redis命中就返回,不查MySQL。
这种模式的优势是:MySQL每次都只承担正常的分页查询成本,没有额外负担;Redis只缓存热点页,不会无限膨胀;数据变更后,只需要删除对应页码的缓存key,不会影响其他页。
代价是:用户每翻到一个新页,还是会触发一次MySQL查询,只是把重复点击某页的成本降下来了。适合"少数页被反复访问"的场景,比如运营经常翻最后几页看最新订单,或者电商活动页的某几页被大量用户同时浏览。
4.3 缓存COUNT结果:一个经常被忽略的优化点
除了缓存列表页本身,分页查询里还有另外一个很容易被忽视的性能瓶颈:COUNT查询。列表页接口为了返回总页数,通常需要执行一条SELECT COUNT(*) FROM orders WHERE status = 1。这张表如果行数多、过滤条件复杂,COUNT可能比分页查询本身还要慢。
Redis可以帮忙缓存这个COUNT结果。思路是:用一个key保存符合当前过滤条件的总记录数,设置短过期时间比如30秒或者1分钟。MySQL在数据变更时,或者通过定时任务,更新这个COUNT值。首页接口返回数据时,总页数直接从Redis读取,而不是每次都执行COUNT。
这个优化逻辑的合理性在于:分页总页数通常不需要完全精确到秒级。用户看到"总页数1200页",实际数据变成1199页或者1201页,影响非常小。但COUNT从300ms降到1ms,对整个接口的响应时间改善非常明显。
我在生产环境处理过一张接近千万行的流水表,COUNT语句在WHERE条件复杂时执行时间能到500ms以上。加了Redis缓存COUNT的优化之后,接口整体响应从800ms降到了120ms,数据库压力也明显下降。这个收益甚至比缓存页面结果集更高,因为COUNT的调用频率在带分页组件的页面上非常高。
4.4 用Redis ZSET加速"带权重的分页排序"
再往深一层,有一种场景是排序规则比较复杂,比如做一个排行榜或者"按综合热度排序的商品列表"。业务热度值由点击量、销量、评分等多个维度加权计算,是实时变动的。这种情况下用MySQL实时排序,每次查询都要计算权重并做文件排序,数据量大时很容易超时。
可以用Redis的Sorted Set来解决。思路是:预先算好每个商品的热度值,把商品ID和分数写入ZSET,热度变了就更新分数。分页时直接用ZREVRANGE key start stop命令按分数从高到低取ID,再回MySQL捞详情。这种方案把排序计算全部搬到内存里,数据库只负责按主键查询详情,性能非常可观。
但要注意,Sorted Set方案并不适合所有业务。它要求数据总量可控(通常几万到几十万条),热度更新有明确的计算入口(比如埋点上报、消息队列处理),并且允许分数有秒级甚至分钟级的延迟。如果把几百万条流水型数据全塞进ZSET,构建和更新成本会非常高,得不偿失。
5. 一次真实的分页慢查询排查:从现象到根因的完整链路
理论讲了一堆,落到实际项目里到底怎么排查?我把之前处理过的一个典型案例完整复盘一下。这个案例不算复杂,但很能说明分页查询问题的排查思路。
5.1 现象:一个接口突然从150ms涨到3秒
当时线上有一个对外的商品列表接口,接收分类ID、排序方式和页码,返回商品信息和总数。这个接口调用量不算小,峰值时每分钟两万多次。某天监控系统报警,接口P99延迟从150ms涨到了3秒。
我登录服务器先看慢查询日志,发现两条SQL特别扎眼。一条是COUNT语句,另一条是分页查询,都是同一张商品表。商品表当时有两百多万行,通过catalog_id二级索引过滤,但排序字段是sale_num,而sale_num上没有索引。
5.2 排查过程:执行计划揭示的根本原因
手动执行分页SQL,加上EXPLAIN分析,看到了关键信息:
code复制type: ref
possible_keys: idx_catalog_id
key: idx_catalog_id
rows: 284312
Extra: Using filesort
这条SQL走的是catalog_id索引,但只过滤掉了一部分数据,命中了28万行商品,然后在内存中对这28万行按照sale_num做文件排序,最后LIMIT 20。这意味着每个用户不管翻到第几页,数据库都要拿出28万行做一次排序,耗时自然高。
加一个覆盖索引就可以大幅改善:ALTER TABLE products ADD INDEX idx_catalog_sale (catalog_id, sale_num, id)。这样MySQL可以直接从索引里按catalog_id定位,同时sale_num已经有序,不需要filesort,只需要顺序读取20行。
5.3 优化效果:索引调整带来的数量级提升
索引加上之后,执行计划变成了:
code复制type: ref
key: idx_catalog_sale
rows: 20
Extra: Using where
rows估算直接从28万降到20。实测单次查询从2.8秒降到了18ms,达到了量级级别的提升。这个案例里没有用到Redis,单纯靠索引设计就把问题解决了。
为什么这个案例能靠索引解决?因为查询条件简单,catalog_id的区分度足够高,且排序字段固定。如果这个接口的排序规则是动态的,今天按销量明天按价格,后天按上架时间,想用索引覆盖所有排序组合就麻烦了。那种情况下才需要引入缓存、搜索引擎或者NoSQL方案。
5.4 排查清单:遇到分页慢,按这个顺序去查
我把这个排查过程总结成一张清单,线上遇到分页慢的问题,按照下面顺序逐层排查,大多数时候能快速定位问题:
| 排查步骤 | 关键动作 | 常见的坑 |
|---|---|---|
| 1. 抓慢查询日志 | 确认到底是分页SQL慢还是COUNT慢 | 很多慢查询是COUNT引起的,但大家只盯着数据列表 |
| 2. 看执行计划 | 确认type、rows、Extra有没有filesort | rows估算大不代表一定慢,但filesort是危险信号 |
| 3. 检查索引覆盖 | WHERE字段和ORDER BY字段能否命中同一个索引 | 排序字段不在索引里,就会触发filesort |
| 4. 评估偏移量 | LIMIT偏移量是否超过1万甚至10万 | 大偏移量伴随大扫描量,要重点优化 |
| 5. 查缓存命中 | Redis缓存率有没有下降 | 缓存失效风暴会导致突发性延迟上升 |
| 6. 看数据分布 | 过滤条件的区分度如何,是否产生大量重复扫描 | 区分度差的字段加索引效果也不明显 |
6. 针对不同业务场景的分页方案选型建议
没有万能的分页方案,只有适合当前业务场景的方案。我在项目里会根据以下几个维度做选型:数据规模、访问模式、实时性要求、接口协议约束、团队维护成本。这里给出一个可以对照的选型框架。
6.1 后台管理系统列表:优先索引优化和COUNT缓存
后台管理系统的典型特征是:数据量中等偏大、用户量少、翻页深、查询条件灵活。运营人员可能一天翻几十页甚至上百页,每次翻页都要实时看到最新数据。
对于这类场景,最合适的组合是:覆盖索引优化分页SQL + Redis缓存COUNT结果。如果产品交互允许,尽量把页码导航改成"加载更多"的线性模式,这样后端可以安全地切换到游标分页,彻底摆脱深偏移量的性能隐患。
如果必须保留页码导航,建议在前端做翻页限制,最多允许查前500页。不是数据真的只有500页,而是深翻页的场景价值很低,没几个用户会认真看第500页之后的数据。用一句话告诉运营"只显示前10000条数据,如需更早数据请使用筛选条件",产品上完全说得通。
6.2 C端高并发场景:热点页缓存和Redis ZSET组合拳
C端用户的分页行为非常规律,基本都是翻首页和第二页,越往后流量越小。典型的首页热点模型。这种情况下不适合把所有页都缓存,而是应该用Redis缓存前几页,后面的页实时查库。
我做过的一个商品列表方案是这样的:
- 第1到第5页,缓存整个结果集到Redis,key加上排序规则和过滤条件的哈希,过期时间1分钟。
- 第6页开始,直接查MySQL,不经过缓存。
- 总页数用Redis缓存,30秒过期。
这套方案把80%的请求拦在了缓存层,数据库只承担少数深翻页和缓存未命中的请求,高峰期的稳定性提升明显。
如果商品排序还涉及热度、距离、价格等多维度排序,那就要考虑在Redis ZSET里维护每个排序维度的ID列表,查询时直接从ZSET切片拿ID,再批量查MySQL补全商品信息。这种方案排序计算完全脱离数据库,但需要开发额外的维护任务来同步ZSET和MySQL的数据。
6.3 高写入流水型场景:游标分页才是唯一正解
日志、流水、消息记录这类业务的特征是:单表写入量极大、数据基本不变、按时间倒序阅读、用户不会跳页。这类业务如果真的把LIMIT OFFSET分页用到底,数据量放大到亿级之后,任何索引优化都救不回来。
这类场景应该直接采用游标分页。接口入参不传页码,而是传上一页最后一条记录的ID或者时间戳。查询固定走WHERE id < 上游ID ORDER BY id DESC LIMIT 20这类模式。因为是主键定位,即使表里有上亿条数据,每次查询都是索引上的常量定位加顺序扫描,性能和偏移量完全无关。
我在日志系统里用过这个方案,单表两亿行,每次查询耗时稳定在5ms到15ms之间,无论翻到多深。换成LIMIT OFFSET的话,偏移量过百万之后基本就扛不住了。
7. 分页查询上线前要检查的边界条件和防御策略
分页查询虽然看起来简单,但上线前一定要做好边界检查。我见过太多线上事故是因为分页参数没校验导致的。
7.1 分页参数的合法性校验不能省
pageNum传负数、传0、传一个巨大的数字、pageSize传10000,这些情况如果后端不拦截,会产生两种结果:一种是MySQL直接内存溢出或者超时,另一种是返回几十万条数据把网卡打爆。
后端代码里必须做参数校验:
java复制if (pageNum == null || pageNum <= 0) {
pageNum = 1;
}
if (pageSize == null || pageSize <= 0) {
pageSize = 20;
}
if (pageSize > MAX_PAGE_SIZE) {
pageSize = MAX_PAGE_SIZE;
}
MAX_PAGE_SIZE一般设置为100到200之间。这个限制不仅是保护数据库,也是保护调用方。业务方一次性拉几千条数据,反而会带来内存和反序列化压力,分页的意义就在于控制单次数据量。
7.2 防止恶意深翻页拖垮数据库
恶意用户或者爬虫可以遍历页码,最高可以把pageNum传到几百万。如果没有限制,服务器每收到一个深翻页请求,就要执行一次大偏移量查询。即使有索引,偏移量百万之后仍然会产生较高的数据库压力。
应对方式可以分两层:
- 应用层直接设置最大页码限制。比如服务端规定pageNum最大为500,超过就直接拒绝。虽然粗暴,但非常有效。
- 用Redis记录同一个IP或者同一个Token的分页请求频率,如果一秒钟翻页超过10次,临时限流。爬虫一般不会像正常用户那样停留阅读。
还可以对深翻页做降级处理:超过一定页数,接口强制转换为游标模式,只返回"下一页"所需的数据,不再支持任意跳页。
7.3 缓存一致性:数据更新后怎么保证分页数据不错乱
Redis缓存分页结果之后,缓存一致性就成了新的问题。不可能每有一条订单状态变化就把所有页码的缓存全部删除,那样删除成本太大。实际项目中我用的策略是:
- 只有首页和前几页做缓存,这几页的数据变动最频繁,但也最容易在短过期时间后自动失效。设置60秒过期,过期后自然回源。
- 如果业务数据变更了状态,系统主动删除包含该条数据的页码缓存。但分页里同一页数据随时可能换位置,所以更稳妥的做法是删除排序所属维度下的首页缓存,例如删除
page:newest:*匹配的key。 - 不要让数据变更逻辑直接改Redis里的列表数据。因为列表数据可能和MySQL已经不在同一批次了,直接改Redis只会加剧不一致。
有一个从实践中总结的经验:分页缓存的过期时间不要设置太长,10到60秒是一个合理范围。太短缓存效果不明显,太长用户看到的数据会明显陈旧。分页这种场景本质上不是一个"高频读同一份静态数据"的场景,数据实时性要求通常高于缓存利用率要求,所以宁可让过期时间短一点,也不能让用户看到明显过期的数据。
8. 另一个常见的分页面试问题:COUNT优化和接口性能设计
最后再聊一个和分页查询强相关但经常被忽略的点,就是COUNT的性能优化。很多分页接口之所以慢,不是列表数据慢,而是COUNT慢。
8.1 COUNT在MySQL里为什么这么慢
COUNT查询在没有WHERE条件的情况下走的是索引全扫描,不管扫哪个索引,最终都要把索引中的记录数统计出来。如果表有500万行,COUNT大概需要扫描500万次索引记录。加了WHERE条件后,还要经过过滤,扫描量取决于过滤条件的区分度。
一个排查案例是:某项目商品表有300万行,分页接口的COUNT查询在无过滤条件时执行了1.8秒。后来发现表里有一个非常小的辅助索引,MySQL优化器选择了这个索引来统计行数,但因为InnoDB的行数统计并不是单独存储的,它必须按索引扫描一遍才能计数,所以慢得离谱。
常见优化手段包括:
- 使用近似计数替代精确计数。比如用MySQL的
SHOW TABLE STATUS或者EXPLAIN里的rows估算值。业务上如果不需要精确总页数,可以直接用估算值。 - 单独的计数表。用一张表记录每个维度下的记录总数,业务更新数据时同步增减,查询时直接查这张小表。
- Redis缓存计数,这个前面已经详细说过。
8.2 基于Redis的计数服务如何设计避免超卖
如果用Redis缓存计数,有个坑是计数更新和业务操作不是一个原子操作。例如订单创建时,先写MySQL再加Redis计数,如果MySQL成功但Redis更新失败,计数就少了;反过来,如果计数先加,MySQL写入失败,计数就多了。
解决思路有两个:
- 使用Redis事务或者Lua脚本保证加数和业务操作尽量一致,但MySQL和Redis天然跨系统,无法做到强一致,只能依靠定时任务对账修复。
- 计数不做精确维护。让MySQL在需要时执行一次准实时的COUNT,并定时刷新到Redis,Redis里的值只是一个"有一定延迟的近似值"。大部分分页总页数显示场景,对准确性要求没那么高,这种做法足够用。
我实际项目里采用的是第二种方案:一个定时任务每30秒执行一次COUNT,把结果更新到Redis。页面展示的总页数和总数最多有30秒延迟,但接口响应速度快很多。交易、日志类系统对总数精确到秒级的场景极少,这种成本换性能的方式收益很高。
9. 一点实践体会
分页查询写起来容易,想要在数据量上去之后依然保持稳定,确实需要在细节上做大量功课。我个人的体会是,先理解分页查询在存储引擎层的真实执行过程,再结合业务访问模式去设计合适的方案。单纯套用一种所谓的最佳实践,往往会在下一个数据量级上出问题。
做任何优化之前,先问自己几个问题:这张表的数据量还适合用LIMIT来做吗?翻页模型是线性浏览还是任意跳页?排序字段能不能被索引覆盖?业务能不能容忍总数有一两分钟的延迟?如果这些问题都有答案,分页方案的选型就顺理成章了。
最后再分享一个小技巧:给分页接口做性能测试时,不要只测第一页。把页码从1翻到1000,看一眼响应时间曲线的斜率。如果曲线走平,说明方案抗深翻页;如果曲线陡增,迟早要出问题。趁早优化永远比线上告警之后救火要舒服得多。
