去年我做的一个内容社区项目,列表页突然开始收到用户投诉:翻到一百多页的时候,页面加载要十几秒,有些用户直接卡死。运营那边反馈后台查询超时,数据库CPU负载冲到90%以上。我当时第一反应是“加索引啊”,但加完索引问题依旧,直到拉出慢查询日志才意识到,问题根子不在索引,而在分页方案本身——当翻页深度足够大,任何一种page/page_size的实现方式都会撞上性能天花板。
这篇文章不打算讲理论模型,而是把我真实的踩坑过程、两种分页方案的底层逻辑、以及我在C端产品里做选型时用到的决策原则完整记录下来。如果你正在做App列表页、信息流、订单列表、搜索结果这类需要持续翻页的场景,这篇内容应该能帮你少走不少弯路。
1. 深分页问题是怎么找上门来的
先说回那个事故本身。我们项目的列表页是标准的信息流形态,用户刷内容、评论、订单记录,用到的是一套非常经典的page/page_size后端接口。初期数据量才几十万条,接口响应都在几十毫秒,完全没人觉得分页会有问题。但是随着用户量增长、内容池扩大到千万级以后,问题就开始集中爆发了。
1.1 一个让我半夜被叫醒的线上事故
那是个周五晚上,我的值班手机响了。运营截图发来一个用户反馈:翻到第150页左右时,接口直接返回空数据,再往下翻就报超时。我第一反应是看看是不是缓存出了问题,或者数据库连接池满没满。结果登录跳板机一查,慢查询日志里全是同一条SQL:
SELECT id, title, content, created_at FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 1490;这条语句单次执行花了3.8秒。更致命的是,当时每分钟有几十个这样的请求同时涌进来,MySQL的CPU瞬间被占满,整个库的读写都开始受影响。我把这条SQL拿去做EXPLAIN分析,走的是created_at索引,type是range,扫描行数只有9500左右——按常理说这不算坏。但问题是:OFFSET等于1490时,数据库要先扫描并排序1490+10行数据,然后再把前面的1490行丢弃。数据量一大,这个“跳过”的动作本身就变成了性能黑洞。
1.2 排查过程:从慢查询到OFFSET的真相
后来我做了个简单的基准测试才彻底明白。同一张表、同一个ORDER BY条件,OFFSET从100增长到10000的时候,查询耗时几乎是线性上涨。这不是MySQL查询优化器的bug,而是所有依赖OFFSET的分页方案天然的问题——DBA能做的只是把索引建得更完整,让“扫描前N行”的速度快一点,但无法改变“必须扫描并排序前面所有行”的执行路径。
更麻烦的是数据一致性问题,这个后面单独讲。我先把当时排查的完整路径写在这里,给你一个参考思路:
- 第一步:定位慢查询,统计哪些接口的P99延迟异常升高,确认都集中在列表类接口上
- 第二步:对慢SQL做EXPLAIN,排除是否缺少索引、是否全表扫描
- 第三步:在不同OFFSET深度下做对比压测,确认性能和翻页深度强相关
- 第四步:检查应用层代码,确认是否存在重复查询、N+1查询等写坏的情况
- 第五步:确认问题根源是OFFSET扫描后,评估替换为游标分页的改造成本
其中第三步是最有说服力的证据。我把同一批数据用不同OFFSET跑了100次,平均值拉出来看,OFFSET=100时30ms,OFFSET=5000时约700ms,OFFSET=10000直接飙到1.9秒。而且这是在一张数据结构很简单的表上,如果业务表字段多、排序字段复杂,代价只会更恐怖。到这一步,我心里已经很清楚——单纯优化索引和新写SQL挽救不了这个场景,需要换一种分页思路。
2. 先看page/page_size分页的内部逻辑
很多入门教程只会告诉你“limit一个偏移量加一个数量就能翻页”,但不会告诉你offset在数据库内部到底付出了什么代价。你要理解page/page_size为什么在C端产品里容易出问题,就得先看懂这条SQL在引擎里做了什么。
2.1 SQL层面的执行代价
以MySQL为例,一条典型的分页查询通常会走向两种执行路径。
第一种是有可用索引的,比如按主键排序:
SELECT * FROM orders ORDER BY id DESC LIMIT 10 OFFSET 1000;它的执行路径大致是:从索引的末尾倒序扫描,扫描到1010条记录后,丢弃前1000条,返回最后10条。也就是说,OFFSET越大,索引需要扫描的节点越多,IO消耗越高,即使最终只返回10条数据。如果排序字段没有索引,那就更惨了,MySQL需要先做一个文件排序(filesort),把所有匹配的行排好序之后再来做OFFSET切割,整个临时文件可能在磁盘上就要写好几轮。
第二种是排序字段有辅助索引的情况,比如我们经常用created_at排序。这里有个容易忽略的细节:MySQL在按辅助索引排序时,为了回表去拿其他字段的数据,会产生大量的随机IO。即使用MRR优化,也免不了要在索引和主表之间来回切换。OFFSET越大,需要回表的记录越多,慢查询就这么产生了。
2.2 数据一致性:翻页过程中“错位”是怎么发生的
性能问题只是OFFSET的原罪之一,另一个在C端产品里非常伤体验的问题是翻页过程中可能出现重复数据和漏数据。
举个典型例子。你在某个购物App上浏览商品列表,当前在第2页,第2页最后一条商品是A。此时运营在后台上架了一个新商品,它正好排到了列表最前面。然后你继续翻到第3页——由于OFFSET已经按“每页20条、第3页等于OFFSET=40”算好了,而数据库里又多了几条新数据,原本在第40~60位的内容整体后移了,你在第3页就会看到本该属于第2页尾巴的重复商品,同时漏掉几个新的商品。
更麻烦的是删除场景。如果商品被下架,上一页的最后一条在下一页里又出现了,用户会以为系统有bug。这类问题在运营频繁操作数据、或者用户自己频繁发布内容的C端产品里特别常见。我在那个社区项目里就收到过“同一个帖子刷出来两次”的反馈,排查了半天,最后发现是用户刷新时有人发帖导致的数据偏移。
这两个问题叠加在一起,基本就说明了一件事:在C端高频动态数据的列表里,page/page_size可以应付“浅分页”和“静态数据”,但一旦涉及深分页、高频写入,它的缺陷是结构性的,怎么调参都救不回来。
3. 游标分页:用排序位置代替偏移量
聊完OFFSET的问题,再看游标分页(Cursor-Based Pagination)的思路。一句话概括:它不再告诉数据库“跳过前面的N条”,而是告诉数据库“从我上次停下的位置继续往后拿”。这样一来,查询耗时不再跟着翻页深度增长,数据一致性也大幅提升。
3.1 游标的基本形态
还是上面那个帖子列表的例子,游标分页的SQL长这样:
SELECT id, title, content, created_at FROM posts WHERE created_at < %(cursor_time)s ORDER BY created_at DESC LIMIT 10;第一次请求没有游标,直接查第一页:
SELECT id, title, content, created_at FROM posts ORDER BY created_at DESC LIMIT 10;拿到第一页的10条数据后,取最后一条数据的created_at作为下一页的游标。下次请求时把游标传进来,用WHERE created_at < ?过滤掉已经看过的内容,只取在这个时间点之前的最新10条。如果created_at有重复值,还得带上一个唯一字段来做二次定位,这个细节后面会专门讲。
从数据库执行计划来看,游标分页利用WHERE条件直接走索引范围扫描(range scan),从游标位置向后取数据,取够了就停。它不需要扫描大量无用的行,所以不管翻到第几页,查询代价都跟第一页差不多。
3.2 为什么游标分页能一直保持稳定查询时间
核心原因是“查询范围缩小”和“避免文件排序”。OFFSET分页的查询范围是整个数据集,游标分页的查询范围是从游标开始的剩余数据集。虽然理论上越往后的游标,剩余数据越少,扫描范围也应该越小,但实际操作里由于索引有序性,MySQL会在命中索引后直接定位,不需要全量扫描排序。
还有一个很重要的点:游标分页天然适合“无限滚动”这种交互。用户往下滑,前端不断请求,后端根据上一条数据的游标继续取一页,整个过程非常顺滑,不会出现“第N页请求特别慢”的情况。
再说数据一致性。游标分页的定位条件是干脆的“小于当前游标”,意味着在两次请求之间插入的新数据不会干扰你的翻页路径。前面那个电商例子,第2页翻到第3页时,即使有人上架了新商品,游标还是基于你在第2页看到的最后一条商品来定位,新商品会排在第3页之后的数据位置,而不会导致已看内容重复出现。这样至少从机制上解决了“重复”和“漏看”的问题。
不过也要说清楚,游标分页不是银弹。它在“跳页”这件事上有天然短板,用户想一下子从第2页跳到第50页,游标方案是完全做不到的。这个问题我会在第4章展开讲。
4. page/page_size与游标分页的核心差异对比
很多团队在选型时没有把两种方案放在同一张表里做过完整的对比,导致系统里混用、甚至坑到后来没人敢改。我做了个对照表,把核心维度列出来了,你可以直接拿去当评审材料参考。
| 维度 | page/page_size(OFFSET分页) | 游标分页(Cursor) |
|---|---|---|
| 查询性能 | 随翻页深度线性下降 | 基本稳定,与翻页深度无关 |
| 数据一致性 | 翻页期间有新增/删除时容易重复或漏数据 | 不受新增数据影响,删除会有轻微漏数据但可容忍 |
| 跳页能力 | 支持,配合总数据量可计算页码 | 不支持,只能一页接一页翻 |
| 实现复杂度 | 低,后端只需取offset和limit | 中,需要处理游标编码、排序字段唯一性 |
| 对索引的依赖 | 低但优化上限也低 | 高,对排序字段必须有合适索引 |
| 适用场景 | 后台管理系统、PC端带页码列表、静态数据浏览 | 信息流、移动端无限滚动、搜索结果持续翻页 |
| 深分页 | 深度越大越慢,容易拖垮数据库 | 无压力,性能恒定 |
| 计数需求 | 需要单独的total count来算页码 | 不算总量也可以继续翻,是否返回total看需求 |
光看这张表,“游标分页完胜”的结论很容易得出来,但实际落地时有一个决定性因素:产品交互形态。如果你的产品需求是一个传统PC后台,列表底部写着“共120页,第5页”,必须允许用户输入页码跳转,那游标分页就不适用了,你只能硬着头皮用OFFSET。所以严格说,这两种方案不是“好与坏”的问题,而是“交互是否匹配”的问题。
4.1 深分页场景下的实际表现:一次压测数据
为了让你有个直观感知,我把当时压测的结果贴出来。表是400万行数据,机器是4核8G的标准云数据库MySQL 8.0,测试SQL分别是OFFSET分页和游标分页,单表单行查询,结果如下:
| 翻页位置 | OFFSET耗时 | 游标耗时 |
|---|---|---|
| 第10页 | 42ms | 33ms |
| 第100页 | 210ms | 36ms |
| 第500页 | 980ms | 34ms |
| 第1000页 | 3.7s | 31ms |
这个数据足够说明问题。游标分页耗时几乎横成一条直线,OFFSET则是一路爬坡。等到生产环境累积到千万级后,OFFSET分页的P99延迟可能已经高到影响整体稳定性了。
4.2 何时必须保留page/page_size
我在和不少同行交流时,发现一个常见的误区:一说分页优化,就让全部接口改成游标。但游标分页有硬条件——排序字段必须稳定且可比较,并且你没法直接计算“某页从哪开始”。下面这些场景里,page/page_size依然是主流:
- 数据量小于几万行,用户也不太会翻超过几十页的列表,OFFSET没有性能压力
- 后台管理系统或者工作台类产品,用户需要快速跳转到特定页码
- 需要展示总条数和总页数,并支持最后一页快捷跳转的场景
- 排序字段不稳定,或者数据本身是静态快照,不需要考虑增量问题
所以我的态度很明确:分页选型必须从头看产品的交互设计,不是技术上的“最优解”绝对适用,而是“和产品交互最匹配的方案”才是正确选择。
5. 游标分页落地时踩过的具体坑
从OFFSET改造到游标分页,我在生产环境里踩了不少坑,这里挑四个最典型的写出来,每个都伴随真实案例,避免你重蹈覆辙。
5.1 游标的编码与传递:别为了让URL好看而折腾出bug
游标不是非要用透明字符串传,最好是做一个不透明标识。我当时犯过一个错:直接把created_at和id用逗号拼起来放在URL参数里,结果客户端、网关、日志系统全都在这个参数上出了问题。
后来我改成序列化加Base64编码:
def encode_cursor(created_at, last_id): raw = f"{created_at}:{last_id}" return base64.urlsafe_b64encode(raw.encode()).decode() def decode_cursor(cursor): raw = base64.urlsafe_b64decode(cursor.encode()).decode() created_at, last_id = raw.split(":") return created_at, last_id用Base64 URL-safe编码能避免特殊字符对URL参数和网关日志造成干扰。还有一个原则是客户端把游标当不透明字符串处理,后端解码失败时直接返回参数错误,不要让客户端自己去解析内容,不然改天你调整游标结构,老版本客户端就全挂了。
5.2 多条件排序下的游标写法:不固定的排序字段是最坑的
如果你的列表支持多种排序方式,比如按时间、按热度、按价格,每种排序都要生成对应的游标,而且游标必须和排序条件绑定在一起。我见过一个实现,只存了一个游标字段,用户切换排序后把旧游标带过来,结果SQL条件错乱,返回了完全无关的数据。
正确的做法是:游标里保存当前排序方式对应的字段值+唯一ID,同时后端要校验客户端传入的排序参数与游标内嵌的排序条件一致。不一致就直接返回第一页或报错。以“按价格升序+按主键降序”为例,SQL形态是:
SELECT * FROM products WHERE (price > %(cursor_price)s) OR (price = %(cursor_price)s AND id < %(cursor_id)s) ORDER BY price ASC, id DESC LIMIT 20;这个WHERE条件里蕴含了“复合排序”的推进规则:先比较第一个排序字段,如果相同再比较第二个唯一字段,保证每次都能精确地定位到上一条的下一行。
5.3 重复值导致的丢数据问题
这是游标分页里最隐蔽的坑。假如你只按created_at倒序分页,而同一秒内有100条记录,那游标时间戳为T时,WHERE created_at < T就会把所有created_at等于T的数据全部排除掉。结果就是用户漏掉了一大批同一时间产生的帖子。
解决思路就是我在5.2里提到的:游标必须是“排序字段+唯一字段”的组合,用(created_at, id)组成复合游标。为了让它高效工作,还需要建联合索引(created_at, id)。MySQL的索引本身就是按联合键排序的,所以条件写起来也很顺:
SELECT * FROM posts WHERE (created_at < %(cursor_created_at)s) OR (created_at = %(cursor_created_at)s AND id < %(cursor_id)s) ORDER BY created_at DESC, id DESC LIMIT 20;这个写法在索引上会形成一个范围扫描,既解决重复值问题,又能保证性能。注意ORDER BY也要改成跟索引同序,否则数据库还得做额外的排序。
5.4 游标过期与前端状态的互相伤害
用户把App切到后台几分钟再切回来,这时他手里的游标可能已经是很久之前的时间点。此时服务端如果依然用游标去查数据,会返回一堆老数据,但用户看到的却是“有新内容吗?没有”。解决方式是配合“上次刷新时间”或者“推荐位刷新机制”做整体状态重置。
我的做法是:每次列表请求除了返回数据外,还附带一个refresh_token或者更新后的游标。前端如果在短时间内下拉刷新,就带上最新游标;如果发现游标时间距离当前时间太久(比如超过10分钟),服务端强制把列表重置到第一页。这样既保持游标定位的一致性,又不会让用户卡在一个过期的数据窗口里。
另外还有一个边界情况要处理:用户拉取到了最后一页,已经没有更多数据了。这时候服务端要返回一个明确的终止标记,前端停止发请求。如果不做这个标记,用户滑到底部时客户端会不断用同一个游标请求空数据,白白消耗流量和服务器资源。
6. 我在真实项目里给团队定的三条决策原则
踩过一轮坑之后,我给自己和团队沉淀了一套分页选型决策逻辑,遇到新需求直接用这三条去判断,效率高很多。
6.1 先看数据量级,再谈优化
如果一张业务表的数据量在几万以内,用户最多翻几十页,我根本不建议强行改成游标分页。OFFSET在这个量级下的性能差异用户感知不到,而游标分页带来的“不能跳页”反而可能让产品交互受限。只有当数据量突破百万、且存在用户持续翻到很深位置的场景时,才值得做方案替换。
6.2 产品交互形态是真正的决定因素
交互的取舍一定优先于技术选型。
- 无限滚动信息流:必须游标分页。用户不会关心“现在在第几页”,只关心刷出来的内容是否连贯、是否重复
- 后台管理系统、订单列表:保留page/page_size,产品需要页码跳转和总条数统计,不能为了性能牺牲基础功能
- 搜索结果列表:多数情况下用游标分页更好,因为搜索结果本身是动态变化的,OFFSET下很容易出现分页期间的重复和错位
6.3 如果需求复杂,就用混合方案
有些C端产品既要支持PC上的页码跳转,又要支持App内的无限滚动。这种情况下不用二选一,可以同时实现两套接口:一套走OFFSET分页,保留页码统计和跳转能力;另一套走游标分页,服务移动端的持续滚动场景。在代码设计上把分页参数抽象成一个公共接口,两种策略各自实现,对业务层暴露统一的分页结果即可。
7. 迁移过程中的实用建议
最后这部分写给已经在考虑把核心列表改成游标分页的人。改造过程不是把SQL换一下就完事,有几个细节需要提前规划好。
第一,老版本客户端兼容。如果你要上线游标分页接口,但线上还留存着使用OFFSET参数的旧版本App,建议先用灰度或者版本分流做过渡。我见过直接切换后,老客户端传了page参数新接口不识别,列表全部空掉的线上事故。
第二,给游标加上版本号。我的做法是游标字符串里第一个字符是版本号,encode_cursor时带上,解码时先判断版本。未来如果要改游标结构,可以平滑兼容老游标。
第三,在接口层面保证排序参数的强校验。客户端传什么排序方式,游标就要和排序方式绑定,服务端不能盲目信任参数。每次查询生成游标时,把排序方式也编码进去,后续校验能避免很多乱七八糟的数据错乱问题。
迁移完之后,最重要的是用一套压测脚本持续盯住分页接口的延迟变化。我比较推荐按业务高峰期抽样,比如分页深度最大值出现的时间点,在监控面板上专门盯这个接口的P99耗时。一旦发现游标分页的延迟也开始不稳定,大概率就是索引被写坏了或者复合索引没建对,到时按索引设计规范重新审查就好。