
1. 先搞清楚PageIndex的出身0基还是1基这是所有混乱的源头做过后端接口对接的朋友应该都体会过前后端联调分页功能时最诡异的一类Bug就是第一页正常第二页的数据和第二页重复或者第二页莫名其妙少了一条。排查到最后十有八九是PageIndex的基数约定不一致。我最早被这个坑压到是在一次给管理后台做列表接口时。前端用的组件库分页器默认从1开始计数而后端用的分页框架Pageable默认从0开始。前端把currentPage2传给后端后端解析成offset2*1020直接跳过了第10到第19条数据页面看起来就是“漏了一屏”。这种问题不在报错时暴露只在数据对不上时暴露非常阴。不同技术栈对PageIndex的起点定义差异很大。我整理了一个表大家对接前先对照一下技术栈/场景PageIndex基数备注大多数前端组件库Element Plus、Ant Design等从1开始用户可见的“第几页”Spring Data JPA / Spring Pageable从0开始page0表示第一页MyBatis PageHelper从1开始文档明确pageNum从1起原生SQL LIMIT写法从0开始offset0对应第一页常见API网关分页参数从1开始多数自定义接口约定数据库游标类型分页无PageIndex改用cursor/nextToken这个表的意思不是说谁对谁错而是说PageIndex本身就是一个约定俗成的概念跨语言、跨框架时会悄然改变语义。最保险的做法是后端接口文档里明确写出“pageIndex从1开始”或“page从0开始”并且用参数校验挡掉非法值。不要指望前端能记住后端框架的默认值。另外一个容易混淆的细节是pageSize的边界。很多后端框架允许pageSize传很大甚至传10000直接把数据库压垮。我在接口层面通常会对pageSize做clamp处理比如最大只能取100超过就报参数错误或静默截断。这种防御看起来“多此一举”但线上环境被扫到一个超大pageSize确实会拖垮数据库这类事故不少见。还有一个隐藏点PageIndex和总页数的关系。后端计算总页数时常用公式是totalPage Math.ceil(total / pageSize)。前端根据这个值来决定是否显示“下一页”按钮。如果前端自己用total / pageSize然后向下取整来算就会出现总页数差一页的情况最后一页永远点不进去或者点进去是空的。前后端对总页数计算口径不一致也是PageIndex相关的经典联调事故。提示规范的返回体里一定要同时返回total、totalPage、pageIndex、pageSize这四件套不要只返回total。前端拿到这四个字段后不需要自己推总页数直接渲染即可能从根上避免口径不一致的问题。2. 前端组件里的PageIndex拿到的是页码不是下标前端工程师每天都要跟分页组件打交道但很多人没意识到组件的change事件里回调的那个参数究竟是页码还是索引是极容易搞混的。以Element Plus的el-pagination为例current-change事件的回调参数是当前的current-page这个值的起点是1也就是用户眼里的“第1页”。但如果你在请求后端时分页接口正好是0基的就必须手动做currentPage - 1的换算。这个换算逻辑看似简单真正写起来却有很多细节。比如搜索条件变化后原本在第5页搜索结果只有2页此时如果不把pageIndex重置为默认第一页请求发出的页码就落在空数据上页面上显示一片空白用户以为搜索失败了。正确做法是搜索按钮点击时先把pageIndex重置为初始值通常是1再发起请求。另外一类很常见的问题是“删除末页最后一条数据”。假设当前在第5页每页10条恰好这一页只有1条数据删除之后这一页就空了。如果不重新计算总页数并将pageIndex回退到新的最后一页用户停留在第5页看到的就是个空表同时“删除成功”的提示也已经弹出体验非常割裂。前端还有一个隐性场景表格的滚动加载无限滚动模式下PageIndex概念会变成“已加载页数”。这类模式我习惯用一个自增的counter来实现每次滚动到底部时先判断是否还有下一页再让counter自增。但要注意如果此时用户切换了筛选条件counter必须重置为1同时清空已有列表数据。否则新旧条件的数据会混合在同一份列表里看起来就是数据错乱。这个问题在真实项目中几乎每周都能遇到——很多人忘了重置counter导致切换筛选后列表里混着上一次条件的数据。无限滚动还有一个坑已加载完全部数据后下拉刷新会重复请求同一页。前端需要用一个hasMore标志来终止后续请求并且在请求结束前加锁防止重复触发。我见过几个项目在这个问题上翻车最后的解决方案都是在请求层加了一个简单的isLoading状态拦截避免并发请求同一页。注意凡是涉及PageIndex的异步请求都应考虑“竞态条件”。用户快速翻页或快速切换条件时先发出的请求可能后返回后发出的请求反而先返回导致最终显示的数据和当前页码对不上。解决方案很简单在请求层记录最后一次发出的请求序号响应返回时只处理最新那个序号。这个习惯能省很多时间。3. 后端分页接口的参数校验与返回设计写后端分页接口时大部分人会把注意力放在SQL怎么写却很少认真设计参数校验和返回结构。但实际上PageIndex的参数校验才是决定这个接口“抗不抗造”的关键。我通常会在Controller入口做如下校验GetMapping(/list) public ResultPageResultItemVO list( RequestParam(defaultValue 1) Integer pageIndex, RequestParam(defaultValue 10) Integer pageSize) { // 页码最小为1pageSize限制在1~100之间 if (pageIndex null || pageIndex 1) { pageIndex 1; } if (pageSize null || pageSize 1) { pageSize 10; } pageSize Math.min(pageSize, 100); // 计算offset int offset (pageIndex - 1) * pageSize; // ... }这里有几个原则不要因为PageIndex不合法就抛异常。分页请求来自前端或客户端参数不合法时更友好的响应是返回空列表而不是一个500错误。对pageIndex做下界截断小于1时重置为1但不做上界截断。总页数是变动的直接限制pageIndex不超过totalPage反而会引入脏数据问题不如让SQL自己返回空集。pageSize一定要有上限。100是个比较稳妥的默认值超过上限的请求要么截断要么拒绝。返回体设计方面我推荐用一个统一的分页包装类{ code: 0, message: ok, data: { list: [], total: 1024, totalPage: 103, pageIndex: 1, pageSize: 10 } }totalPage由后端统一计算前端不要自行推导。这样前端组件可以直接用totalPage控制分页器的最大页码避免每一端各自算算出来的结果不一致。另外查询总数和查询列表是两条独立的SQL这两条SQL必须放在同一个事务边界内吗其实不一定。对绝大多数业务来说数据量在百万级以内count和list之间的时间差造成的新增或删除对用户来说是无感的。如果你强行加事务反而可能因为长事务影响到写入性能。我一般的做法是count查询走从库list查询也走从库主从延迟稍大时允许轻微的数据不一致换来的是主库的写入压力大幅下降。提示如果list查询结果为空但total大于0这通常是页码越界的标志。此时可以直接把pageIndex修正为totalPage并重查一次这个重试逻辑放在后端接口内部前端完全无感。它能有效避免“第100页只剩一条数据删掉后页面空白”的问题。4. 从PageIndex到深分页页码变大之后的性能拐点PageIndex本身只是一个数字真正让人头疼的是这个数字变大以后。用一个最简单的例子说明LIMIT 1000000, 10。MySQL执行这段SQL时不是直接从第100万条开始读10条而是先把前100万条全部扫描一遍并丢掉然后才返回第100万条之后的10条。这意味着你翻得越深数据库做无用功越多响应时间呈线性甚至超线性增长。到了几百万页的深渊一个普通的列表接口就能把数据库连接池打满。我先解释一下为什么MySQL会这么笨。原因是MyISAM和InnoDB的行存储并不是按你查询的排序顺序物理排列的数据库必须通过索引扫描来定位“第N行”。如果走的是主键索引它确实可以快速跳到某个主键值附近但LIMIT OFFSET不能直接转换成主键位置只能老老实实从第一条开始数。所以offset越大扫描量就越大这是结构性的劣势无法通过加索引根治。解决深分页有两条主流路径路径一延迟关联也叫子查询分页。SQL改成SELECT * FROM orders WHERE id ( SELECT id FROM orders ORDER BY id LIMIT 1000000, 1 ) ORDER BY id LIMIT 10;内层子查询虽然也要扫100万行去找起点ID但因为它只返回一个主键值不需要回表拿完整行数据所以整体IO量比直接LIMIT OFFSET小很多——这种优化方式在许多场景下能让耗时从数秒降到几十毫秒以内。路径二书签keyset分页也叫“关建键值定位”。核心思路是放弃PageIndex改成“查下一页时带上上一页最后一条数据的排序字段值”SELECT * FROM orders WHERE create_time 2025-06-01 12:00:00 ORDER BY create_time DESC LIMIT 10;这种写法天然规避了OFFSET扫描问题因为数据库可以直接用复合索引定位到create_time的指定位置速度与页码无关。代价是用户无法直接跳转到第500页只能一页一页往下翻。我把这两种方案的取舍总结一下方案优点缺点适用场景传统LIMIT PageIndex实现简单支持跳页深分页性能差后台管理、数据量小延迟关联深翻页性能提升明显逻辑改动小仍要扫描offset前的索引数据量较大、仍需要跳页书签分页翻页速度恒定性能最好不支持跳页移动端信息流、搜索结果5. 从PageIndex到游标什么时候应该换分页方案书签分页方案落地时接口的入参就不应该再叫PageIndex了而应该叫cursor或者nextToken。这是一个根本性的API设计变化从“第几页”变成“从哪条开始”。我建议改造的时机是数据量达到百万级且用户日常操作多为“往下翻”时列表顺序按时间倒序排列而不是允许用户按任意字段排序时对实时性有要求不希望翻页过程中插入新数据导致重复或漏读时游标分页的典型接口结构如下{ data: { list: [ { id: 1024, title: hello, createTime: ... } ], nextCursor: 1024, hasMore: true } }客户端每次请求带上前一次响应中的nextCursor服务端用这个值定位起点SELECT * FROM articles WHERE id #{nextCursor} ORDER BY id DESC LIMIT #{pageSize};由于id是主键这个查询几乎可以恒定地在几毫秒内返回无论前面已经翻了多少页。这种体验在用户刷信息流时是碾压式的。但游标分页也不是银弹。它最大的问题是无法跳页。产品经理如果说“用户要能直接点第38页”那游标方案就直接出局。其次是排序不灵活只支持单一稳定排序字段通常是主键或时间戳如果列表允许用户按价格、热度等多种字段排序用游标就会很别扭因为每个排序字段都要设计对应的游标编码。所以我的设计原则是管理端、分页跳转需求明确时继续用PageIndexC端信息流、时间线类列表优先考虑游标。两种不是替代关系而是定位互补。老项目不要贸然把PageIndex全改成cursor可以先从高频访问的接口试点观察性能和用户反馈后再铺开。经验分享如果短期内不想引入游标但又要缓解深分页问题可以先做“分页上限控制”——后端限制PageIndex最大只能到100页或200页超过后返回空列表并在响应里加一个提示字段。对于绝大多数用户来说他们根本不会翻到第100页以后这个限制几乎不影响正常使用却能有效阻止恶意爬虫深翻你的页面。6. 实战排错复盘三个PageIndex相关的典型事故6.1 第一页和最后一页数据重复现象后台列表第一页最后一条数据和第二页第一条数据重复或者相反地漏了一条。数据量小时看不出来数据量大了就露馅。排查链路先确认SQL里LIMIT的offset是不是算错了。果然后端用的分页框架是0基前端传的是1基中间少了一次减1。修复方式在Controller统一做pageIndex - 1的换算禁止在业务层再算一次避免每个接口各自换算导致每次都不一样。这个案例最典型但也很容易在大型项目中反复出现——每个接口的入参命名不统一有的叫page有的叫pageIndex有的叫currentPage有的直接用offset。出现重复数据的根因是“两层换算”所以后来我定了个死规矩前端传的页码统一叫pageIndex且从1开始后端At层统一换算为offset业务层只认offset任何地方不许再出现“页码减一”的操作。6.2 搜索条件变化后页面停留在深层现象用户在第10页搜索新关键词结果集只有2页前端把请求发出去了后端返回空数据。页面白屏用户以为系统坏了。排查链路看请求参数pageIndex确实还带着10。看后端返回list是空的totalPage只有2。问题是前端没有在搜索时重置pageIndex。修复方式写一个统一的search方法先setPageIndex(1)再fetchList()。另外后端做了越界修正逻辑前面提到的totalPage重查双保险。排查这类问题有一个快速技巧把Network面板的请求参数和后端返回的totalPage、pageIndex字段对照起来看。一旦发现请求的pageIndex远远大于totalPage前端重置逻辑一定有缺陷。6.3 导出功能把所有分页参数全忽略现象列表分页正常但点“导出全部”时后端只导出了第一页的10条。排查链路导出接口没有复用列表接口而是自己写了一条无分页的SQL。但导出代码里复制了列表服务的部分逻辑分析师在拼SQL时把LIMIT条件也带上了。因为导出时不传pageIndex参数导致前端传来的pageIndex为nullMyBatis动态SQL里LIMIT 10直接生效。修复方式导出接口的Service方法单独提供不传任何分页参数用独立的一条查询SQL。同时在代码Review环节明确“导出接口禁止使用分页查询方法”。这类问题最好的解决方式其实不是排查而是建立规范——所有导出接口都走独立的查询方法不允许调用带分页的通用查询。7. 分页参数设计的几个小习惯最后分享几个我自己长期用下来的习惯刚入行的同事照着做基本能避开分页相关的绝大多数暗坑。第一参数名统一。项目里一旦出现pageNum、currentPage、pageIndex混用就离事故不远了。我一般统一用pageIndex和pageSize并且在API文档、前端请求封装、后端DTO三个层面保持一致。改一个名字的成本远低于排查一次数据错乱。第二日志里带上下文。分页相关的核心日志格式至少包含uid、pageIndex、pageSize、total、请求耗时这几个字段。线上排查问题时没有日志里的pageIndex你根本不知道用户当时翻到了哪一页。第三测试覆盖边界值。每个分页接口上线前至少用例覆盖第1页、最后一页、超出总页数1页、pageSize1、pageSize100、pageSize0或负数、不传pageIndex的默认值。不超过5分钟能跑完却能挡掉大量低级Bug。第四前端请求封装里把“页码重置”做成通用逻辑。我在项目里会在公共的请求层加一个mixins里面统一处理pageIndex重置、空数据占位、错误重试。这样业务开发就不需要每次写分页列表时都重复处理这些边界出错概率大大降低。提示凡是分页接口中涉及排序的排序字段必须是三选一主键、唯一索引、或者一个包含主键的复合索引字段。哪怕你允许用户按任意字段排序后端也要强制“第二排序键为主键”否则两个字段值相同时MySQL不保证分页顺序稳定翻页过程中就会出现同一条数据反复出现。PageIndex这词看上去简单但它背后牵涉的基数约定、边界处理、深分页性能、接口设计每一层都有不少值得讲究的地方。我自己刚入行时也是踩了一轮才把这些要点串起来。希望这篇把关键路径梳理清楚以后再做分页、调分页Bug时能少走几步弯路。