
分页大概是C端产品列表接口里最没有存在感、却最容易在数据量大起来之后让你半夜爬起来处理告警的功能。早期我刚接手一个商品列表接口时服务端签名里写着 page 和 page_size 两个参数实现就一句话LIMIT OFFSET日常跑得很稳。直到某次运营人员连续翻到第 700 页数据库慢查询飙到 9 秒整个接口超时率直接飙升我才意识到分页选型不是“能查出来就行”。这篇内容把基于 page、page_size 的偏移量分页和基于游标的分页这两种主流方案完整梳理一遍包含原理、SQL 写法、适用边界和线上踩坑记录给正在做列表类接口的同学一份能直接参考的经验。1. 分页问题的源头性能、一致性与体验的三方博弈1.1 C端产品为什么绕不开分页C端产品的列表场景实在太多商品列表、订单记录、文章信息流、评论楼层、消息通知。这些数据少则几十万条多则上千万条产品层面不可能把全量数据一次性返回给客户端否则带宽、服务端内存、客户端渲染能力都会被打爆。分页因此成了一切列表接口的基础能力。但这里有个容易被忽略的点分页不是单纯的“把数据切块”它本质上是系统为数据量增长预留的缓冲机制。很多团队在项目初期数据量几千条时随便写个LIMIT 10 OFFSET 20就能应付这时候两种分页方案都没有性能差异大家自然选择最熟悉的 page、page_size。等到数据量涨到几十万、上百万深翻页问题突然暴露接口一个接一个超时才意识到最初几行 SQL 里藏着的设计决策会影响系统很久。所以我一直认为分页方案选型应当放在接口设计阶段做而不是等出现慢查询再救火。选型之前先搞清楚我们的评判标准是什么。1.2 评判分页方案的三把尺子我习惯用三个维度来评估分页方案性能、一致性、体验。性能指的是接口耗时是否稳定。同一个接口翻到第 2 页和翻到第 500 页耗时应保持在同一量级。如果耗时随页码线性上涨那这套方案在数据增长后一定出问题。一致性关注的是翻页过程中数据发生变化时用户看到的内容是否错乱。举例来说用户在第一页看到 10 条数据翻到第二页之前有人删掉了其中一条那么第二页应该展示哪些数据才不会让用户觉得“莫名其妙”分页方案不同处理结果完全不同。体验则主要指向跳页能力。用户能不能直接跳到第 80 页信息流产品要不要支持“回到上次浏览位置”这些需求直接决定了方案的取舍。这三把尺子往往互相制约。追求精确跳页就要承受深翻页性能劣化追求翻页稳定和性能恒定往往要放弃任意页码跳转。没有完美的方案只有匹配场景的方案。1.3 两种方案的宏观差异用大白话解释两者的核心区别偏移量分页page、page_size的思路是“跳过 N 条再取 M 条”。给我第 3 页的数据我就跳过前 20 条取 21 到 30 条。它的语义是“位置是我自己数的”。游标分页cursor/keyset 分页的思路是“从上次停下的位置继续走”。我不关心一共跳过了多少条我只知道上一次最后一条数据是 85 号那么下一页就从 85 号之后继续取。它的语义是“位置是上一次接口告诉我的”。生活化类比偏移量分页像你站在一列队伍前工作人员让你“从第 21 个人开始往后数 10 个人”游标分页像你手里拿着一张队伍里某个人的照片你只要找到这个人他后面的人就是你要找的下一批。前者需要一遍遍从头数后者只需要记住上次的位置因此游标分页在“继续往下翻”这个场景下有天然性能优势。2. page、page_size 与偏移量分页原理、SQL 与深翻页之坑2.1 最简单也最常用的 LIMIT OFFSET偏移量分页的经典实现是 SQL 里的LIMIT配合OFFSET。以 MySQL 为例第三页、每页 10 条数据的查询是这样的SELECT id, title, created_at FROM articles ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 20;LIMIT 10表示取 10 行OFFSET 20表示跳过 20 行。如果产品接口用 page 表示页码、page_size 表示每页条数那么 offset 的计算公式是offset (page - 1) * page_size比如 page3、page_size10offset(3-1)*1020。这个公式我建议严格使用(page-1)而不是page因为绝大多数产品的页码从 1 开始如果用page * page_size就会跳过第一页的数据。接口侧的校验也很重要。page_size 必须设置上限通常不超过 50 或 100防止客户端一次性拉走全表。page 参数应大于等于 1。我见过有的接口没做参数校验线上被一个 page999999 的参数直接打出一条灾难级慢查询这种事情真不是在开玩笑。在数据量小的时候这套方案非常顺手。SQL 清晰、容易理解、完全支持任意页码跳转前端要做分页组件、页码按钮都非常方便。也正因为这些优点它成了大多数团队默认采用的分页方式。2.2 深翻页为什么越来越慢MySQL 的执行口径偏移量分页最大的坑就是“深翻页性能劣化”。在 MySQL 中执行LIMIT 10 OFFSET 200000时服务端并不是只读取 10 行而是要从满足 WHERE 条件的完整结果集里取前 200010 行然后丢弃前 200000 行只把最后 10 行返回给客户端。换句话说OFFSET 越大MySQL 要扫描和丢弃的数据就越多。我做过一次直观测试一张 100 万行的订单表ORDER BY create_time DESC LIMIT 10 OFFSET 50000耗时约 800ms而OFFSET 500000直接到了 5 秒以上。业务低峰期还算能扛一到高峰期相同 SQL 并发执行数据库的磁盘 IO 和 CPU 立刻被打满整个分页接口连带超时。更糟糕的是如果查询条件里有过滤字段比如WHERE status 1MySQL 必须先找出所有 status1 的数据再执行同样的扫描和丢弃逻辑。这意味着深翻页慢不是索引能完全解决的即使走了二级索引依然要从索引头部开始扫描一路数到 offset 位置。从原理层面讲B 树索引可以快速定位到“第一个满足条件”的记录但它无法直接定位到“第 200000 个满足条件的记录”因为中间那些记录即使最终被丢弃也必须被读取和判断。这就是深翻页性能劣化的本质。2.3 什么时候可以继续用偏移量分页说了深翻页这么多坏话并不意味着偏移量分页一无是处。事实上在很多场景下它依然是合理选择。数据量可控的业务后台适合用偏移量分页。比如运营后台的订单列表、会员列表、操作日志这些表通常过滤条件多、查询频率低、数据规模在十万到百万级别而且运营人员确实需要跳转到任意页码查看着历史数据。这种情况下只要限制最大页码或最大 offset深翻页问题可以被规避。数据总量本身有限制的场景也适合偏移量分页。比如一个用户自己的收藏夹上限几百条分页深度撑死三四十页完全没必要引入游标复杂度。此外统计报表类页面通常需要配合排序和筛选做快速跳转页码语义清晰偏移量分页依然是最直接的实现方式。我在实际项目中通常会给 offset 设置一个上限比如最大允许翻到 1000 页超过这个深度直接提示用户“请缩小筛选范围”从产品层面掐断深翻页查询。3. 游标分页用“上次的位置”替代“跳过的条数”3.1 游标分页的核心思想和 SQL 写法游标分页也叫 keyset 分页、seek 分页。它的核心思想是每次查询都以上一次返回的最后一条记录的位置作为起点获取该位置之后的数据。用 SQL 来描述。假设每页 10 条第一页查询SELECT id, title, created_at FROM articles ORDER BY created_at DESC, id DESC LIMIT 10;前端拿到第一页后第二页的查询条件是“created_at 比第一页最后一条更小倒序或者 created_at 相同但 id 更小”SELECT id, title, created_at FROM articles WHERE (created_at 2025-01-15 10:00:00) OR (created_at 2025-01-15 10:00:00 AND id 100123) ORDER BY created_at DESC, id DESC LIMIT 10;这里2025-01-15 10:00:00和100123就是第一页最后一条记录的排序值。下一页的游标就是这一行数据里的两个字段值客户端在请求下一页时把这两个值传给服务端即可。为什么能保持性能稳定因为 WHERE 条件里的created_at ...和id ...是等值范围查询能够直接命中(created_at, id)联合索引。B 树可以快速定位到“第一个小于该时间戳的记录”直接从该位置开始扫描 10 行全程不用扫描任何被丢弃的数据。无论翻到第几页耗时基本恒定在十几毫秒级别与总数据量、翻页深度都无关。需要注意的是ORDER BY 的字段组合必须保证唯一性。如果只按created_at排序而两条记录 created_at 相同游标定位就会产生歧义导致漏数据或重复数据。经典做法是排序字段加主键 id 作为 tie-breaker即ORDER BY created_at DESC, id DESC并在查询条件里同时带上这两个字段。3.2 游标的生成、传递与解析直接暴露排序字段值给客户端并不可取。一方面时间戳、自增 id 这些值本身不难猜这倒不算大安全问题但游标格式一旦被客户端篡改可能查询出异常数据另一方面如果后续排序规则变化比如从created_at DESC改成updated_at DESC客户端传进来的旧游标格式就和新的解析逻辑对不上。我常用的做法是服务端生成一个不透明的游标字符串通常用 Base64 编码一段 JSON或者把排序字段值拼接后做编码返回给前端。以 Java 为例大概这样public String buildCursor(Article lastArticle) { MapString, Object payload new HashMap(); payload.put(createdAt, lastArticle.getCreatedAt().getTime()); payload.put(id, lastArticle.getId()); String json JSON.toJSONString(payload); return Base64.getUrlEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); }接口入参只接收 cursor 一个字段服务端解出来再拼 SQLpublic PageResultArticle listByCursor(String cursor, int pageSize) { PageResultArticle firstPage queryFirstPage(pageSize); if (firstPage.getItems().isEmpty()) { return firstPage; } Cursor c parseCursor(cursor); ListArticle items queryAfter(c.getCreatedAt(), c.getId(), pageSize); return new PageResult(items, buildCursor(items.get(items.size() - 1))); }解析时做好字段校验防止非法游标。更严谨的方案是在游标里加一个签名或版本号字段解析时校验签名一旦发现版本不匹配就返回参数错误。我一般还会在游标里带上排序规则的版本号比如sortType1这样将来切换排序规则时老游标可以直接失效不会出现前端用旧游标请求新排序导致数据错乱的问题。3.3 游标分页的局限跳页困难与排序字段约束游标分页最明显的局限是不支持任意页码跳转。用户想从第 2 页直接跳到第 80 页游标方案做不到因为游标只记录了“上一次的位置”没有提供“第 80 页的位置”。想要跳页就必须知道第 80 页起始位置的游标这样一个一个推算过去又回到了全表扫描的老路。所以游标分页特别适合“上一页/下一页”或者“加载更多”这类线性翻页场景却天然不适合带页码导航的内容。C端信息流产品几乎都是线性翻页这也是游标分页在社区、电商、内容产品里大量使用的原因。另一个局限是排序字段不能随便换。如果列表按热度排序而热度值每分钟都在变化游标定位就失去意义。因为上一次最后一条记录的热度值在你翻页期间可能已经被其他记录超过数据顺序发生变化游标无法稳定表达“上次的位置”。这种情况下要么接受排序近似改用偏移量要么在时间维度上加上快照机制把热度值在某一时刻固定下来但这在业务上往往很难实现。还有一点容易被忽略游标本身也是有一定长度的。Base64 编码后通常几十到一百多字符放在 URL 上没问题但在一些对参数长度敏感的场景比如部分网关、日志系统需要提前确认。我自己就遇到过网关层限制 query string 长度 256 字符的问题最后把游标改成了短码方案比如用一个全局发号器分配一个短数字 ID 并存储映射关系前端拿到的游标就是c82391这样的短参数。4. 两种方案选型并不存在银弹4.1 性能、一致性、体验与实现的横向对比把两个方案放在同一张表里看取舍关系会非常清晰维度偏移量分页page,page_size游标分页cursor深翻页性能随 offset 增大急剧劣化基本恒定与翻页深度无关数据一致性数据增删时易出现重复/漏数据数据增删基本不影响翻页结果跳页能力支持任意页码跳转只支持顺序翻页实现复杂度低几行 SQL 即可中等需生成/解析游标排序灵活性排序字段可频繁变化排序字段需稳定且唯一缓存友好性可按页码缓存天然分片游标含具体值缓存边界复杂这里重点解释一下“数据一致性”的差异。偏移量分页在翻页过程中如果有人插入或删除数据会有明显的“错位感”。我举一个真实案例订单列表本来有 100 条数据第一页取了 1-10 条用户翻页前有一条订单被退款删除原来的第 11 条变成了新的第 10 条于是用户第二页第一条数据与第一页最后一条重复用户会疑惑“怎么出现两条一样的订单”。反过来如果有人在前面插了一条新数据第二页会跳过一条原来应该出现的数据用户感觉“有一条消失了”。游标分页天然规避了这个问题因为它基于位置值继续取数前面删掉还是插入多少条都不影响“从上次那一条的排序位置之后继续取”的语义。这一点在用户对内容一致性格外敏感的信息流场景里是压倒性的优势。4.2 按产品形态给选型建议我自己的选型经验可以总结成一张决策表商品列表、搜索结果、订单列表这类需要跳页或需要精确定位的场景用偏移量分页但要限制最大深度。信息流、消息通知、评论列表、动态时间线这类以“加载更多”“下拉刷新”为主要交互形式的场景用游标分页。管理后台、数据报表、日志查询基本都用偏移量分页配合筛选条件和最大页数上限。无限滚动页面类似淘宝商品流下拉加载适合游标分页。那种既需要页码导航又需要滚动加载的复杂列表建议拆成两个接口一个用偏移量支持页码跳转一个用游标支持连续加载。以商品详情页的“大家都在看”推荐流举例这个场景用户是无限下拉的用游标分页后每次请求毫秒级返回而商品列表页通常带左侧筛选条件用户会跳到第 5 页比较商品这种情况下偏移量分页更符合产品预期。两套分页方案在我的项目里是并存的关系而不是互相替代。4.3 混合分页给用户“上一页/下一页页码”的能力有些场景产品经理会提出这种需求“既能像页码一样跳转返回时又能继续向下加载。”这时候要不要做混合方案我的经验是除非数据总量可控否则不建议。混合方案常见的折中做法是只允许跳转到前 N 页比如 500 页内超过 N 页之后前端把页码导航隐藏改用游标加载更多。这个方案确实能在一定程度上兼顾两种体验但实现复杂度会上升至少要有两套查询逻辑和两套校验逻辑。而且当用户跳转到第 400 页又继续往下加载时游标要能从第 400 页的最后一条记录开始这意味着接口要再提供一次“从页码偏移位置初始化为游标”的能力。我在一个超过千万条数据的后台列表里做过这个方案前 200 页用偏移量offset 2000200 页之后强制用户缩小筛选范围同时提供一个“从当前位置继续加载”按钮按钮走后端生成的临时游标。上线后排查问题的复杂度确实高了不少但业务方满意度很高。这个方案的代价是接口入参同时要兼容 page/page_size 和 cursor 两种模式代码审查时需要格外小心。4.4 其他技术栈里的“游标思想”ES search_after 与 Oracle 分页游标分页不只是 MySQL 里的概念它在很多中间件里都有对应实现。最典型的是 Elasticsearch 的search_after。ES 默认的from size分页就是偏移量分页默认index.max_result_window为 10000超过 10000 条直接报错。我接手过一个数据检索服务从 ES 拉超过 1 万条数据做导出一开始用的 from 分页跑到 10000 条就报错后来改成search_after基于上一页最后一条的排序值继续查询不仅绕过了 10000 条限制性能也稳定很多。它的写法是这样的GET /articles/_search { size: 10, sort: [ { created_at: desc }, { id: desc } ], search_after: [1736937600000, 100123] }Oracle 数据库的情况也类似。在没有 12c 的OFFSET FETCH之前Oracle 使用ROWNUM做分页需要三层嵌套子查询且偏深时同样要扫描大量无用的行。12c 之后引入了OFFSET FETCH语法更友好但深翻页性能问题依然存在。这些例子说明一个规律凡是支持 offset 翻页的系统最终都会遇到深翻页性能问题而解法往往都是往游标/keyset 的方向靠。所以只要理解了游标分页的核心思想你在任何一个技术栈里遇到“深层分页性能差”的问题都能找到对应的解法。5. 分页性能的常见问题与排查技巧实录5.1 深分页慢查询的优化手段如果你的列表接口已经用了偏移量分页而且确实出现了深翻页慢查询在零改造的前提下有几个优化手段。第一延迟关联也叫子查询分页。思路是先用覆盖索引找到当前页的 id 集合再通过主键回表取完整数据。改造后的 SQL 长这样SELECT a.id, a.title, a.created_at FROM articles a INNER JOIN ( SELECT id FROM articles ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 20000 ) tmp ON a.id tmp.id ORDER BY a.created_at DESC, a.id DESC;子查询只查主键和排序字段能走覆盖索引避免回表造成的额外 IO。实测在深翻页时这个写法比直接 LIMIT OFFSET 快 3 到 5 倍但依然存在扫描和丢弃 offset 行的开销所以它是“缓解”而不是“根治”。第二设计索引时把排序列和过滤列放进同一个索引。最典型的联合索引是(status, created_at, id)保证 WHERE 等值条件在最左ORDER BY 字段在中间主键 id 最后。这个顺序不是随便排的MySQL 对一个联合索引的使用规则是等值条件可以过滤掉大量数据并且如果排序字段紧随其后Extra 里会出现 Using filesort 的概率会大幅降低。第三从产品层面直接限制最大 offset。比如允许翻到第 1000 页超过后提示“数据过多请使用筛选条件”。很多管理后台根本没人会翻到第 1000 页去查找数据这个限制对业务几乎没有影响。5.2 MyBatis-Plus 分页失效的排查国内 Java 项目里 MyBatis-Plus 普及度很高分页失效是常见的排查场景。现象通常是SQL 能执行但返回的是全量数据完全没走分页解析。第一步检查MybatisPlusInterceptor是否注册了分页插件。没有注册分页拦截器分页就是一个空壳。配置类似Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }第二步看自定义 SQL 的写法。MyBatis-Plus 里自定义 SQL 分页要求IPage参数必须放在第一个参数的位置。我见过好几次把 IPage 放在第二个参数结果分页参数完全没被解析的情况。第三步打印执行日志看 SQL 里有没有 LIMIT。如果 LIMIT 是 0说明 count 查询可能出了问题如果没有 LIMIT基本就是拦截器没生效。第四步检查DbType是否正确。数据库方言指定错了分页 SQL 生成的语法就是错的可能直接被 SQL 解析器忽略。5.3 数据变更导致的问题重复、漏数据与一致性偏移量分页在数据频繁变动时“重复/漏数据”问题几乎是无法避免的。这里给出两个方向一是接受问题但降低影响。比如订单列表翻页频率不高、数据变更相对少可以接受偶尔的重复/缺失。前端通过唯一标识做去重或者翻页时刷新最近记录即可。二是切换到游标分页。游标分页在数据删除场景下表现很好因为删除的数据不参与排序和过滤基于位置继续查询不受影响。但在数据新增场景下也有一个体验问题如果新的数据不断插入到排序前端比如时间倒序流里不断有新帖用户在一直按“加载更多”时游标位置之前的区域不断有新内容但游标本身不会回退所以用户可能一直看不到最新内容。解决方式是在顶部加一个“有 N 条新内容点击查看”的按钮用max_id或min_id去查询最新数据插入现有列表头部。这个交互在微博、抖音这类产品里非常常见本质上是“下拉刷新 游标翻页”的组合。5.4 分页与缓存、以及容易被混淆的“分页”概念如果你的列表数据更新频率不高可以对偏移量分页做缓存。key 就是page page_size 筛选条件value 是当前页的 id 列表。这种缓存策略实现简单命中率高但缺点是筛选条件多时 key 长得没法看而且数据更新后缓存失效粒度很粗。游标分页的缓存不太好设计因为游标值本身包含具体排序值缓存 key 里带上游标就等于按位置缓存边界不清晰我通常只在游标分页前加一层热点数据缓存比如第一页肯定要被很多人刷第一页的 id 列表缓存价值高后续页就交给数据库索引扛。最后提醒一句别把接口分页和操作系统分页混为一谈。网上搜“非分页缓冲池占用很高”“分页文件设置”时讨论的是 Windows 虚拟内存、内存池这类系统层面的话题和数据库LIMIT OFFSET、cursor完全不是一个维度的概念。如果你在排查分页接口性能时看到这类词先确认自己的问题边界别被带偏。E 同学之前就因为搜“分页”搜到了操作系统分页文件设置绕了一大圈才发现数据库慢查询才是真凶这种乌龙我也是经历过的。6. 根据个人经验的一些建议分页技术看起来简单真正选型时还是要回到产品和数据这两个原点去想。第一条建议新接口设计时先问产品“这个列表需要页码跳转吗”。如果需要用偏移量分页并加上 offset 深度限制如果只是加载更多、下拉刷新直接用游标分页从一开始就避开深翻页的坑。第二条建议排序字段务必稳定。无论哪种分页方案排序字段的稳定性都直接影响数据一致性。优先选择创建时间、自增 id 这类生成后不再变化的字段作为排序依据避免使用修改时间、热度值这类高频变动字段。如果业务必须按热度排序最好设计成快照热度值每小时刷新一次保证一个时间段内排序稳定。第三条建议游标分页的升级方向是“双向游标”。很多信息流产品需要同时支持“下拉刷新”和“上滑加载”下拉刷新用since_id上滑加载用before_id这就是双向游标的雏形。MyBatis 映射 SQL 时构造两个方向的条件即可本质上还是一套 keyset 查询逻辑。第四条建议分页接口上线前要覆盖这几个测试用例——空数据返回、最后一页不足 page_size、并发翻页时数据重复率、排序字段相同的边界、游标参数异常传入。我在实际测试中发现很多分页 bug 都藏在排序字段相同的数据里比如两条记录的 created_at 完全相同游标拼接时如果忘了带上主键 id同一条数据会在两页里反复出现。最后说说我现在的习惯做新项目时默认给信息流和列表接口上游标分页只有后台管理类、报表类页面才回退到偏移量分页。这套偏好不是理论推导出来的是实打实被线上告警教育出来的。分页这个问题花在设计阶段的十分钟能省掉后面无数次救火的深夜。