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
obsidian/金山办公作业/Week05/分页.md
T
2026-04-20 22:47:51 +08:00

15 KiB
Raw Blame History

tags, create time
tags create time
go
pagination
backend
frontend
api
database
assignment
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. 页码导航(上一页、下一页、直接跳页)
  2. 页码显示(智能压缩:"1 ... 5 6 7 ... 10")
  3. 每页大小切换(10/20/50/100条)
  4. 总数展示("共 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]);

注意事项:

  • 使用 sticky footer 显示加载状态
  • 返回顶部时考虑重新加载或保留状态
  • 批量追加数据避免频繁渲染

后端实现要点

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页的部分数据
  • 刷新后数据可能重复或遗漏

解决方案:

  1. 快照分页(Snowflake/Slack模式)

    type QueryID string  // 每次查询生成唯一ID
    cache.Store(queryKey, allData, 10m)  // 缓存完整结果集
    // 后续请求基于快照
    
  2. 游标 + 时间戳

    WHERE (created_at, id) <= (:last_time, :last_id)
    ORDER BY created_at DESC, id DESC
    

    使用复合游标保证顺序稳定

  3. 接受不一致性(社交媒体)

    • 少量重复/遗漏可接受
    • 优先性能而非严格一致性

分布式分页

问题:分库分表后如何分页?

方案对比:

方案 复杂度 性能 适用场景
全局聚合后分页 低 差(需扫描全量) < 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万条记录

答案:

  1. 限制深度:禁止查询超过1000页
  2. 混合方案:前N页OFFSET,深度页游标
  3. 游标分页:使用 WHERE id > last_id 替代 OFFSET
  4. 预计算映射:缓存页码到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

学习资源

关联笔记