游标分页是一种基于游标(通常是唯一标识或时间戳)的分页技术,相比传统的LIMIT offset, size分页有显著的性能优势。
传统分页的问题
-- 传统分页:性能随offset增大而下降
SELECT * FROM orders
ORDER BY created_time DESC, id DESC
LIMIT 100000, 20; -- 需要扫描100020行
游标分页的原理
基本思想
使用唯一的、有序的游标来标记分页位置,而不是使用页码。
实现方式
1. 基于自增主键的游标分页
- 第一页
SELECT * FROM orders
ORDER BY id DESC
LIMIT 20;
-- 第二页:使用上一页最后一条的id作为游标
SELECT * FROM orders
WHERE id < 上一页最后一条的id -- 游标条件
ORDER BY id DESC
LIMIT 20;
2. 基于时间戳的游标分页
-- 第一页
SELECT * FROM orders
ORDER BY created_time DESC, id DESC
LIMIT 20;
-- 第二页
SELECT * FROM orders
WHERE (created_time < 上一页最后时间)
OR (created_time = 上一页最后时间 AND id < 上一页最后ID)
ORDER BY created_time DESC, id DESC
LIMIT 20;
3. 复合游标分页(推荐)
-- 使用(时间戳, ID)作为复合游标
-- 第一页请求
SELECT id, content, created_time
FROM posts
ORDER BY created_time DESC, id DESC
LIMIT 10;
-- 第二页请求(客户端记住上一页最后一条的created_time和id)
SELECT id, content, created_time
FROM posts
WHERE (created_time < '2023-01-01 12:00:00')
OR (created_time = '2023-01-01 12:00:00' AND id < 1000)
ORDER BY created_time DESC, id DESC
LIMIT 10;
实际应用示例
API设计示例
// 第一页请求
GET /api/posts?limit=20
// 响应
{
"data": [...],
"next_cursor": "2023-01-01T10:00:00Z_1000", // 最后一条的游标
"has_more": true
}
// 第二页请求
GET /api/posts?limit=20&cursor=2023-01-01T10:00:00Z_1000
完整SQL实现
-- 创建适合游标分页的索引
CREATE INDEX idx_posts_cursor ON posts(created_time DESC, id DESC);
-- 分页查询函数
CREATE FUNCTION get_posts_by_cursor(
p_cursor_time TIMESTAMP DEFAULT NULL,
p_cursor_id BIGINT DEFAULT NULL,
p_limit INT DEFAULT 20
) RETURNS TABLE AS $$
BEGIN
IF p_cursor_time IS NULL THEN
-- 第一页
RETURN QUERY
SELECT * FROM posts
ORDER BY created_time DESC, id DESC
LIMIT p_limit;
ELSE
-- 后续分页
RETURN QUERY
SELECT * FROM posts
WHERE (created_time < p_cursor_time)
OR (created_time = p_cursor_time AND id < p_cursor_id)
ORDER BY created_time DESC, id DESC
LIMIT p_limit;
END IF;
END;
$$ LANGUAGE plpgsql;
游标分页的优势
性能对比
具体优势
恒定性能:无论翻到第几页,性能基本一致
无数据跳过:不需要
OFFSET,避免扫描和跳过大量数据实时性更好:适合频繁更新的数据流
无重复数据:避免传统分页在数据更新时出现的重复或丢失
适用场景
推荐使用游标分页的场景
✅ 社交媒体时间线(Twitter、微博)
✅ 消息列表、聊天记录
✅ 实时数据流(日志、监控数据)
✅ 无限滚动页面
传统分页仍适用的场景
✅ 数据基本不变的列表(商品分类、用户列表)
✅ 需要跳转到特定页码的场景
✅ 数据量不大且offset较小的情况
注意事项
1. 游标必须唯一且有序
-- 错误:只使用时间戳,可能重复
WHERE created_time < '2023-01-01'
-- 正确:使用(时间戳, ID)保证唯一性
WHERE (created_time < '2023-01-01')
OR (created_time = '2023-01-01' AND id < 1000)
2. 客户端实现
lass CursorPager {
constructor() {
this.currentCursor = null;
this.hasMore = true;
}
async loadNextPage() {
if (!this.hasMore) return;
const params = { limit: 20 };
if (this.currentCursor) {
params.cursor = this.currentCursor;
}
const response = await api.get('/items', { params });
this.currentCursor = response.next_cursor;
this.hasMore = response.has_more;
return response.data;
}
}
3. 边界情况处理
-- 处理游标不存在的情况
SELECT * FROM posts
WHERE (created_time < COALESCE(:cursor_time, NOW()))
AND (id < COALESCE(:cursor_id, 2^62)) -- 很大的数作为默认值
ORDER BY created_time DESC, id DESC
LIMIT :limit;
总结
游标分页是现代应用中处理大量数据分页的最佳实践,特别适合:
高性能要求的实时应用
大数据量的分页场景
需要良好用户体验的无限滚动
虽然实现比传统分页稍复杂,但带来的性能优势是巨大的。