Files
cs-note/hhs/GORM/06-排序与分页.md
T
2026-05-24 11:42:38 +08:00

453 lines
16 KiB
Markdown
Raw 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: [GORM, Go, ORM, 排序, 分页, Order, Limit, Offset, Keyset, Cursor, Paginate]
create time: 2026-04-28 00:00
---
# 排序与分页
## 概述
排序和分页是面向用户的数据展示层的核心技能。不管后端查出多少数据,最终呈现在页面上的总是「一页」——理解 GORM 如何高效地完成这个任务,直接影响 API 的响应时间和用户体验。
想象一个电商商品列表页:用户每次翻页都能看到 20 条最新上架的商品,按价格或销量排序。如果底层查询没有做好排序和分页控制,哪怕只有百万级数据,一个简单的列表接口也可能耗时数秒甚至超时。
> [!question] 在往下看之前想一想
>
> 你有遇到过「越往后翻,页面加载越慢」的场景吗?这通常就是 OFFSET 分页在深页时性能急剧下降造成的。接下来我们会从基础讲到高性能方案,帮你彻底搞懂这个问题。
## Order — 排序
### 基本用法
```go
// 单字段升序
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` 会**追加**排序条件,而不是覆盖。所以上面的写法等价于:
> ```go
> db.Order("age DESC, created_at ASC").Find(&users)
> ```
### 动态排序
当排序字段来自前端参数时,需要小心处理 SQL 注入:
```go
// ❌ 危险 —— 用户可传入 "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 预编译机制只对参数有效,对结构部分无效。因此必须通过白名单或其他方式确保列名的安全性。
### 随机排序
偶尔需要随机取数据(如「每日精选」"推荐话题」),不同数据库提供了不同的随机函数:
```go
// 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) 复杂度,百万级数据几乎不可用。如果只需要几条随机记录,改用更高效的方案:
> ```go
> // 方案一:OFFSET random —— 先查总数,再随机跳 offset
> var count int
> db.Model(&User{}).Count(&count)
> offset := rand.Intn(count)
> db.Limit(10).Offset(offset).Find(&users)
> // ⚡ 只扫描 11 行数据,但大量并发时可能取到重复 ID
>
> // 方案二:先在 Go 中随机选几个 ID,再查
> ids := randomIDs(count, 10)
> db.Where("id IN ?", ids).Find(&users)
> // ✅ 精准定位、零浪费;注意处理 ID 不存在的边界情况
> ```
>
> **如何选择**:用户量 < 10 万且 QPS 不高时,方案一简单够用;生产环境推荐方案二,或用 Redis Sorted Set 预生成随机池。
## Limit / Offset — 分页基础
### 基本原理
```go
// 每页 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 条 |
### 查询总行数
完整分页响应通常需要三个步骤:先查总数、再算偏移量、最后取数据。这样前端才能渲染出分页控件(「共 N 页」):
```go
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 未使用,会导致编译错误
// totalPages := int(math.Ceil(float64(total) / float64(perPage)))
```
> [!warning] Count 和 Find 之间可能有数据变化
> `Count` 执行后如果恰好有记录被插入或删除,`Find` 拿到的结果可能与 `Count` 不一致——导致最后一页可能出现空数据或页数对不上。在数据一致性要求高的场景下,可以在事务内同时执行这两个操作。
> [!question] 思考题
> 如果 offset 极大(比如第 10000 页),查询会变得很慢,为什么?有没有更好的方案?
>
> > **答案**:因为数据库仍然需要扫描并跳过前面大量的行,即使它们不会被返回。更好的方案是用 **游标分页(Keyset Pagination)**——按上一次最后一条记录的 id 来查,详见下面的游标分页章节。
### Offset 分页为什么越翻越慢?
```mermaid
flowchart LR
subgraph Shallow["浅页(第 1-10 页)"]
A1["OFFSET 0 → 跳过 0 行<br/>响应 ~5ms ✓"]
A2["OFFSET 100 → 跳过 100 行<br/>响应 ~8ms ✓"]
end
subgraph Deep["深页(第 100+ 页)"]
B1["OFFSET 1000 → 跳过 1000 行<br/>响应 ~30ms ⚠️"]
B2["OFFSET 100000 → 跳过 10万行<br/>响应 ~500ms ✗"]
end
Shallow -.->|数据量增大| Deep
style Shallow fill:#4FC08D,color:#fff
style Deep fill:#EF4444,color:#fff
style A1 fill:#A0AEC0,color:#fff
style A2 fill:#A0AEC0,color:#fff
style B1 fill:#FED7AA,color:#000
style B2 fill:#FCA5A5,color:#000
```
**底层原理**:`LIMIT 10 OFFSET 100000` 对数据库来说,需要先读取前 100010 行、扔掉前 100000 行、最后返回剩余的 10 行。数据量越大、跳过的行越多,浪费的 CPU 和 IO 就越多。
## 游标分页(Keyset Pagination)
传统的 `LIMIT/OFFSET` 分页在深页性能急剧下降。游标分页通过「记住上一页最后一条记录的位置」来实现 O(log n) 的跳转:
```go
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)
}
if err := q.Find(&users).Error; err != nil {
// handle error
}
hasNextPage := len(users) > perPage
if hasNextPage {
users = users[:perPage] // 去掉多余的「探测」记录
cursor = users[len(users)-1].ID // 提取新的 cursor
}
```
> [!tip] "多取 1 条"探测下一页的原理
> `Limit(perPage + 1)` 的核心技巧:如果拿到的结果超过 `perPage`,说明还有下一页。多余的 1 条被丢弃,同时它的 ID 成为下一页的 cursor。只需一次查询就能拿到数据和翻页信息。
> [!tip] 游标分页的优点
> - **速度恒定**:无论翻到哪页,都是查紧邻的下一段数据
> - **不会漏数据**:传统分页在插入新记录时可能漏掉;游标分页每次从明确位置继续
> - **天然防抖**:cursor 是不可预测的值,无法伪造页码
>
> **缺点**:不能跳页(不能说「直接看第 100 页」),适合列表类滚动加载场景。
### 多字段排序下的游标分页
单字段主键是最简单的游标场景,但实际项目中排序往往涉及多个字段。例如按「状态优先、创建时间倒序」展示订单,cursor 就需要携带多个条件:
```go
// 排序规则:status ASC(未处理在前),created_at DESC(最新的在前)
type Order struct {
ID uint
Status string
CreatedAt time.Time
}
type Cursor struct {
Status string
CreatedAt time.Time
}
// 上一页最后一条:Status="pending", CreatedAt="2026-05-01 10:30:00"
prev := Cursor{Status: "pending", CreatedAt: time.Date(2026, 5, 1, 10, 30, 0, 0, time.UTC)}
perPage := 20
var orders []Order
q := db.Model(&Order{}).
Order("status ASC, created_at DESC").
Limit(perPage + 1)
// 第一组:status < prev.Status(更早的状态排在前面)
// 第二组:status == prev.Status 且 created_at < prev.CreatedAt(同一状态下更晚的排前面)
q = q.Where(
"(status < ?) OR (status = ? AND created_at < ?)",
prev.Status, prev.Status, prev.CreatedAt,
)
err := q.Find(&orders).Error
if err != nil {
// handle error
}
hasNextPage := len(orders) > perPage
if hasNextPage {
orders = orders[:perPage]
last := orders[len(orders)-1]
nextCursor = Cursor{Status: last.Status, CreatedAt: last.CreatedAt}
}
```
> [!warning] 多字段游标的核心原则
> - 排序条件的**顺序必须严格一致**——`WHERE` 中的比较逻辑要和 `ORDER BY` 一一对应
> - **ASC 用 `<` / `>`,DESC 反过来**。上面示例中 `created_at DESC` 所以用了 `<`:因为倒序排列时「旧的」在后面,下一页要比上一页更旧
> - 当主键(或唯一索引)已经能覆盖排序时,只需在 cursor 中传主键即可,无需额外字段
> - cursor 值建议通过 URL-safe Base64 序列化后传给前端,防止篡改和泄露内部 ID
## 完整分页辅助函数
实际项目中,每次都要手写 `Count + Limit + Offset` 既冗长又容易出错。利用 Go 1.18+ 的**泛型**,可以写一个类型安全的通用分页函数:
```go
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)
```
> [!note] 泛型分页函数设计要点
>
> | 设计点 | 说明 |
> |--------|------|
> | `T any` 泛型 | 调用方指定模型类型,返回值类型安全,IDE 自动补全 |
> | `where any + args ...any` | 同时支持字符串条件、map 和结构体三种 GORM 写法 |
> | `pageSize > 100` 上限 | 防止前端传一个极大的 `page_size` 导致内存溢出 |
> | `Count + Find` 两次查询 | 标准分页模式;极端性能场景可只用游标分页省去 Count |
## HTTP Handler 中的分页
在实际项目中,分页参数通常来自 URL query string。以 Gin 框架为例,一个健壮的分页 Handler 应该同时做好**参数校验**和**白名单过滤**:
```go
// 排序字段白名单 — 建议抽到配置文件中统一管理
var allowedOrders = map[string]string{
"created_at": "created_at DESC",
"updated_at": "updated_at DESC",
"name": "name ASC",
"age": "age ASC",
}
func ListUsers(c *gin.Context) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
c.JSON(400, gin.H{"error": "invalid page"})
return
}
pageSize, err := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if err != nil || pageSize < 1 || pageSize > 100 {
pageSize = 20 // 超限则回退到默认值
}
// 从白名单取完整的排序表达式(含方向)
order := "created_at DESC" // 默认排序
if orderBy := c.Query("order_by"); orderBy != "" {
if expr, ok := allowedOrders[orderBy]; ok {
order = expr
}
}
db := c.MustGet("db").(*gorm.DB)
result, err := Paginate[User](db, nil, order, page, pageSize)
if err != nil {
log.Printf("paginate failed: %v", err)
c.JSON(500, gin.H{"error": "internal 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` 配置项作为全局兜底:
```go
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)`,它等同于无限制(不走默认值)。建议在业务层统一做分页控制,不要依赖这个配置。
## 排序与分页决策图
```mermaid
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 |
## 关联笔记
- [[03-CRUD 操作]]
- [[04-条件查询]]
- [[07-子查询与分组]]
- [[15-性能优化]]