This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
2026-04-20 22:47:51 +08:00

611 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 设计最佳实践