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

324 lines
9.6 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, Paginate]
create time: 2026-04-28 00:00
---
# 排序与分页
## 概述
排序和分页是面向用户的数据展示层的核心技能。不管后端查出多少数据,最终呈现在页面上的总是「一页」——理解 GORM 如何高效地完成这个任务,直接影响 API 的响应时间和用户体验。
## 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
> 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 — 分页基础
### 基本原理
```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 条 |
### 查询总行数
```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 := int(math.Ceil(float64(total) / float64(perPage)))
```
> [!question] 思考题
> 如果 offset 极大(比如第 10000 页),查询会变得很慢,为什么?有没有更好的方案?
>
> > **答案**:因为数据库仍然需要扫描并跳过前面大量的行,即使它们不会被返回。更好的方案是用 **游标分页(Keyset Pagination)**——按上一次最后一条记录的 id 来查,详见下面的游标分页章节。
## 游标分页(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)
}
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 页」),适合列表类滚动加载场景。
## 完整分页辅助函数
实际项目中建议封装通用的分页工具:
```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)
```
## HTTP Handler 中的分页
在实际项目中,分页参数通常来自 URL query string。以 Gin 框架为例:
```go
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` 配置项作为全局兜底:
```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-性能优化]]