从页码分页到游标分页:OpenAPI分页改造的完整实践与避坑指南
2026/9/9 20:32:34 网站建设 项目流程

接了个开放平台的整改需求,把对外提供的OpenAPI从页码分页整体迁到游标分页。改之前觉得这是个小活,无非就是把pageNum换成cursor,真动手才发现里面的坑比想象中深得多。尤其是接口被不少老客户端用着,参数一变,各种意外都冒出来了。这篇文章就打算把这次改造的完整思路、踩过的坑、以及最终落地的方案都梳理一遍,给正在做API设计或者准备优化分页逻辑的同行一个参考。

我自己是后端出身,平时做数据服务,对接口性能和数据一致性比较敏感。市面上不少团队的分页实现还停留在把数据库的limit offset直接暴露成API参数这种阶段,短期的确省事,但一旦数据量上来、接口被外部频繁调用,问题会非常集中地爆发。这篇文章主要针对三类读者:一是正在设计新接口的开发者,二是维护老接口被分页性能困扰的同仁,三是做数据中台或API网关相关工作的后端同学。

1. 页码分页的“默认”陷阱:为什么一开始大家都这么做

1.1 从PageHelper到limit offset:页码分页的实现成本确实低

几乎每一个Java后端都见过类似这样的代码:

PageHelper.startPage(pageNum, pageSize); List<User> users = userMapper.selectAll(); PageInfo<User> pageInfo = new PageInfo<>(users);

或者直接用MyBatis Plus的Page<T>

Page<User> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.orderByDesc(User::getCreateTime); userMapper.selectPage(page, wrapper);

这套写法最大的优点就是省事。你只需要告诉框架“第几页、每页几条”,它就会自动帮你拼LIMIT offset,甚至把总记录数都查出来。对于管理后台这种数据量撑死几万条的系统,这种做法绰绰有余。我见过不少小团队把这种写法从后台带到对外API,就是为了快。

但问题恰恰出在“快”上。对外API和内部后台的访问模式、数据规模完全不是一个量级。一旦接口开放给第三方,调用方的行为你是控制不了的,有人会写脚本凌晨3点循环拉全量数据,有人会把pageSize调到2000去翻最后几页,这些场景下页码分页的缺陷会成倍放大。

1.2 翻页过程中数据变动,页码分页会翻出“幽灵页”

页码分页的定位逻辑很简单:当前偏移量等于pageNum * pageSize,它假定数据是静态的,页码和记录之间存在固定映射。但这个假设在真实业务中几乎不成立。

举个最典型的例子:数据库里总共有100条记录,页大小10。第1页返回1~10条,此时有两条数据被业务删掉了,用户点“下一页”时,偏移量是10,从第11条开始取,你会发现第11、12条之外,原本第10条位置的数据被跳过了。反过来,如果两页之间插入了新数据,用户可能会在两次翻页中看到同一条记录。

对于用户浏览场景,重复或遗漏一条数据可能问题不大;但对于第三方系统对接、数据同步、批量任务拉取,这个问题是致命的。一批数据翻到一半,因为中间某次接口调用前有人删了两条记录,后续所有页都会错位,要么漏数据,要么重复数据,而且这种错位往往要等到最终校验总量时才发现。

1.3 深翻页性能:OFFSET的不是线性成本,是平方级增长

LIMIT 0, 20LIMIT 10000, 20在数据库层面的执行计划几乎一样,但实际成本差出两个量级。MySQL必须扫描并丢掉前10000行,才能返回你需要的20行。页数越大,丢掉的越多,性能下降不是线性的,而是随着偏移量的增加呈近似平方级的增长。

我之前做过一次压测,单表500万数据,走主键排序,LIMIT 0, 20耗时3毫秒,LIMIT 500000, 20耗时820毫秒,翻了270倍。如果这个接口的QPS再上去几个量级,数据库必然会被打满。

分页查询慢用Redis优化是很多文章喜欢讲的方案,把前N页结果缓存到Redis确实能解决一部分高频访问问题,但这只是一层缓存,治标不治本。只要用户翻到缓存覆盖不到的深页,数据库还是会扛完整的偏移量扫描。而且对外API有一个特点,翻到深页的往往不是普通用户,而是爬虫或同步任务,缓存对他们的命中率极低。

1.4 从“若依”到Element UI:生态默认了页码分页

你会发现困扰很多人的“若依分页查询”问题、Element UI分页组件的行为,本质上都在强化页码分页的心智。前端表格组件要展示总页数、支持跳页,天然适合页码分页;但后端如果不加思考地把API直接对上前端组件的入参,就把前端逻辑和数据库查询耦合在了一起。

Element UI的el-pagination只是把用户输入的current-pagepage-size发到后端,它本身不关心数据是怎么查出来的。很多后端看到前端传了pageNum就顺手写个LIMIT offset,这个链条看着顺理成章,其实是问题最深的地方——不是生态错了,是你在把数据查询的内部实现直接暴露给调用方。

2. API侧的错觉:把前端翻页需求直接映射成API参数

2.1 前端分页组件与后端API脱节:谁在用pageNum和pageSize

前面说到Element UI,这里展开讲一下。前端分页组件通常围绕两个需求工作:展示“当前页/总页数”、允许用户“跳转到指定页”。这两个需求对应的就是current-pagetotal两个值。因此前端文档里通常会要求后端返回“当前页数据列表”和“总条数”。

后端一旦被前端牵着走,就会习惯性把pageNumpageSize设为API的必填参数,并在响应体里写死total字段。这套设计对管理后台够用,但对外部开发者来说,他们调用API往往并不想知道“总共有多少页”,他们只想知道“我接下来要传什么参数才能拿到下一批数据”。

我的建议是,对外API的文档尽量别出现“页码”这个词。它是内部实现,不是业务语义。真正的业务语义是“给我下一页”,而不是“给我第5页”。

2.2 同步与ETL场景:Kettle一把梭翻到天荒地老

搜索热词里有一个很典型的需求:用Kettle的HTTP组件获取API分页数据。这种低代码ETL工具的典型用法是,通过循环调用接口,每次递增pageNum,直到取不到数据为止。

这在页码分页下会踩两个坑。第一是深翻页性能问题,翻到第几百页时接口响应会越来越慢,整个同步链路被拖死。第二是数据一致性,只要目标系统在同步过程中有任何写操作,下一次取数的偏移量就不可信了,同步结果和源库对不上。

我当时排查过一个真实故障,Kettle任务每天早上6点同步订单数据,下午两点发现两边差了37条记录。查到最后是因为源系统的订单表在凌晨有批量删除操作,导致Kettle拉到第20页左右开始错位,后面的数据全部漏掉了。

如果当时接口用的游标分页,这个故障根本不会发生。游标指向的是一条确定的记录,无论源表数据怎么变,下一次查询都从这条记录之后继续,不会因为中间少了几条数据而错位。

2.3 分页查询不是幂等读取:并发环境下页码的“幻读”

还有一类场景容易被忽略:并发写操作比较频繁的业务表,页码分页几乎一定出现数据不一致。你第1页读到了id=10的记录,插入了一批新数据后第2页又从id=10往后读了,id=10被你读了两次。反过来,删掉一批数据后,id=10可能永远不会出现在任何一页里。

这不是数据库的问题,是你用页码这种相对位置去标记绝对数据流。相对位置天然受数据变动影响,标记数据流正确的方式是给每条记录一个绝对位置标尺——主键ID、时间戳、或者组合排序键。

我印象里有个做电商供应链的朋友,他们开放订单查询接口用页码分页,第三方ERP每两小时拉一次增量,结果每周都要花半天时间手工对账。后来换成了按最后更新时间游标分页,对账的活儿直接少了一个人月。

3. OpenAPI规范为什么忌讳页码分页:游标分页的语义正确性

3.1 OpenAPI里分页参数怎么描述,直接暴露了设计水平

写OpenAPI(也就是Swagger)定义时,很多人会怎么写分页参数?大概率是这样的:

parameters: - name: pageNum in: query required: false schema: type: integer default: 1 - name: pageSize in: query required: false schema: type: integer default: 20

这段描述本身没错,但它只描述了“参数是什么”,没有描述“分页的行为是什么”。调用方看完文档依然不知道:传入pageNum=3时,如果第一页和第二页之间数据变了,会发生什么?

OpenAPI 3.0里没有专门的分页字段定义,但规范文档里明确建议,API的参数语义应当是自解释的。如果一个参数名需要调用方理解内部实现才能用对,那这个参数设计得就不合格。cursor作为参数名的好处是它不携带任何相对位置的信息,调用方只需要老老实实把上一次响应里返回的next_cursor原样传回来就行。

3.2 游标(Cursor)的本质:不是“下一页”,是“从这里继续”

游标分页的本质可以理解成:你在读一本没有装订的书,为了记住上次读到哪里,你夹了一个书签。书签上写的是“第5页”和写的是“第3个自然段的第2句话”效果完全不同。前者在书页被抽掉后立即失效,后者无论书怎么变,都能准确定位。

在数据库层面,这个“书签”就是一条记录的唯一标识。常见做法是基于自增主键:查询条件写成WHERE id > ?,排序按id ASC,每页取固定条数,返回当前页最后一条记录的id作为下一次的游标。

基于主键的游标最简单,但有一个前提——表的删除操作不能太频繁。如果游标对应的记录被删了,这个游标就失效了。在实际工程里可以用逻辑删除代替物理删除,或者把游标设计成复合键,用created_at + id甚至(created_at, id)的元组,这样即使主记录被删,时间戳也能兜住。

3.3 游标分页的三种形态:主键游标、时间游标、复合游标

  • 主键游标:最简单,适合auto_increment主键或雪花ID的表。查询条件WHERE id > ?,排序ORDER BY id ASC。优点是索引利用充分,性能极好;缺点是如果主键不是递增的,比如UUID主键,游标就无从谈起。
  • 时间游标:把最后一条记录的创建时间或更新时间作为游标。适合按时间维度的增量同步,比如“拉取最近5分钟的新订单”。但时间可能重复,因此通常要加上主键作为次级排序键,确保游标位置唯一。
  • 复合游标:把排序键序列化成一个不透明字符串,通常是base64(created_at + '_' + id)。服务端解析后转成WHERE (created_at > ? OR (created_at = ? AND id > ?))。这样才能保证排序的严格单调。

我最终在OpenAPI改造里用的是复合游标,主要原因是我们允许用户按任意字段排序,并且排序字段并不是唯一键。

3.4 游标分页与页码分页的完整对比

维度页码分页 (pageNum/pageSize)游标分页 (cursor)
深翻页性能差,OFFSET越大扫描越多好,只扫描游标之后的数据
数据一致性受插入/删除影响,易重复或遗漏不受影响,始终从上次位置继续
随机跳页支持,依赖总页数和偏移量不支持,只能顺序翻页
总条数统计容易提供,COUNT(*)即可不易提供,需要额外成本
实时性相对位置随数据变化漂移绝对位置,稳定可靠
实现复杂度中高,需要处理游标生成和解析
适用场景管理后台、固定数据集合对外API、数据同步、无限滚动

4. 游标分页落地:从MySQL到OpenAPI定义的一次完整改造

4.1 游标怎么生成:可逆编码的细节

游标不能直接把数据库字段值裸奔出去,原因有两个:一是暴露内部主键ID容易被人遍历抓取;二是排序字段可能包含多列,裸传多个参数太丑。最佳实践是把游标做一次编码,最简单的方案是JSON打包后base64。

import base64 import json def encode_cursor(last_id: int, last_created_at: str) -> str: payload = { "id": last_id, "created_at": last_created_at } raw = json.dumps(payload, separators=(',', ':')).encode('utf-8') return base64.urlsafe_b64encode(raw).decode('utf-8') def decode_cursor(cursor: str) -> dict: raw = base64.urlsafe_b64decode(cursor.encode('utf-8')) return json.loads(raw)

这里要特别注意用urlsafe_b64encode而不是普通b64encode,因为普通base64会包含+/,这两个字符放在URL的query参数里会被转义或截断。我用Python举例,是因为做工具链的兄弟很多用Python;Java端用Base64.getUrlEncoder()也能达到同样效果。

4.2 SQL怎么写:Keyset分页的查询条件

拿到了游标之后,SQL就不能写成LIMIT offset了,要改成keyset形式的条件查询:

SELECT id, order_no, created_at, amount FROM orders WHERE (created_at < #{lastCreatedAt} -- 按创建时间倒序 OR (created_at = #{lastCreatedAt} AND id < #{lastId})) ORDER BY created_at DESC, id DESC LIMIT #{pageSize}

注意这里的LIMIT只是限制返回条数,不再承担偏移任务。数据库可以从游标定位到目标记录,然后顺序向后扫描,最多扫pageSize + 1条就够。这个SQL在(created_at, id)联合索引下执行效率非常高。

反过来,如果按正序排,查询条件用>;按倒序排,查询条件用<。关键是游标字段必须和ORDER BY字段完全一致,否则游标就失效了。我在改造过程中踩过这个坑:排序字段是created_at DESC, id DESC,但游标里只编码了created_at,结果同一秒内多条数据时反复漏数据。

4.3 OpenAPI定义怎么描述响应结构:next_cursor与has_more

游标分页API的响应体长这样:

{ "items": [ { "id": "10086", "order_no": "SO20240101001", "created_at": "2024-01-01 12:00:00" } ], "next_cursor": "eyJpZCI6MTAwODYsImNyZWF0ZWRfYXQiOiIyMDI0LTAxLTAxIDEyOjAwOjAwIn0=", "has_more": true }

对应的OpenAPI定义:

components: schemas: OrderPage: type: object properties: items: type: array items: $ref: '#/components/schemas/Order' next_cursor: type: string description: 下一页游标,has_more为false时为空 example: "eyJpZCI6MTAwODYs..." has_more: type: boolean description: 是否还有下一页

我强烈建议把has_more字段加上。调用方拿到false就知道数据取完了,不用再费力判断next_cursor是否为空。很多ETL工具写循环也依赖这个字段来终止。

4.4 避坑点:排序字段的约束直接决定游标生死

这不是一个可以偷懒的地方。游标分页对排序字段有三个硬性要求:

  1. 排序字段必须唯一:纯按created_at排序,同一毫秒内创建了两条记录,游标就会出现二义性。要么加主键作为次要排序,要么选一个真正唯一的业务键。
  2. 排序字段必须是索引前缀:数据库查询要高效利用索引,ORDER BY字段必须在联合索引的最左前缀里。否则即使逻辑正确,性能也上不去。
  3. 排序期间字段值不能变:如果游标选了updated_at,但业务上有字段更新不刷新updated_at的情况,数据流就会中断。

正常情况下,我的配置是ORDER BY created_at DESC, id DESC,索引是idx_created_at_id(created_at, id)。这基本是通用王牌组合。

4.5 老客户端兼容:用户不信你,你要给过渡方案

任何API改造都绕不开兼容问题。我当时的处理是,新接口/v2/orders用游标分页,老接口/v1/orders保留页码分页三个月。一个月后看监控,v1的调用量还剩不到5%,剩下的几乎都是没人维护的历史脚本,就直接发公告下线了。

为什么不让老接口直接改参数?因为老接口的调用方已经把pageNum写死在代码里了,接口行为一变,他们的程序就崩了。给足过渡期是API服务者基本的职业素养。

如果实在需要老接口平滑升级,还有一种方案:保留pageNum/pageSize参数,但后端内部自动换算成游标,响应里额外带上next_cursor。但我不推荐这种方案,因为它会让游标分页特有的参数语义被稀释,你永远在维护两套心智。

5. 分页方案选型参考:不同业务场景真不是一套方案打天下

5.1 管理后台、报表系统:页码分页仍然是最优解

管理后台的分页有自己的特点:数据量固定(往往限定在某个查询条件内)、需要跳页、需要看总条数。比如订单管理页面,运营人员会点第2页、第8页,甚至直接输页码跳到第50页。这种情况下你让他游标翻页,一次翻50次?绝对不可接受。

所以我的原则是,内部系统能页码分页就页码分页,对外API才优先游标。内部系统的数据量是可控的,表结构是自己管的,就算深翻页性能有点问题,加个LIMIT上限,或者干脆限制只能查前100页,就解决了。

还有一种情况也需要页码分页:数据是静态快照。比如导出某个月的所有账单,这一个月的数据不会变,用页码分页一点问题没有,而且还能快速跳到指定页。

5.2 对外API、无限滚动、数据同步:无脑游标分页

开放平台对外API,是我唯一推荐“无脑游标分页”的场景。理由不复杂:你根本不认识调用方,你不知道他会怎么调用、翻到多少页、调用间隔多久。游标分页性能稳定、语义清晰,能帮你挡掉绝大多数由分页导致的数据不一致问题。

无限滚动的APP场景也一样,用户在信息流里往下刷,永远只需要“加载更多”,不需要“跳到第3页”。游标分页和产品形态严丝合缝。

5.3 时间戳分页的边界问题:增量同步要结合业务时间

我特别想聊一下时间戳分页。很多团队用updated_at > ?做增量同步,这比页码分页强多了,但有两个坑:

第一,如果业务系统更新数据时没有更新updated_at,增量同步会漏数据。排查这种问题极其痛苦,因为不是每次都漏,只有特定字段修改时漏。第二,同步任务的时间窗口边界,比如任务每5分钟跑一次,但上一批次的数据在下一批次开始时才提交事务,就会出现1~2秒的窗口期数据漏拉。

解决方式一般是双保险:时间条件之外再加一个主键条件作为边界,并且把时间窗口的重叠区设置为15%左右,用幂等写入来兜底。游标分页解决的是“位置漂移”的问题,它还解决不了“时间窗口”的问题,这个要注意。

5.4 分页缓冲池占用高:Redis缓存与缓冲池调优的本质

搜索热词里有“redis存数据分页”“分页缓冲池占用很高怎么解决”,这两个问题我顺带说一句。分页查询慢Redis优化,本质是针对热点页做缓存,比如前10页的查询结果缓存到Redis,设置TTL 30秒。深页依然会打数据库,所以它不解决深翻页性能问题,只解决热点页压力。

分页缓冲池占用高,通常是InnoDB的buffer_pool里大量脏页和LRU列表被扫描查询来回刷。深翻页的场景下,OFFSET前的数据会被反复读入缓冲池,挤占真正需要缓存的热数据。这条路走到头,还是得改游标分页——从根源消除无效IO。

如果你查数据库时发现SHOW ENGINE INNODB STATUSBuffer pool hit rate已经低于95%,先别急着加内存,翻一下慢查询日志里有没有深翻页的SQL。早改分页方式比加内存划算得多。

6. 踩坑记录:三个可以复现的排查链路

6.1 现象一:接口翻页越深,耗时越长,最后直接超时

排查过程

  1. 先看监控系统,接口P99耗时从第80页开始陡增。
  2. 打开MySQL慢查询日志,发现大量ORDER BY create_time DESC LIMIT 8000, 20的SQL。
  3. EXPLAIN一看,type=ALL,全表扫描,扫描行数12万。

根因:表数据量到了几十万,深翻页的OFFSET扫描成本超出预期,且没有合适的索引支撑任何深层查询。

修复:将ORDER BY create_time DESC, id DESC加上联合索引idx_create_time_id,并把接口切到游标分页。改造后相同数据量的接口,最坏情况耗时从850ms降到40ms。

提示:不要以为加了索引就能救页码分页。即使ORDER BY字段有索引,OFFSET 8000依然要扫前8000条,索引只能让你的全表扫描变成索引扫描,成本依然随页码增加。唯一的根治方案是不要用OFFSET。

6.2 现象二:同步任务重复拉取同一批数据

排查过程

  1. Kettle定时任务读到第3页时,源系统insert了10条新记录。
  2. 第4页的OFFSET随之偏移了10,本应第4页的数据被挤到了第5页。
  3. 但任务已经拉完了第4页、第5页,之后通过日志对比发现,中间有10条数据被重复拉取。

根因:页码分页的相对位置被并发写入破坏,导致滑动窗口重叠。

修复:源系统API改为游标分页,同步任务改为循环读取next_cursor直到has_more=false。之后再也没出现过重复或遗漏。

类似场景:不只是Kettle,任何用for (page=1; ; page++)接口调用方式的脚本都会有这个隐患。写同步脚本的时候,循环条件千万别写成“当返回不足一页时停止”,要写成“当has_more=false时停止”。

6.3 现象三:游标分页上线后,用户反馈数据“跳变”

排查过程

  1. 新接口上线第3天,有用户反馈翻页时偶尔跳过一条数据。
  2. 排查SQL,游标解析和WHERE条件都没问题。
  3. 最后发现游标编码时只取了created_at,同一秒内插入的多条数据排序不稳定,导致两条记录游标位置相同。

根因:排序字段不唯一,游标位置二义性。

修复:游标改为created_at + id复合键,排序条件变成(created_at < ? OR (created_at = ? AND id < ?)),问题消失。

注意这属于游标分页最容易深藏的问题。数据量不大时,时间戳撞车的概率很低,一旦量上来,每秒插入几十条时,纯时间戳游标几乎必出问题。

6.4 经验总结:三条硬性约束,缺一条都会翻车

结合上面的三个故障,我把游标分页落地时最容易忽视的点总结成三条:

  • 第一条,游标编码必须包含完整排序键,排序是一个字段就编一个字段,排序是两个字段就编两个字段,偷懒少编一个,必然出问题。
  • 第二条,ORDER BY字段必须建联合索引,排序列不在索引内,游标分页的性能优势就白白浪费了。
  • 第三条,老客户端一定要给过渡期,API改造的落地难度往往不在技术,而在推进过程中老调用方的配合。

我自己在实际操作中的体会是,游标分页不是银弹,它牺牲了随机跳页能力,换来了稳定的性能和一致的数据流。做API设计,最怕的不是选错方案,而是拿了前端交互的需求套到后端数据查询上。先把接口的消费者想清楚——是人在屏幕上点,还是程序在循环里刷——再决定分页方案,基本不会走偏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询