vault backup: 2026-04-28 20:23:33
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
---
|
||||
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
|
||||
// 第一页:没有 cursor,直接取前 N 条
|
||||
cursor := "" // 空表示从头开始
|
||||
perPage := 20
|
||||
|
||||
var users []User
|
||||
query := db.Model(&User{}).Order("id ASC").Limit(perPage + 1) // 多取 1 条判断是否有下一页
|
||||
|
||||
if cursor != "" {
|
||||
// 查找 id > cursor 的记录
|
||||
query = query.Where("id > ?", cursor)
|
||||
}
|
||||
|
||||
err := query.Find(&users).Error
|
||||
hasNextPage := false
|
||||
|
||||
if len(users) > perPage {
|
||||
hasNextPage = true
|
||||
users = users[:perPage] // 去掉多余的「探测」记录
|
||||
cursor = fmt.Sprint(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, 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)
|
||||
}
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var items []T
|
||||
offset := (page - 1) * pageSize
|
||||
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, "status = ?", "active", "created_at DESC", page, 20)
|
||||
```
|
||||
|
||||
## 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-性能优化]]
|
||||
Reference in New Issue
Block a user