ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深分页性能优化:从OFFSET到游标分页的实践指南

深分页性能优化:从OFFSET到游标分页的实践指南 去年我做的一个内容社区项目列表页突然开始收到用户投诉翻到一百多页的时候页面加载要十几秒有些用户直接卡死。运营那边反馈后台查询超时数据库CPU负载冲到90%以上。我当时第一反应是“加索引啊”但加完索引问题依旧直到拉出慢查询日志才意识到问题根子不在索引而在分页方案本身——当翻页深度足够大任何一种page/page_size的实现方式都会撞上性能天花板。这篇文章不打算讲理论模型而是把我真实的踩坑过程、两种分页方案的底层逻辑、以及我在C端产品里做选型时用到的决策原则完整记录下来。如果你正在做App列表页、信息流、订单列表、搜索结果这类需要持续翻页的场景这篇内容应该能帮你少走不少弯路。1. 深分页问题是怎么找上门来的先说回那个事故本身。我们项目的列表页是标准的信息流形态用户刷内容、评论、订单记录用到的是一套非常经典的page/page_size后端接口。初期数据量才几十万条接口响应都在几十毫秒完全没人觉得分页会有问题。但是随着用户量增长、内容池扩大到千万级以后问题就开始集中爆发了。1.1 一个让我半夜被叫醒的线上事故那是个周五晚上我的值班手机响了。运营截图发来一个用户反馈翻到第150页左右时接口直接返回空数据再往下翻就报超时。我第一反应是看看是不是缓存出了问题或者数据库连接池满没满。结果登录跳板机一查慢查询日志里全是同一条SQLSELECT 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时数据库要先扫描并排序149010行数据然后再把前面的1490行丢弃。数据量一大这个“跳过”的动作本身就变成了性能黑洞。1.2 排查过程从慢查询到OFFSET的真相后来我做了个简单的基准测试才彻底明白。同一张表、同一个ORDER BY条件OFFSET从100增长到10000的时候查询耗时几乎是线性上涨。这不是MySQL查询优化器的bug而是所有依赖OFFSET的分页方案天然的问题——DBA能做的只是把索引建得更完整让“扫描前N行”的速度快一点但无法改变“必须扫描并排序前面所有行”的执行路径。更麻烦的是数据一致性问题这个后面单独讲。我先把当时排查的完整路径写在这里给你一个参考思路第一步定位慢查询统计哪些接口的P99延迟异常升高确认都集中在列表类接口上第二步对慢SQL做EXPLAIN排除是否缺少索引、是否全表扫描第三步在不同OFFSET深度下做对比压测确认性能和翻页深度强相关第四步检查应用层代码确认是否存在重复查询、N1查询等写坏的情况第五步确认问题根源是OFFSET扫描后评估替换为游标分页的改造成本其中第三步是最有说服力的证据。我把同一批数据用不同OFFSET跑了100次平均值拉出来看OFFSET100时30msOFFSET5000时约700msOFFSET10000直接飙到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页等于OFFSET40”算好了而数据库里又多了几条新数据原本在第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_sizeOFFSET分页游标分页Cursor查询性能随翻页深度线性下降基本稳定与翻页深度无关数据一致性翻页期间有新增/删除时容易重复或漏数据不受新增数据影响删除会有轻微漏数据但可容忍跳页能力支持配合总数据量可计算页码不支持只能一页接一页翻实现复杂度低后端只需取offset和limit中需要处理游标编码、排序字段唯一性对索引的依赖低但优化上限也低高对排序字段必须有合适索引适用场景后台管理系统、PC端带页码列表、静态数据浏览信息流、移动端无限滚动、搜索结果持续翻页深分页深度越大越慢容易拖垮数据库无压力性能恒定计数需求需要单独的total count来算页码不算总量也可以继续翻是否返回total看需求光看这张表“游标分页完胜”的结论很容易得出来但实际落地时有一个决定性因素产品交互形态。如果你的产品需求是一个传统PC后台列表底部写着“共120页第5页”必须允许用户输入页码跳转那游标分页就不适用了你只能硬着头皮用OFFSET。所以严格说这两种方案不是“好与坏”的问题而是“交互是否匹配”的问题。4.1 深分页场景下的实际表现一次压测数据为了让你有个直观感知我把当时压测的结果贴出来。表是400万行数据机器是4核8G的标准云数据库MySQL 8.0测试SQL分别是OFFSET分页和游标分页单表单行查询结果如下翻页位置OFFSET耗时游标耗时第10页42ms33ms第100页210ms36ms第500页980ms34ms第1000页3.7s31ms这个数据足够说明问题。游标分页耗时几乎横成一条直线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耗时。一旦发现游标分页的延迟也开始不稳定大概率就是索引被写坏了或者复合索引没建对到时按索引设计规范重新审查就好。
返回列表