15 KiB
15 KiB
tags, create time
| tags | create time | |||||||
|---|---|---|---|---|---|---|---|---|
|
2026-04-17 22:45 |
分页实现指南
概述
分页是 Web 应用中处理大规模数据的核心技术。本文深入探讨分页的各种实现策略、性能优化及最佳实践,帮助构建高效的用户体验。
分页架构全景
核心流程
sequenceDiagram
participant C as Client
participant F as Frontend
participant S as Backend
participant D as Database
participant Cache as Redis
C->>F: 访问列表页(第1页)
F->>S: GET /api/list?page=1&page_size=10
alt 缓存命中
S->>Cache: get(pagination:1:10)
Cache-->>S: cached data
else 缓存未命中
S->>D: SELECT ... LIMIT 10 OFFSET 0
S->>D: SELECT COUNT(*)
D-->>S: data + total
S->>Cache: set(pagination:1:10, ttl:5m)
end
S-->>F: {data, total, page, page_size, total_page}
F->>F: 计算分页信息
F-->>C: 渲染数据 + 分页器
三种分页模式对比
graph LR
A[分页需求] --> B{数据特征}
B -->|稳定数据<br>管理后台| C[传统分页<br>LIMIT/OFFSET]
B -->|实时数据<br>无限滚动| D[游标分页<br>Cursor-based]
B -->|历史数据<br>时间范围| E[键集分页<br>Keyset]
C -.-> F[✅ 支持跳页<br>⚠️ 深度性能差]
D -.-> G[✅ 性能稳定<br>❌ 不支持跳页]
E -.-> H[✅ 最佳性能<br>⚠️ 需排序字段]
选择指南:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 管理后台、商品列表 | 传统分页 | 需要跳页功能,数据量可控 |
| 社交媒体动态、无限滚动 | 游标分页 | 实时性强,只需向下加载 |
| 日志查看、历史订单 | 键集分页 | 大数据量,性能优先 |
| 搜索结果 | 混合方案 | 前10页传统 + 深度游标 |
前端分页器设计
核心接口设计
interface PaginationRequest {
page: number; // 当前页码(从1开始)
page_size: number; // 每页大小
}
interface PaginationResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_page: number;
}
关键点:
page从 1 开始(用户直觉友好)- 响应包含
total用于前端计算总页数 - 支持泛型
T复用于不同数据类型
分页器组件要点
传统分页器的核心功能:
- 页码导航(上一页、下一页、直接跳页)
- 页码显示(智能压缩:"1 ... 5 6 7 ... 10")
- 每页大小切换(10/20/50/100条)
- 总数展示("共 100 条,共 10 页")
// 简化版展示核心逻辑
const totalPages = Math.ceil(total / pageSize);
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
if (startPage > 1) showEllipsis = true;
if (endPage < totalPages) showEndEllipsis = true;
进阶技巧:
- 页码压缩:页数 > 7 时显示省略号
- 防抖处理:快速点击时只执行最后一次请求
- 地址栏同步:URL 参数
?page=2, 支持前进/后退 - 骨架屏:加载时显示占位符,提升感知性能
无限滚动实现
使用 Intersection Observer API(性能优于 scroll 事件):
// 核心逻辑:监听列表最后一项进入视口
const lastItemRef = useRef<HTMLDivElement>();
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasMore && !loading) {
loadMore(); // 自动加载下一页
}
}, { threshold: 0.1 });
if (lastItemRef.current) observer.observe(lastItemRef.current);
return () => observer.disconnect();
}, [hasMore, loading]);
注意事项:
- 使用
stickyfooter 显示加载状态 - 返回顶部时考虑重新加载或保留状态
- 批量追加数据避免频繁渲染
后端实现要点
Go 核心结构
// 分页请求
type PaginationRequest struct {
Page int `form:"page"` // 默认 1
PageSize int `form:"page_size"` // 默认 10,最大 100
}
// 分页响应
type PaginationResponse struct {
Data []interface{} `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPage int `json:"total_page"`
}
关键逻辑:
func GetPagination(r *http.Request) PaginationRequest {
p := PaginationRequest{Page: 1, PageSize: 10}
// 参数解析 + 验证
if page := r.URL.Query().Get("page"); page != "" {
if n, err := strconv.Atoi(page); err == nil && n > 0 {
p.Page = n
}
}
if size := r.URL.Query().Get("page_size"); size != "" {
if n, err := strconv.Atoi(size); err == nil && n > 0 {
p.PageSize = min(n, 100) // 限制最大值
}
}
return p
}
func ListHandler(w http.ResponseWriter, r *http.Request) {
p := GetPagination(r)
offset := (p.Page - 1) * p.PageSize
// 查询数据
items, _ := db.Query("SELECT ... LIMIT ? OFFSET ?", p.PageSize, offset)
total, _ := db.QueryInt("SELECT COUNT(*)")
resp := PaginationResponse{
Data: items,
Total: total,
Page: p.Page,
PageSize: p.PageSize,
TotalPage: (int(total) + p.PageSize - 1) / p.PageSize,
}
json.NewEncoder(w).Encode(resp)
}
三种分页查询策略
1. 传统分页(LIMIT/OFFSET)
-- 基础查询
SELECT * FROM products ORDER BY id DESC LIMIT 10 OFFSET 0; -- 第1页
深度分页问题:
- OFFSET 10000 需要扫描前 10001 条记录
- 性能随页码线性下降:O(n)
优化方案:
- 限制最大页码(如 1000 页)
- 预计算页码到 ID 的映射
- 对热门数据使用缓存
2. 游标分页(Cursor-based)
-- 使用 WHERE 替代 OFFSET
SELECT * FROM products
WHERE id < {last_id}
ORDER BY id DESC
LIMIT 10;
优势:
- 性能稳定,复杂度 O(1)
- 支持实时数据(新增数据不影响)
实现要点:
type CursorResponse struct {
Data []Product `json:"data"`
Cursor string `json:"cursor"` // 最后一条的 ID
HasMore bool `json:"has_more"`
}
// 前端保存 cursor,下次请求携带
3. 键集分页(Keyset Pagination)
适用于有明确排序字段(如时间戳):
-- 获取第N页(需要知道N-1页的最后值)
SELECT * FROM logs
WHERE created_at < '2026-04-17 10:00:00' -- 上一页最后的时间戳
ORDER BY created_at DESC
LIMIT 10;
性能最优,但实现复杂,需要:
- 前端保存每页的最后值作为"书签"
- 支持双向导航(需要第一页、最后页的边界值)
总数查询优化
传统方案需要两次查询(数据 + COUNT),优化策略:
// 策略1:缓存总数(适合数据变化不频繁)
total := cache.Get("total_products")
// 策略2:近似总数(如每天更新一次)
if time.Since(lastUpdate) > 24*time.Hour {
total = db.QueryInt("SELECT COUNT(*)")
}
// 策略3:渐进式加载(不显示总数,只显示"更多")
hasMore := len(items) == pageSize
API 响应示例
标准分页响应格式
{
"data": [
{
"id": 1,
"name": "Item 1",
"created_at": "2026-04-17T10:00:00Z"
},
{
"id": 2,
"name": "Item 2",
"created_at": "2026-04-17T09:00:00Z"
}
],
"total": 100,
"page": 1,
"page_size": 10,
"total_page": 10
}
错误响应
{
"error": "Invalid pagination parameters",
"message": "page must be greater than 0"
}
性能优化
数据库索引策略
-- 基础索引:按排序字段
CREATE INDEX idx_products_created ON products(created_at DESC);
-- 复合索引:过滤条件 + 排序
CREATE INDEX idx_products_status_created ON products(status, created_at DESC);
-- 覆盖索引:避免回表
CREATE INDEX idx_products_covering ON products(status, name, price);
-- 查询可以直接从索引获取,无需访问表数据
SELECT name, price FROM products WHERE status = 1 ORDER BY created_at;
索引设计原则:
- WHERE 条件字段 → 等值匹配优先级更高
- ORDER BY 字段 → 排序方向(ASC/DESC)
- 覆盖索引 → 避免回表,提升 50%+ 查询性能
缓存层次
graph TB
A[请求] --> B{缓存检查}
B -->|Hit| C[返回缓存数据]
B -->|Miss| D[数据库查询]
D --> E[写入缓存]
E --> C
F[缓存策略] --> G[热门页<br>TTL: 10分钟]
F --> H[普通页<br>TTL: 5分钟]
F --> I[总数统计<br>TTL: 1小时]
实现要点:
// 分层缓存策略
type CacheConfig struct {
HotPages map[int]time.Duration // 热门页长期缓存
Normal time.Duration // 普通页短期缓存
Total time.Duration // 总数统计超长期缓存
}
// 缓存键设计
key := fmt.Sprintf("list:%d:%d", page, pageSize)
totalCountKey := "list:total"
并为了防止缓存雪崩,添加随机 TTL 偏移:
ttl := baseTTL + time.Duration(rand.Intn(60))*time.Second
深度分页优化方案
问题场景:100万条数据查询第10页
| 方案 | 查询时间 | 适用场景 |
|---|---|---|
| LIMIT 10 OFFSET 100 | ~50ms | < 1000页 |
| WHERE id > last_id LIMIT 10 | ~5ms | > 1000页 |
| 预计算页码映射 | ~1ms | 数据稳定 |
混合策略:
func QueryData(page, pageSize int) {
if page < 100 {
// 前页用 OFFSET
db.Query("SELECT ... LIMIT ? OFFSET ?", pageSize, (page-1)*pageSize)
} else {
// 深度页用游标(需要第99页的最后ID)
lastID := getPageLastID(99)
db.Query("SELECT ... WHERE id > ? LIMIT ?", lastID, pageSize * (page-99))
}
}
前进话题
实时分页挑战
问题场景:
- 用户在第5页浏览
- 其他用户删除了第5页的部分数据
- 刷新后数据可能重复或遗漏
解决方案:
-
快照分页(Snowflake/Slack模式)
type QueryID string // 每次查询生成唯一ID cache.Store(queryKey, allData, 10m) // 缓存完整结果集 // 后续请求基于快照 -
游标 + 时间戳
WHERE (created_at, id) <= (:last_time, :last_id) ORDER BY created_at DESC, id DESC使用复合游标保证顺序稳定
-
接受不一致性(社交媒体)
- 少量重复/遗漏可接受
- 优先性能而非严格一致性
分布式分页
问题:分库分表后如何分页?
方案对比:
| 方案 | 复杂度 | 性能 | 适用场景 |
|---|---|---|---|
| 全局聚合后分页 | 低 | 差(需扫描全量) | < 10万数据 |
| 按用户分片(user_id取模) | 中 | 好 | 私有数据 |
| 路由表(mapping表) | 高 | 较好 | 公共数据 |
| 基于ES的搜索分页 | 高 | 优秀 | 搜索功能 |
ES方案示例:
GET /products/_search
{
"from": 0,
"size": 10,
"sort": [{"created_at": "desc"}],
"query": {"match_all": {}}
}
ES 使用 search_after 替代 from/size 进行深度分页:
{
"size": 10,
"sort": [{"created_at": "desc"}, {"_id": "desc"}],
"search_after": ["2026-04-17", "last_id"]
}
GraphQL 分页
使用 Relay规范 实现连接(Connection):
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type ProductEdge {
node: Product!
cursor: String!
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type Query {
products(first: Int, after: String): ProductConnection!
}
客户端使用:
// 查询第一页
query {
products(first: 10) {
edges { node { name } cursor }
pageInfo { hasNextPage, endCursor }
}
}
// 加载更多
query {
products(first: 10, after: "cursor-from-first-page") {
// ...
}
}
最佳实践总结
前端
✅ 推荐
- 使用
Intersection Observer实现无限滚动 - URL 同步分页参数(支持前进后退)
- 显示"加载中"骨架屏提升感知性能
- 合理的防抖/节流策略
- 分页器智能压缩(显示省略号)
❌ 避免
- 首次加载请求所有数据(前端分页)
- scroll 事件高频触发(用 Observer 替代)
- 前端计算总数(需要后端提供)
- 不处理空数据/单页边界
后端
✅ 推荐
- 统一参数命名(
page/page_size) - 参数验证与默认值(page ≥ 1, page_size ≤ 100)
- 使用索引优化查询(重点优化排序字段)
- 多层缓存策略
- 返回总数和总页数
- 深度分页使用游标优化
❌ 避免
- 不限制最大查询深度(导致性能问题)
- 不返回总数(前端无法展示)
- 每次查询 COUNT(可用缓存或近似值)
- SQL 注入风险(排序字段白名单验证)
API 响应标准
{
"data": [...],
"total": 1250,
"page": 2,
"page_size": 20,
"total_page": 63,
"has_more": true
}
字段说明:
has_more:方便前端判断是否加载更多(适用于无限滚动)
常见问题解答
Q: 深度分页性能如何优化?
问题:查询第10000页需要扫描100万条记录
答案:
- 限制深度:禁止查询超过1000页
- 混合方案:前N页OFFSET,深度页游标
- 游标分页:使用
WHERE id > last_id替代 OFFSET - 预计算映射:缓存页码到ID的映射关系
Q: 数据变化时分页如何处理?
场景:用户在第5页,删除操作后页面变空
答案:
// 删除后检查并跳转
if (currentData.length === 1 && currentPage > 1) {
loadData(currentPage - 1); // 跳到上一页
} else {
loadData(currentPage); // 重新加载当前页
}
Q: 如何支持自定义排序?
答案:
// 白名单验证防止SQL注入
allowedFields := []string{"name", "created_at", "price"}
sortQuery := "ORDER BY " + allowedField(req.SortBy) + " " +
(req.SortDesc ? "DESC" : "ASC")
Q: 为什么有时候返回重复数据?
原因:
- 传统分页:数据插入/删除导致位移
- 游标分页:使用复合字段解决
-- 使用复合游标保证稳定
WHERE (created_at, id) <= (:last_time, :last_id)
ORDER BY created_at DESC, id DESC
学习资源
- PostgreSQL文档:Pagination
- MySQL优化:Optimizing LIMIT Queries
- Relay规范:Cursor-based Pagination
关联笔记
- 金山办公作业/Week05/用户认证.md - 用户认证授权机制
- CS/DB/索引优化 - 数据库索引设计与优化
- CS/NET/RESTful API - API 设计最佳实践