ARTICLE DETAIL

资讯详情

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

从页码分页到游标分页:OpenAPI分页改造的完整实践与避坑指南

从页码分页到游标分页:OpenAPI分页改造的完整实践与避坑指南 接了个开放平台的整改需求把对外提供的OpenAPI从页码分页整体迁到游标分页。改之前觉得这是个小活无非就是把pageNum换成cursor真动手才发现里面的坑比想象中深得多。尤其是接口被不少老客户端用着参数一变各种意外都冒出来了。这篇文章就打算把这次改造的完整思路、踩过的坑、以及最终落地的方案都梳理一遍给正在做API设计或者准备优化分页逻辑的同行一个参考。我自己是后端出身平时做数据服务对接口性能和数据一致性比较敏感。市面上不少团队的分页实现还停留在把数据库的limit offset直接暴露成API参数这种阶段短期的确省事但一旦数据量上来、接口被外部频繁调用问题会非常集中地爆发。这篇文章主要针对三类读者一是正在设计新接口的开发者二是维护老接口被分页性能困扰的同仁三是做数据中台或API网关相关工作的后端同学。1. 页码分页的“默认”陷阱为什么一开始大家都这么做1.1 从PageHelper到limit offset页码分页的实现成本确实低几乎每一个Java后端都见过类似这样的代码PageHelper.startPage(pageNum, pageSize); ListUser users userMapper.selectAll(); PageInfoUser pageInfo new PageInfo(users);或者直接用MyBatis Plus的PageTPageUser page new Page(pageNum, pageSize); LambdaQueryWrapperUser 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, 20和LIMIT 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-page和page-size发到后端它本身不关心数据是怎么查出来的。很多后端看到前端传了pageNum就顺手写个LIMIT offset这个链条看着顺理成章其实是问题最深的地方——不是生态错了是你在把数据查询的内部实现直接暴露给调用方。2. API侧的错觉把前端翻页需求直接映射成API参数2.1 前端分页组件与后端API脱节谁在用pageNum和pageSize前面说到Element UI这里展开讲一下。前端分页组件通常围绕两个需求工作展示“当前页/总页数”、允许用户“跳转到指定页”。这两个需求对应的就是current-page和total两个值。因此前端文档里通常会要求后端返回“当前页数据列表”和“总条数”。后端一旦被前端牵着走就会习惯性把pageNum、pageSize设为API的必填参数并在响应体里写死total字段。这套设计对管理后台够用但对外部开发者来说他们调用API往往并不想知道“总共有多少页”他们只想知道“我接下来要传什么参数才能拿到下一批数据”。我的建议是对外API的文档尽量别出现“页码”这个词。它是内部实现不是业务语义。真正的业务语义是“给我下一页”而不是“给我第5页”。2.2 同步与ETL场景Kettle一把梭翻到天荒地老搜索热词里有一个很典型的需求用Kettle的HTTP组件获取API分页数据。这种低代码ETL工具的典型用法是通过循环调用接口每次递增pageNum直到取不到数据为止。这在页码分页下会踩两个坑。第一是深翻页性能问题翻到第几百页时接口响应会越来越慢整个同步链路被拖死。第二是数据一致性只要目标系统在同步过程中有任何写操作下一次取数的偏移量就不可信了同步结果和源库对不上。我当时排查过一个真实故障Kettle任务每天早上6点同步订单数据下午两点发现两边差了37条记录。查到最后是因为源系统的订单表在凌晨有批量删除操作导致Kettle拉到第20页左右开始错位后面的数据全部漏掉了。如果当时接口用的游标分页这个故障根本不会发生。游标指向的是一条确定的记录无论源表数据怎么变下一次查询都从这条记录之后继续不会因为中间少了几条数据而错位。2.3 分页查询不是幂等读取并发环境下页码的“幻读”还有一类场景容易被忽略并发写操作比较频繁的业务表页码分页几乎一定出现数据不一致。你第1页读到了id10的记录插入了一批新数据后第2页又从id10往后读了id10被你读了两次。反过来删掉一批数据后id10可能永远不会出现在任何一页里。这不是数据库的问题是你用页码这种相对位置去标记绝对数据流。相对位置天然受数据变动影响标记数据流正确的方式是给每条记录一个绝对位置标尺——主键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这段描述本身没错但它只描述了“参数是什么”没有描述“分页的行为是什么”。调用方看完文档依然不知道传入pageNum3时如果第一页和第二页之间数据变了会发生什么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举例是因为做工具链的兄弟很多用PythonJava端用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 避坑点排序字段的约束直接决定游标生死这不是一个可以偷懒的地方。游标分页对排序字段有三个硬性要求排序字段必须唯一纯按created_at排序同一毫秒内创建了两条记录游标就会出现二义性。要么加主键作为次要排序要么选一个真正唯一的业务键。排序字段必须是索引前缀数据库查询要高效利用索引ORDER BY字段必须在联合索引的最左前缀里。否则即使逻辑正确性能也上不去。排序期间字段值不能变如果游标选了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 STATUS里Buffer pool hit rate已经低于95%先别急着加内存翻一下慢查询日志里有没有深翻页的SQL。早改分页方式比加内存划算得多。6. 踩坑记录三个可以复现的排查链路6.1 现象一接口翻页越深耗时越长最后直接超时排查过程先看监控系统接口P99耗时从第80页开始陡增。打开MySQL慢查询日志发现大量ORDER BY create_time DESC LIMIT 8000, 20的SQL。用EXPLAIN一看typeALL全表扫描扫描行数12万。根因表数据量到了几十万深翻页的OFFSET扫描成本超出预期且没有合适的索引支撑任何深层查询。修复将ORDER BY create_time DESC, id DESC加上联合索引idx_create_time_id并把接口切到游标分页。改造后相同数据量的接口最坏情况耗时从850ms降到40ms。提示不要以为加了索引就能救页码分页。即使ORDER BY字段有索引OFFSET 8000依然要扫前8000条索引只能让你的全表扫描变成索引扫描成本依然随页码增加。唯一的根治方案是不要用OFFSET。6.2 现象二同步任务重复拉取同一批数据排查过程Kettle定时任务读到第3页时源系统insert了10条新记录。第4页的OFFSET随之偏移了10本应第4页的数据被挤到了第5页。但任务已经拉完了第4页、第5页之后通过日志对比发现中间有10条数据被重复拉取。根因页码分页的相对位置被并发写入破坏导致滑动窗口重叠。修复源系统API改为游标分页同步任务改为循环读取next_cursor直到has_morefalse。之后再也没出现过重复或遗漏。类似场景不只是Kettle任何用for (page1; ; page)接口调用方式的脚本都会有这个隐患。写同步脚本的时候循环条件千万别写成“当返回不足一页时停止”要写成“当has_morefalse时停止”。6.3 现象三游标分页上线后用户反馈数据“跳变”排查过程新接口上线第3天有用户反馈翻页时偶尔跳过一条数据。排查SQL游标解析和WHERE条件都没问题。最后发现游标编码时只取了created_at同一秒内插入的多条数据排序不稳定导致两条记录游标位置相同。根因排序字段不唯一游标位置二义性。修复游标改为created_at id复合键排序条件变成(created_at ? OR (created_at ? AND id ?))问题消失。注意这属于游标分页最容易深藏的问题。数据量不大时时间戳撞车的概率很低一旦量上来每秒插入几十条时纯时间戳游标几乎必出问题。6.4 经验总结三条硬性约束缺一条都会翻车结合上面的三个故障我把游标分页落地时最容易忽视的点总结成三条第一条游标编码必须包含完整排序键排序是一个字段就编一个字段排序是两个字段就编两个字段偷懒少编一个必然出问题。第二条ORDER BY字段必须建联合索引排序列不在索引内游标分页的性能优势就白白浪费了。第三条老客户端一定要给过渡期API改造的落地难度往往不在技术而在推进过程中老调用方的配合。我自己在实际操作中的体会是游标分页不是银弹它牺牲了随机跳页能力换来了稳定的性能和一致的数据流。做API设计最怕的不是选错方案而是拿了前端交互的需求套到后端数据查询上。先把接口的消费者想清楚——是人在屏幕上点还是程序在循环里刷——再决定分页方案基本不会走偏。
返回列表