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 设计最佳实践
|