从 page、page_size 到游标:深入解析C端产品的两种主流分页技术
在开发 C 端应用程序时,无论是社交媒体的信息流、电商的商品列表,还是新闻 App 的文章列表,只要涉及到大量数据的展示,"分页"就是一个不可或缺的功能。它不仅能显著提升页面加载速度,还能优化服务器和数据库的性能。
长久以来,page
(页码)和 page_size
(每页数量)的组合是我们最熟悉的分页方式。然而,随着"无限滚动"和实时数据流的兴起,还有一种叫做"游标分页"的设计。
本文将带你深入了解这两种分页方式的运作原理、优劣势,并结合 Java 实现代码 、性能对比 和 真实案例,为你介绍这两种技术选型。
一、传统分页:简单直观的 page
和 page_size
这是最经典的分页实现,也被称为"偏移量分页"。核心思想是通过指定要跳过的记录数(offset
)和要获取的记录数(limit
)来查询数据。
工作原理
客户端请求通常包含两个参数:
page
:当前请求的页码(例如:3)page_size
:每页显示的数量(例如:10)
服务器端在收到请求后,会将其转换为数据库查询中的 LIMIT
和 OFFSET
。
SQL 查询示例:
sql
-- 请求第一页
SELECT * FROM items ORDER BY created_at DESC LIMIT 10 OFFSET 0;
-- 请求第三页
SELECT * FROM items ORDER BY created_at DESC LIMIT 10 OFFSET 20;
Java 代码示例
java
@GetMapping("/items")
public PageResponseDTO<Item> list(@RequestParam int page, @RequestParam int pageSize) {
Pageable pageable = PageRequest.of(page - 1, pageSize, Sort.by("createdAt").descending());
Page<Item> result = itemRepository.findAll(pageable);
return new PageResponseDTO<>(
result.getContent(),
page,
pageSize,
result.getTotalElements(),
null, null,
result.hasNext()
);
}
优点
- 实现简单:逻辑直观,前后端都容易理解。
- 支持跳页:用户能直接跳转到指定页码,适合后台管理类系统。
缺点
- 深度分页性能差 :
OFFSET
会丢弃前面大量数据,1000 页以后性能急剧下降。 - 数据不一致:数据集频繁更新时,翻页容易出现重复或遗漏。
二、游标设计
游标分页放弃了"页码"的概念,而是用一个"游标"(Cursor)来标记当前位置。常用策略是基于唯一且有序的字段 (如 (created_at, id)
)来生成游标。
工作原理
- 初始请求 :客户端请求
/items?limit=10
。 - 服务端响应 :返回数据 +
next_cursor
。 - 后续请求 :客户端带上游标
/items?limit=10&cursor=xxxx
,服务端从游标位置继续取数据。
SQL 查询示例:
sql
-- 初始请求
SELECT * FROM items ORDER BY created_at DESC, id DESC LIMIT 10;
-- 假设最后一条记录 created_at='2025-09-05 10:00:00', id=1234
-- 下一页请求
SELECT * FROM items
WHERE (created_at < '2025-09-05 10:00:00'
OR (created_at = '2025-09-05 10:00:00' AND id < 1234))
ORDER BY created_at DESC, id DESC
LIMIT 10;
Java 实现(游标分页)
java
@GetMapping("/items/cursor")
public PageResponseDTO<Item> cursorPage(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "10") int limit) {
List<Item> items;
if (cursor == null) {
items = itemRepository.findTopNByOrderByCreatedAtDescIdDesc(limit);
} else {
CursorPayload cp = cursorCodec.decode(cursor)
.orElseThrow(() -> new IllegalArgumentException("Invalid cursor"));
items = itemRepository.seekNext(cp.getCreatedAt(), cp.getId(), limit);
}
String next = items.isEmpty() ? null :
cursorCodec.encode(new CursorPayload(
items.get(items.size() - 1).getCreatedAt(),
items.get(items.size() - 1).getId()));
return new PageResponseDTO<>(items, null, null, null, next, null, items.size() == limit);
}
注意这里的
cursorCodec
,负责将(createdAt, id)
编码为 Base64 字符串,对前端保持不透明。
优点
- 性能稳定:查询性能与页数无关。
- 数据一致性好:避免重复和遗漏。
- 天然适配无限滚动:非常适合信息流。
缺点
- 不能跳页:用户无法跳转到第 100 页。
- 难以统计总数 :一般只能单独提供
count
接口。 - 实现复杂度高:需要额外的游标编码、复合索引。
三、关键坑点与解决方案
-
时间戳重复导致丢数据
- 用
(created_at, id)
作为复合游标。
- 用
-
反向翻页(聊天记录向上加载)
- 提供
prevCursor
,SQL 使用>
条件,再反转结果。
- 提供
-
游标安全性
- Base64 + 签名(HMAC)防篡改。
-
是否还有更多数据
LIMIT = 请求条数 + 1
,如果结果超出则说明有更多。
四、性能对比(MySQL)
场景 | Offset 分页 | 游标分页 |
---|---|---|
第 1 页 | 很快 | 很快 |
第 100 页 | 需要丢弃前 999 条,SQL 变慢 | 与第一页几乎一致 |
数据插入 | 下一页数据错位 | 不影响 |
适合场景 | 后台表格、搜索结果 | 信息流、聊天、无限滚动 |
建议实际做
EXPLAIN
,偏移量分页深页通常会出现 Using filesort 或 扫描行数激增,而游标分页能保持稳定。