---
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 -->|稳定数据
管理后台| C[传统分页
LIMIT/OFFSET]
B -->|实时数据
无限滚动| D[游标分页
Cursor-based]
B -->|历史数据
时间范围| E[键集分页
Keyset]
C -.-> F[✅ 支持跳页
⚠️ 深度性能差]
D -.-> G[✅ 性能稳定
❌ 不支持跳页]
E -.-> H[✅ 最佳性能
⚠️ 需排序字段]
```
**选择指南**:
| 场景 | 推荐方案 | 理由 |
|-----|---------|------|
| 管理后台、商品列表 | 传统分页 | 需要跳页功能,数据量可控 |
| 社交媒体动态、无限滚动 | 游标分页 | 实时性强,只需向下加载 |
| 日志查看、历史订单 | 键集分页 | 大数据量,性能优先 |
| 搜索结果 | 混合方案 | 前10页传统 + 深度游标 |
## 前端分页器设计
### 核心接口设计
```typescript
interface PaginationRequest {
page: number; // 当前页码(从1开始)
page_size: number; // 每页大小
}
interface PaginationResponse {
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();
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[热门页
TTL: 10分钟]
F --> H[普通页
TTL: 5分钟]
F --> I[总数统计
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 设计最佳实践