611 lines
15 KiB
Markdown
611 lines
15 KiB
Markdown
---
|
||
tags: [go, pagination, backend, frontend, api, database, assignment]
|
||
create time: 2026-04-17 22:45
|
||
---
|
||
|
||
# 分页实现指南
|
||
|
||
## 概述
|
||
|
||
分页是 Web 应用中处理大规模数据的核心技术。本文深入探讨分页的各种实现策略、性能优化及最佳实践,帮助构建高效的用户体验。
|
||
|
||
## 分页架构全景
|
||
|
||
### 核心流程
|
||
|
||
```mermaid
|
||
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: 渲染数据 + 分页器
|
||
```
|
||
|
||
### 三种分页模式对比
|
||
|
||
```mermaid
|
||
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页传统 + 深度游标 |
|
||
|
||
## 前端分页器设计
|
||
|
||
### 核心接口设计
|
||
|
||
```typescript
|
||
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. 页码导航(上一页、下一页、直接跳页)
|
||
2. 页码显示(智能压缩:"1 ... 5 6 7 ... 10")
|
||
3. 每页大小切换(10/20/50/100条)
|
||
4. 总数展示("共 100 条,共 10 页")
|
||
|
||
```typescript
|
||
// 简化版展示核心逻辑
|
||
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 事件):
|
||
|
||
```typescript
|
||
// 核心逻辑:监听列表最后一项进入视口
|
||
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]);
|
||
```
|
||
|
||
**注意事项**:
|
||
- 使用 `sticky` footer 显示加载状态
|
||
- 返回顶部时考虑重新加载或保留状态
|
||
- 批量追加数据避免频繁渲染
|
||
|
||
## 后端实现要点
|
||
|
||
### Go 核心结构
|
||
|
||
```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"`
|
||
}
|
||
```
|
||
|
||
**关键逻辑**:
|
||
|
||
```go
|
||
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)
|
||
|
||
```sql
|
||
-- 基础查询
|
||
SELECT * FROM products ORDER BY id DESC LIMIT 10 OFFSET 0; -- 第1页
|
||
```
|
||
|
||
**深度分页问题**:
|
||
- OFFSET 10000 需要扫描前 10001 条记录
|
||
- 性能随页码线性下降:O(n)
|
||
|
||
**优化方案**:
|
||
- 限制最大页码(如 1000 页)
|
||
- 预计算页码到 ID 的映射
|
||
- 对热门数据使用缓存
|
||
|
||
#### 2. 游标分页(Cursor-based)
|
||
|
||
```sql
|
||
-- 使用 WHERE 替代 OFFSET
|
||
SELECT * FROM products
|
||
WHERE id < {last_id}
|
||
ORDER BY id DESC
|
||
LIMIT 10;
|
||
```
|
||
|
||
**优势**:
|
||
- 性能稳定,复杂度 O(1)
|
||
- 支持实时数据(新增数据不影响)
|
||
|
||
**实现要点**:
|
||
```go
|
||
type CursorResponse struct {
|
||
Data []Product `json:"data"`
|
||
Cursor string `json:"cursor"` // 最后一条的 ID
|
||
HasMore bool `json:"has_more"`
|
||
}
|
||
|
||
// 前端保存 cursor,下次请求携带
|
||
```
|
||
|
||
#### 3. 键集分页(Keyset Pagination)
|
||
|
||
适用于有明确排序字段(如时间戳):
|
||
|
||
```sql
|
||
-- 获取第N页(需要知道N-1页的最后值)
|
||
SELECT * FROM logs
|
||
WHERE created_at < '2026-04-17 10:00:00' -- 上一页最后的时间戳
|
||
ORDER BY created_at DESC
|
||
LIMIT 10;
|
||
```
|
||
|
||
**性能最优**,但实现复杂,需要:
|
||
- 前端保存每页的最后值作为"书签"
|
||
- 支持双向导航(需要第一页、最后页的边界值)
|
||
|
||
### 总数查询优化
|
||
|
||
传统方案需要两次查询(数据 + COUNT),优化策略:
|
||
|
||
```go
|
||
// 策略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 响应示例
|
||
|
||
### 标准分页响应格式
|
||
|
||
```json
|
||
{
|
||
"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
|
||
}
|
||
```
|
||
|
||
### 错误响应
|
||
|
||
```json
|
||
{
|
||
"error": "Invalid pagination parameters",
|
||
"message": "page must be greater than 0"
|
||
}
|
||
```
|
||
|
||
## 性能优化
|
||
|
||
### 数据库索引策略
|
||
|
||
```sql
|
||
-- 基础索引:按排序字段
|
||
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%+ 查询性能
|
||
|
||
### 缓存层次
|
||
|
||
```mermaid
|
||
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小时]
|
||
```
|
||
|
||
**实现要点**:
|
||
|
||
```go
|
||
// 分层缓存策略
|
||
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 偏移:
|
||
|
||
```go
|
||
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 | 数据稳定 |
|
||
|
||
**混合策略**:
|
||
|
||
```go
|
||
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页的部分数据
|
||
- 刷新后数据可能重复或遗漏
|
||
|
||
**解决方案**:
|
||
|
||
1. **快照分页**(Snowflake/Slack模式)
|
||
```go
|
||
type QueryID string // 每次查询生成唯一ID
|
||
cache.Store(queryKey, allData, 10m) // 缓存完整结果集
|
||
// 后续请求基于快照
|
||
```
|
||
|
||
2. **游标 + 时间戳**
|
||
```sql
|
||
WHERE (created_at, id) <= (:last_time, :last_id)
|
||
ORDER BY created_at DESC, id DESC
|
||
```
|
||
使用复合游标保证顺序稳定
|
||
|
||
3. **接受不一致性**(社交媒体)
|
||
- 少量重复/遗漏可接受
|
||
- 优先性能而非严格一致性
|
||
|
||
### 分布式分页
|
||
|
||
**问题**:分库分表后如何分页?
|
||
|
||
**方案对比**:
|
||
|
||
| 方案 | 复杂度 | 性能 | 适用场景 |
|
||
|-----|-------|------|----------|
|
||
| 全局聚合后分页 | 低 | 差(需扫描全量) | < 10万数据 |
|
||
| 按用户分片(user_id取模) | 中 | 好 | 私有数据 |
|
||
| 路由表(mapping表) | 高 | 较好 | 公共数据 |
|
||
| 基于ES的搜索分页 | 高 | 优秀 | 搜索功能 |
|
||
|
||
**ES方案示例**:
|
||
|
||
```json
|
||
GET /products/_search
|
||
{
|
||
"from": 0,
|
||
"size": 10,
|
||
"sort": [{"created_at": "desc"}],
|
||
"query": {"match_all": {}}
|
||
}
|
||
```
|
||
|
||
ES 使用 `search_after` 替代 `from/size` 进行深度分页:
|
||
|
||
```json
|
||
{
|
||
"size": 10,
|
||
"sort": [{"created_at": "desc"}, {"_id": "desc"}],
|
||
"search_after": ["2026-04-17", "last_id"]
|
||
}
|
||
```
|
||
|
||
### GraphQL 分页
|
||
|
||
使用 **Relay规范** 实现连接(Connection):
|
||
|
||
```graphql
|
||
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!
|
||
}
|
||
```
|
||
|
||
**客户端使用**:
|
||
|
||
```typescript
|
||
// 查询第一页
|
||
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 响应标准
|
||
|
||
```json
|
||
{
|
||
"data": [...],
|
||
"total": 1250,
|
||
"page": 2,
|
||
"page_size": 20,
|
||
"total_page": 63,
|
||
"has_more": true
|
||
}
|
||
```
|
||
|
||
**字段说明**:
|
||
- `has_more`:方便前端判断是否加载更多(适用于无限滚动)
|
||
|
||
## 常见问题解答
|
||
|
||
### Q: 深度分页性能如何优化?
|
||
|
||
**问题**:查询第10000页需要扫描100万条记录
|
||
|
||
**答案**:
|
||
1. **限制深度**:禁止查询超过1000页
|
||
2. **混合方案**:前N页OFFSET,深度页游标
|
||
3. **游标分页**:使用 `WHERE id > last_id` 替代 OFFSET
|
||
4. **预计算映射**:缓存页码到ID的映射关系
|
||
|
||
### Q: 数据变化时分页如何处理?
|
||
|
||
**场景**:用户在第5页,删除操作后页面变空
|
||
|
||
**答案**:
|
||
```typescript
|
||
// 删除后检查并跳转
|
||
if (currentData.length === 1 && currentPage > 1) {
|
||
loadData(currentPage - 1); // 跳到上一页
|
||
} else {
|
||
loadData(currentPage); // 重新加载当前页
|
||
}
|
||
```
|
||
|
||
### Q: 如何支持自定义排序?
|
||
|
||
**答案**:
|
||
```go
|
||
// 白名单验证防止SQL注入
|
||
allowedFields := []string{"name", "created_at", "price"}
|
||
|
||
sortQuery := "ORDER BY " + allowedField(req.SortBy) + " " +
|
||
(req.SortDesc ? "DESC" : "ASC")
|
||
```
|
||
|
||
### Q: 为什么有时候返回重复数据?
|
||
|
||
**原因**:
|
||
- 传统分页:数据插入/删除导致位移
|
||
- 游标分页:使用复合字段解决
|
||
|
||
```sql
|
||
-- 使用复合游标保证稳定
|
||
WHERE (created_at, id) <= (:last_time, :last_id)
|
||
ORDER BY created_at DESC, id DESC
|
||
```
|
||
|
||
## 学习资源
|
||
|
||
- **PostgreSQL文档**:[Pagination](https://www.postgresql.org/docs/current/queries-limit.html)
|
||
- **MySQL优化**:[Optimizing LIMIT Queries](https://dev.mysql.com/doc/refman/8.0/en/optimization-limit-optimization.html)
|
||
- **Relay规范**:[Cursor-based Pagination](https://relay.dev/graphql/connections.htm)
|
||
|
||
## 关联笔记
|
||
|
||
- [[金山办公作业/Week05/用户认证.md]] - 用户认证授权机制
|
||
- [[CS/DB/索引优化]] - 数据库索引设计与优化
|
||
- [[CS/NET/RESTful API]] - API 设计最佳实践
|