This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/GORM/06-排序与分页.md
T
2026-04-28 20:56:51 +08:00

9.6 KiB
Raw Blame History

tags, create time
tags create time
GORM
Go
ORM
排序
分页
Order
Limit
Offset
Paginate
2026-04-28 00:00

排序与分页

概述

排序和分页是面向用户的数据展示层的核心技能。不管后端查出多少数据,最终呈现在页面上的总是「一页」——理解 GORM 如何高效地完成这个任务,直接影响 API 的响应时间和用户体验。

Order — 排序

基本用法

// 单字段升序
db.Order("age ASC").Find(&users)
// SELECT * FROM users ORDER BY age ASC;

// 多字段排序(先按年龄降序,年龄相同则按创建时间升序)
db.Order("age DESC").Order("created_at ASC").Find(&users)
// SELECT * FROM users ORDER BY age DESC, created_at ASC;

[!tip] 链式 Order 的行为 多次调用 Order 会追加排序条件,而不是覆盖。所以上面的写法等价于:

db.Order("age DESC, created_at ASC").Find(&users)

动态排序

当排序字段来自前端参数时,需要小心处理 SQL 注入:

// ❌ 危险 —— 用户可传入 "age; DROP TABLE users;"
orderBy := "age" // 来自 query params
db.Order(orderBy + " DESC").Find(&users)

// ✅ 白名单校验
allowedOrders := map[string]bool{
    "age":          true,
    "created_at":   true,
    "name":         true,
    "amount":       true,
}
field := "created_at" // 来自前端
if !allowedOrders[field] {
    field = "created_at" // 默认值
}
db.Order(field + " DESC").Find(&users)

[!important] 为什么不能用占位符传列名? GORM 的 ? 只支持值参数化,不支持标识符(表名、列名、ORDER BY 方向)。这是所有 SQL 驱动的限制——SQL 预编译机制只对参数有效,对结构部分无效。因此必须通过白名单或其他方式确保列名的安全性。

随机排序

// MySQL
db.Order("RAND()").Find(&users)

// PostgreSQL
db.Order("RANDOM()").Find(&users)

// SQLite
db.Order("RANDOM()").Find(&users)

[!warning] 随机排序的性能陷阱 ORDER BY RAND() 会对整张表生成随机数再排序——O(n log n) 复杂度,百万级数据几乎不可用。如果只需要几条随机记录,改用更高效的方案:

// 方案一:OFFSET random
var count int
db.Model(&User{}).Count(&count)
offset := rand.Intn(count)
db.Limit(10).Offset(offset).Find(&users)

// 方案二:先在 Go 中随机选几个 ID,再查
ids := randomIDs(count, 10)
db.Where("id IN ?", ids).Find(&users)

Limit / Offset — 分页基础

基本原理

// 每页 10 条,第 2 页(offset = (page-1) * limit)
perPage := 10
page := 2
offset := (page - 1) * perPage

var products []Product
db.Limit(perPage).Offset(offset).Order("id").Find(&products)
// SELECT * FROM products ORDER BY id LIMIT 10 OFFSET 10;

[!example] 偏移量计算速查

页码 每页数 Offset 计算 结果
第 1 页 10 (1-1) × 10 = 0 前 10 条
第 2 页 10 (2-1) × 10 = 10 第 11-20 条
第 5 页 10 (5-1) × 10 = 40 第 41-50 条

查询总行数

var total int64
db.Model(&Product{}).Where("status = ?", "active").Count(&total)

perPage := 10
page := 2
offset := (page - 1) * perPage

var products []Product
db.Model(&Product{}).
    Where("status = ?", "active").
    Order("id").
    Limit(perPage).
    Offset(offset).
    Find(&products)

// 总页数
totalPages := int(math.Ceil(float64(total) / float64(perPage)))

[!question] 思考题 如果 offset 极大(比如第 10000 页),查询会变得很慢,为什么?有没有更好的方案?

答案:因为数据库仍然需要扫描并跳过前面大量的行,即使它们不会被返回。更好的方案是用 游标分页(Keyset Pagination)——按上一次最后一条记录的 id 来查,详见下面的游标分页章节。

游标分页(Keyset Pagination)

传统的 LIMIT/OFFSET 分页在深页性能急剧下降。游标分页通过「记住上一页最后一条记录的位置」来实现 O(log n) 的跳转:

type User struct {
    ID         uint
    Name       string
    Age        int
    CreatedAt  time.Time
}

// 第一页:没有 cursor,直接取前 N 条
cursor := uint(0) // 用主键类型更严谨
perPage := 20

var users []User
q := db.Model(&User{}).Order("id ASC").Limit(perPage + 1) // 多取 1 条判断是否有下一页

if cursor != 0 {
    // 查找 id > cursor 的记录
    q = q.Where("id > ?", cursor)
}

err := q.Find(&users).Error
hasNextPage := false

if len(users) > perPage {
    hasNextPage = true
    users = users[:perPage]                    // 去掉多余的「探测」记录
    cursor = users[len(users)-1].ID            // 提取新的 cursor
}

[!tip] 游标分页的优点

  • 速度恒定:无论翻到哪页,都是查紧邻的下一段数据
  • 不会漏数据:传统分页在插入新记录时可能漏掉;游标分页每次从明确位置继续
  • 天然防抖:cursor 是不可预测的值,无法伪造页码

缺点:不能跳页(不能说「直接看第 100 页」),适合列表类滚动加载场景。

完整分页辅助函数

实际项目中建议封装通用的分页工具:

type PageResult struct {
    Data     any   `json:"data"`
    Total    int64 `json:"total"`
    Page     int   `json:"page"`
    PageSize int   `json:"page_size"`
}

func Paginate[T any](db *gorm.DB, where any, args ...any, order string, page, pageSize int) (*PageResult, error) {
    if page < 1 {
        page = 1
    }
    if pageSize < 1 || pageSize > 100 {
        pageSize = 20
    }

    var total int64
    q := db.Model(new(T))
    if where != nil {
        q = q.Where(where, args...)
    }
    if err := q.Count(&total).Error; err != nil {
        return nil, err
    }

    var items []T
    offset := (page - 1) * pageSize
    q = q.Order(order)
    if err := q.Limit(pageSize).Offset(offset).Find(&items).Error; err != nil {
        return nil, err
    }

    return &PageResult{
        Data:     items,
        Total:    total,
        Page:     page,
        PageSize: pageSize,
    }, nil
}

// 无过滤条件
result, err := Paginate[User](db, nil, "created_at DESC", page, 20)

// 参数化条件查询
result, err := Paginate[User](db, "status = ?", "active", "created_at DESC", page, 20)

// 结构体条件(GORM 自动处理字段名)
result, err := Paginate[User](db, User{Status: "active"}, "created_at DESC", page, 20)

HTTP Handler 中的分页

在实际项目中,分页参数通常来自 URL query string。以 Gin 框架为例:

func ListUsers(c *gin.Context) {
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))

    // 白名单校验排序字段
    order := "created_at DESC"
    if field := c.Query("order_by"); field != "" {
        if allowedOrder[field] {
            order = field + " DESC"
        }
    }

    result, err := Paginate[User](c.MustGet("db").(*gorm.DB), nil, order, page, pageSize)
    if err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }

    c.JSON(200, result)
}
// GET /users?page=2&page_size=10&order_by=name

[!tip] 前端推荐参数名

  • page:页码(从 1 开始)
  • page_size:每页条数
  • order_by:排序字段(配合后端白名单)

DefaultPageSize 配置

GORM 提供了 DefaultPageSize 配置项作为全局兜底:

db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
    DefaultPageSize: 20, // 不指定 Limit 时的默认上限
})

// 配合 Preload 使用时限制关联数量
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
    return db.Limit(db.Session(&gorm.Config{}).DB.Set("gorm:DefaultPageSize", 10).Session(&gorm.Config{}).Find)
})

[!info] 注意 DefaultPageSize 仅在没有显式 Limit 时生效。如果你写了 db.Limit(0),它等同于无限制(不走默认值)。建议在业务层统一做分页控制,不要依赖这个配置。

排序与分页决策图

flowchart TD
    Start["开始:需要分页/排序数据"] --> SortQ{"需要自定义排序?"}

    SortQ -->|"否"| DefaultSort["ORDER BY 主键 ASC<br/>GORM 默认排序"]
    SortQ -->|"是"| Whitelist{"字段在白名单中?"}

    Whitelist -->|"是"| CustomSort["db.Order(field + direction)"]
    Whitelist -->|"否"| DefaultSort

    DefaultSort --> PagesQ{"要跳页还是滚动加载?"}
    CustomSort --> PagesQ

    PagesQ -->|"跳页"| OffsetPaginate["LIMIT / OFFSET 分页<br/>简单直白,适合浅翻页"]
    PagesQ -->|"滚动加载"| CursorPaginate["游标分页<br/>性能好、防漏数据"]

    OffsetPaginate --> DeepPage{"是否深翻页?"}
    DeepPage -->|"浅页 ≤ 100"| OK["直接使用 ✓"]
    DeepPage -->|"深页 > 100"| CursorRecommend["推荐改用游标分页"]

    style Start fill:#4FC08D,color:#fff
    style CursorPaginate fill:#3B82F6,color:#fff
    style OK fill:#A0AEC0,color:#fff
    style CursorRecommend fill:#EF4444,color:#fff

常见坑点速查

问题 原因 解决方案
排序字段被 SQL 注入 直接拼接用户输入 白名单校验列名
深翻页极慢 OFFSET 需要跳过大量行 改用游标分页
分页后总数不对 并发修改导致 Count 和查询不一致 事务内执行或使用快照隔离
Limit(0) 没生效 0 被视为无限制 加最小校验:if pageSize < 1 { pageSize = 1 }
多表 JOIN + Limit 结果异常 Limit 作用于 JOIN 后的整行集合 拆分为子查询或用 Preload

关联笔记