☰
分页核心PageIndex:从0-based约定到深分页的实战指南
2026/10/4 8:18:08 网站建设 项目流程

我在 .NET 项目里做分页做得多了,对 PageIndex 这个词几乎是条件反射——它是 GridView 的属性,从 0 开始计数,第二页的 PageIndex 是 1 而不是 2。就是这个容易被忽略的约定,让我在前后端联调、数据库翻页、前端组件封装里反复踩坑。加上后来接触了 MySQL、SQL Server、Vue、React 各种技术栈,我发现 PageIndex 其实是一个横跨整条链路的概念,每一层对它都有不同的默认解释,稍微不注意就出 off-by-one 的 bug。这篇文章把我这些年围绕 PageIndex 的实操经验、踩过的坑、以及最终沉淀下来的分页规范一次性写清楚,给正在写分页功能或者正被分页 bug 折磨的朋友一个参考。

1. PageIndex 这个词,我在三个地方都见过,含义完全不同

1.1 数据库层:页只是一个抽象概念

先从数据库说起。分页本质上是"把一批数据切成固定大小的片段,每次只取其中一段",这个"段"在业务上的编号就是 PageIndex。MySQL 里写LIMIT 20, 10,意思是"跳过 20 行,再取 10 行",其实就是在算(pageIndex - 1) * pageSize之后的偏移量;SQL Server 里OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY,同理。

很多人在这一层最容易犯的错,是直接把业务页码当成偏移量。比如要取第 2 页、每页 10 条,第一反应是写LIMIT 20, 10——这就是把页码当成 1-based 的直觉。可如果后端接口里的 pageIndex 规定是从 0 开始的,那第二页对应的偏移量其实还是 10,不是 20。我在好几次 code review 里见过这类问题:代码跑起来页面数据对不上,查到最后都是前后端各自维护了一套页码约定。

数据库层其实根本不关心你叫 PageIndex 还是 PageNumber,它只关心你要跳过多少行、取多少行。真正决定语义的是业务层和前端。这也是我这篇文章想强调的核心结论之一:一个系统里必须把 PageIndex 的语义钉死,否则每个接口都可能在边界上悄悄出问题。

1.2 控件层:ASP.NET GridView 把 PageIndex 焊在了 0 基址

如果你是 .NET 出身,对 PageIndex 的第一反应大概率是GridView.PageIndex。ASP.NET WebForms 的 GridView 自带分页,它有一个 PageIndex 属性表示当前页索引,而且明确从 0 开始:第一页 PageIndex = 0,第二页 = 1。

这个设计有历史原因。GridView 的前身 DataGrid 在 .NET 1.0 时代就存在了,底层用集合做数据绑定时,索引天然从 0 开始,PageIndex 也顺理成章地用了 0-based。后来的 ListView、DataPager 以及很多 Web API 的分页封装,都延续了这个习惯。

但麻烦在于:界面上的页码按钮、用户眼里的"第几页",都是 1-based 的。于是每个用 GridView 的人都要在PageIndexChanging事件里做一次 +1/-1 的转换。我印象里踩过最折腾的一个坑是:列表排序之后再翻页,事件里拿到的e.NewPageIndex和当前数据源的顺序对不上,排查了半天才发现根本不是 PageIndex 的问题,而是 ViewState 里缓存的数据源在排序后没有重新绑定。这种问题在 WebForms 时代特别磨人,但它让我养成一个习惯:拿到 PageIndex 先确认它的基准,再动逻辑。

1.3 前端组件层:PageIndex 是状态,不是数据

到了前端,分页组件就更多样了。Element UI 的 Pagination 用currentPage,Ant Design 的 Pagination 也用current,另一个组件可能叫pageIndex。如果你自己封装,命名更是随心情。

我的经验是:前端组件里的"当前页"本质上是一个 UI 状态,它和数据库的偏移量之间差着一个转换公式。只要页面状态里存的是 1-based 页码,请求接口前就必须把页码换算成接口要求的 PageIndex。很多 bug 的根源就是:组件里存的current = 2,接口文档写pageIndex = 1(0-based),程序员忘记转换,结果第二页显示成了第一页的数据,或者反过来。

所以我在设计自己的前端分页组件时,第一件事就是规定:组件内部状态用currentPage(1-based),对外发送请求统一用pageIndex(0-based),转换只在一个函数里做,绝不在业务代码里到处散落 +1/-1。

2. 从 PageIndex 到 SQL:我对比过的三种分页写法

2.1 最常用的 OFFSET 分页,五分钟就能写完

最常见的分页 SQL 有两种方言。SQL Server 是:

SELECT * FROM dbo.Orders ORDER BY OrderId OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;

MySQL 是:

SELECT * FROM Orders ORDER BY OrderId LIMIT @PageSize OFFSET @Offset;

其中@Offset = pageIndex * pageSize。这种写法逻辑直观、实现简单,适合绝大多数中小规模系统。我自己的原则是:在拿不出性能证据说明它不行之前,先用 OFFSET 分页,别提前优化。为分页方案引入复杂度,应该是被数据量和产品形态逼出来的,而不是为了技术上的炫技。

2.2 深分页为什么会把数据库拖垮

OFFSET 分页的代价在于:数据库为了拿到"跳过 N 行"之后的结果,必须先把前 N 行都读出来然后丢掉。你翻到第 1000 页,它就得白读 20000 行。随着页码变大,这个成本线性增长,索引再快也救不了——因为 OFFSET 本质上没有用索引直接定位到偏移位置,它只是"从头数着走"。

我实测过一次:一张五百多万行的订单表,主键是自增 OrderId,每页 20 条。翻到第 100 页左右,查询耗时还在几十毫秒;翻到第 5000 页时,单次查询已经涨到两秒多。这个"越往后越慢"的体验,用户体感非常明显。如果产品里还有"跳转到第 N 页"的输入框,OFFSET 就成了一个定时炸弹。

还有一个容易被忽略的问题:OFFSET 分页在结果集有并发插入或删除时,会出现"重复"或"丢失"数据。因为每次查询都是一个独立动作,上一页和下一页之间的数据如果变了,页码就错位了。这在后台管理系统里通常无所谓,但在交易流水、日志、动态 feed 这种场景里就不可接受。

2.3 游标分页(keyset)才是大表的解药

keyset 分页的思路是:不用 OFFSET,而是用 WHERE 条件直接定位到"上一页最后一条记录"的位置,再往下取。SQL 大概长这样:

SELECT * FROM Orders WHERE OrderId > @LastSeenId ORDER BY OrderId LIMIT @PageSize;

因为WHERE OrderId > 10000可以直接走主键索引,数据库不需要先扫描前面的一万行,所以无论翻到多深,耗时基本恒定。这种写法最适合"从首页一直往下刷"的列表场景,比如动态 feed、消息记录、操作日志。

代价也很明显:你失去了"跳转到任意页码"的能力。keyset 要求每次请求都带着上一页最后一条记录的标识,这在无限滚动、加载更多这类交互里天然契合,但在需要"跳到第 88 页"的传统分页导航里就不适用了。

我当时的一个取舍是:面向用户的列表如果保留传统页码导航,就用 OFFSET;如果是移动端"加载更多"或者后台日志流,就换 keyset。分页方案不是越高级越好,而是要跟产品交互形态匹配。下面这张表是我经常拿来跟同事对方案的:

方案典型 SQL翻页深度性能支持跳页推荐场景
OFFSET/FETCHSQL Server / PostgreSQL浅分页尚可,深分页线性变慢支持后台表格、管理页面
LIMIT/OFFSETMySQL同上支持中小规模列表
keysetWHERE id > 游标基本恒定不支持无限滚动、feed、日志流

3. 我踩过的 PageIndex 相关的坑,每一个都真实发生过

3.1 0-based 和 1-based 混用导致的第一页空白

这个坑在前后端联调时最常见。前端用 Element UI 的 Pagination,currentPage默认 1,点击第二页后组件把currentPage = 2发给了后端。后端接口写的是pageIndex = 0第一页,于是"第二页"请求实际取的是"第三页"的数据。

还有一种更隐蔽的版本:后端用了 EF Core 的Skip((pageIndex - 1) * pageSize),而前端发的 pageIndex 已经是 0-based。第一页请求pageIndex = 0,后端套公式算出-10,Skip 一个负数直接抛异常,页面空白。这类问题光靠看代码很难定位,因为两边代码单独看都"没错"。

我的规避手段很简单:接口层统一规定 pageIndex 是 0-based、pageSize 最小 1 最大 100,进入业务逻辑前强制做一次规整。同时在网关或请求日志里记录实际收到的 pageIndex 和返回的 total,联调时对不上就查日志,基本不用猜。

3.2 直接把页码拼进 SQL 的惨痛教训

分页参数是天然的注入入口,因为在很多老系统里,LIMIT 和 OFFSET 后面的数字会被偷懒直接拼进字符串。我接手过一个遗留项目,里面就躺着这种代码:

var sql = $"SELECT * FROM Orders ORDER BY OrderId " + $"OFFSET {pageIndex * pageSize} ROWS " + $"FETCH NEXT {pageSize} ROWS ONLY";

这条 SQL 一旦执行,pageIndex 是可以被任意改写的。正确做法是全部参数化,哪怕你以为"它只是整数"。EF Core 和 Dapper 都支持参数化分页,完全没有必要拼字符串。把整数参数化不只是安全考虑,还能让 SQL Server 生成更可复用的执行计划,对性能也有正面帮助。这算是分页里最不该省的一步。

3.3 删除数据之后,页码错位和总数不准

分页列表通常伴一个"总数"的显示。我见过不少系统为了省性能,总数直接用COUNT(*)查,结果在删除频繁的表上,总数和列表实际行数对不上。更麻烦的是:用户正停在第 20 页,后台删掉了一批数据,他再次翻页时,OFFSET 分页会跳过或重复显示部分行。

这属于 OFFSET 分页的固有缺陷,但可以缓解。我的做法是三条:第一,列表查询和总数查询尽量放在同一个事务快照里,保证一致性;第二,在接口响应里返回hasNext,让前端知道后面还有没有数据,避免在空页上做无意义的跳转;第三,对"删除导致当前页为空"做兜底——当前页没有数据且 pageIndex 大于 0 时,自动回退到最后一页而不是展示空白。最后这条看起来很小,但在用户眼里就是"系统崩了"和"系统正常"的区别。

4. 我沉淀下来的一套分页接口规范

4.1 参数命名和边界行为的约定

先给结论,这是我最终定下来的规范,供你直接参考:

  • 查询参数统一叫pageIndex、pageSize,pageIndex从 0 开始,pageSize默认 20、上限 100。
  • 响应结构统一是{ items, pageIndex, pageSize, total },items就是数据数组。
  • pageIndex超过最大页时,返回空items,不返回 400,也不抛异常。
  • pageIndex小于 0 时按 0 处理;pageSize小于 1 或大于 100 时按默认值处理。

关键参数的行为都可以先定死在文档里:

参数含义默认值边界行为
pageIndex页码,0 起0小于 0 按 0 算;超过最大页返回空列表
pageSize每页条数20小于 1 或大于 100 按默认值算
total总条数由后端统计与过滤条件一致
items当前页数据空数组长度不超过 pageSize

这套约定在内部多个项目里用了好几年,最大的好处就是降低了前后端的沟通成本。人人都知道 pageIndex 从 0 开始,就不会再出现"你这个 2 到底是第二页还是第三页"的争论。

4.2 后端实现示例

以 ASP.NET Core 为例,控制器里大致是这样:

[HttpGet("orders")] public async Task<IActionResult> GetOrders( [FromQuery] int pageIndex = 0, [FromQuery] int pageSize = 20, CancellationToken ct) { pageIndex = Math.Max(0, pageIndex); pageSize = Math.Clamp(pageSize, 1, 100); var query = _db.Orders.OrderBy(o => o.OrderId); var total = await query.CountAsync(ct); var items = await query .Skip(pageIndex * pageSize) .Take(pageSize) .ToListAsync(ct); return Ok(new { items, pageIndex, pageSize, total }); }

把参数规整放在最前面,后面所有查询逻辑都不用再担心 pageIndex 出现非法值。注意 Count 和列表两条查询,我复用的是同一个IQueryable,确保排序和过滤条件完全一致,不然总数跟列表对不上,排查起来又得折腾半天。如果表特别大,Count 也可以考虑用近似统计或缓存,但那是另一个话题,别在项目初期就给自己加复杂度。

4.3 前端组件配合示例

前端我在 Vue 里通常封装一个分页请求函数,把转换逻辑收敛到一处:

function buildPagedQuery(params) { const currentPage = params.currentPage ?? 1; // 1-based const pageSize = params.pageSize ?? 20; return { pageIndex: currentPage - 1, // 转换成 0-based pageSize, }; }

然后在翻页回调里这样用:

async function onPageChange(currentPage) { const data = await fetchOrders(buildPagedQuery({ currentPage })); tableData.value = data.items; total.value = data.total; }

唯一要记住的点是:currentPage 和 pageIndex 的转换只在这里发生一次。不要在组件内部、路由参数、后端逻辑里各转一遍,转了就是 bug 的温床。我见过最离谱的写法是,前端传currentPage - 1,后端又套一层(pageIndex - 1),合在一起第一页直接变成 -2,整个列表渲染不出来。

5. 什么时候该换 keyset:我的迁移判断与实践

5.1 keyset 的完整写法与示例

如果你终于决定把某些列表换成 keyset,也有规范的写法。还是以上面的订单表为例,后端接口需要多接收一个游标参数lastOrderId:

[HttpGet("orders")] public async Task<IActionResult> GetOrders( [FromQuery] long? lastOrderId = null, [FromQuery] int pageSize = 20, CancellationToken ct = default) { pageSize = Math.Clamp(pageSize, 1, 100); var query = _db.Orders.AsQueryable(); if (lastOrderId.HasValue) { query = query.Where(o => o.OrderId > lastOrderId.Value); } var items = await query .OrderBy(o => o.OrderId) .Take(pageSize) .ToListAsync(ct); var hasNext = items.Count == pageSize; var nextCursor = items.LastOrDefault()?.OrderId; return Ok(new { items, nextCursor, hasNext }); }

注意几个细节:游标必须是排序键本身,这里按 OrderId 排序,游标就是 OrderId。但如果排序字段是CreateTime这种可能重复的列,WHERE 条件就得加上一个唯一键做 tie-breaker,写成类似(CreateTime > @lastCreateTime) OR (CreateTime = @lastCreateTime AND OrderId > @lastOrderId),否则翻页会丢数据。这个坑是我在实际迁移时亲测踩过的,当时数据量一大,重复时间戳导致的丢行非常隐蔽,一时半会根本看不出来。

5.2 迁移判断标准和我个人的取舍心得

我的迁移判断标准是三条:

  • 单表数据量是否到了深分页影响体验的程度?我一般以百万行以上、或者翻页深度超过 100 页为参考线。
  • 产品是否真的需要"跳转到任意页码"?如果不需要,keyset 几乎总是更好的选择。
  • 团队有没有精力维护两套分页?如果只有一两个人维护,我倾向于只保留最必要的一套,避免逻辑分叉。

实际上在我的项目里,最终是"混合策略":面向运营后台的表格继续用 OFFSET,因为要跳页、要排序、要导出;面向用户的动态流和日志查询改成 keyset。两套接口在命名上区分开,参数里声明分页模式,避免前端用混。> 提示:如果你是在做个人项目,我的建议是直接用 keyset + 无限滚动,省掉一半分页的坑,你不用为"跳转到第 N 页"这种低频需求付出深分页的性能代价。

说回 PageIndex 这个名字本身。它看起来只是一个属性名,但牵涉到 0-based / 1-based 的约定、数据库偏移量计算、接口边界行为、深分页性能一整套问题。我这些年最大的体会是:把分页当成一个系统级约定来设计,而不是每个页面各写各的。哪怕项目再小,也值得花十分钟把 pageIndex 的语义定下来,后面能省下大量联调时间。最后一个技巧是:不管用哪种方案,一定要在接口层的入口把参数规整掉,别让非法 pageIndex 流向数据库——这一条做到了,你就能躲开我在文章里讲的一半坑。

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

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

立即咨询