vault backup: 2026-05-18 17:09:17
This commit is contained in:
+156
-27
@@ -1,5 +1,5 @@
|
||||
---
|
||||
tags: [GORM, Go, ORM, 排序, 分页, Order, Limit, Offset, Paginate]
|
||||
tags: [GORM, Go, ORM, 排序, 分页, Order, Limit, Offset, Keyset, Cursor, Paginate]
|
||||
create time: 2026-04-28 00:00
|
||||
---
|
||||
|
||||
@@ -9,6 +9,12 @@ create time: 2026-04-28 00:00
|
||||
|
||||
排序和分页是面向用户的数据展示层的核心技能。不管后端查出多少数据,最终呈现在页面上的总是「一页」——理解 GORM 如何高效地完成这个任务,直接影响 API 的响应时间和用户体验。
|
||||
|
||||
想象一个电商商品列表页:用户每次翻页都能看到 20 条最新上架的商品,按价格或销量排序。如果底层查询没有做好排序和分页控制,哪怕只有百万级数据,一个简单的列表接口也可能耗时数秒甚至超时。
|
||||
|
||||
> [!question] 在往下看之前想一想
|
||||
>
|
||||
> 你有遇到过「越往后翻,页面加载越慢」的场景吗?这通常就是 OFFSET 分页在深页时性能急剧下降造成的。接下来我们会从基础讲到高性能方案,帮你彻底搞懂这个问题。
|
||||
|
||||
## Order — 排序
|
||||
|
||||
### 基本用法
|
||||
@@ -57,6 +63,8 @@ db.Order(field + " DESC").Find(&users)
|
||||
|
||||
### 随机排序
|
||||
|
||||
偶尔需要随机取数据(如「每日精选」"推荐话题」),不同数据库提供了不同的随机函数:
|
||||
|
||||
```go
|
||||
// MySQL
|
||||
db.Order("RAND()").Find(&users)
|
||||
@@ -71,16 +79,20 @@ db.Order("RANDOM()").Find(&users)
|
||||
> [!warning] 随机排序的性能陷阱
|
||||
> `ORDER BY RAND()` 会对整张表生成随机数再排序——O(n log n) 复杂度,百万级数据几乎不可用。如果只需要几条随机记录,改用更高效的方案:
|
||||
> ```go
|
||||
> // 方案一:OFFSET random
|
||||
> // 方案一: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 — 分页基础
|
||||
|
||||
@@ -106,6 +118,8 @@ db.Limit(perPage).Offset(offset).Order("id").Find(&products)
|
||||
|
||||
### 查询总行数
|
||||
|
||||
完整分页响应通常需要三个步骤:先查总数、再算偏移量、最后取数据。这样前端才能渲染出分页控件(「共 N 页」):
|
||||
|
||||
```go
|
||||
var total int64
|
||||
db.Model(&Product{}).Where("status = ?", "active").Count(&total)
|
||||
@@ -122,25 +136,54 @@ db.Model(&Product{}).
|
||||
Offset(offset).
|
||||
Find(&products)
|
||||
|
||||
// 总页数
|
||||
totalPages := int(math.Ceil(float64(total) / float64(perPage)))
|
||||
// ❌ 删除线写法: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
|
||||
ID uint
|
||||
Name string
|
||||
Age int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// 第一页:没有 cursor,直接取前 N 条
|
||||
@@ -155,16 +198,20 @@ if cursor != 0 {
|
||||
q = q.Where("id > ?", cursor)
|
||||
}
|
||||
|
||||
err := q.Find(&users).Error
|
||||
hasNextPage := false
|
||||
if err := q.Find(&users).Error; err != nil {
|
||||
// handle error
|
||||
}
|
||||
|
||||
if len(users) > perPage {
|
||||
hasNextPage = true
|
||||
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] 游标分页的优点
|
||||
> - **速度恒定**:无论翻到哪页,都是查紧邻的下一段数据
|
||||
> - **不会漏数据**:传统分页在插入新记录时可能漏掉;游标分页每次从明确位置继续
|
||||
@@ -172,9 +219,62 @@ if len(users) > perPage {
|
||||
>
|
||||
> **缺点**:不能跳页(不能说「直接看第 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 {
|
||||
@@ -193,7 +293,7 @@ func Paginate[T any](db *gorm.DB, where any, args ...any, order string, page, pa
|
||||
}
|
||||
|
||||
var total int64
|
||||
q := db.Model(new(T))
|
||||
q := db.Model(new(T)) // 通过泛型自动获取模型,无需手动传类型
|
||||
if where != nil {
|
||||
q = q.Where(where, args...)
|
||||
}
|
||||
@@ -216,36 +316,63 @@ func Paginate[T any](db *gorm.DB, where any, args ...any, order string, page, pa
|
||||
}, 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 框架为例:
|
||||
在实际项目中,分页参数通常来自 URL query string。以 Gin 框架为例,一个健壮的分页 Handler 应该同时做好**参数校验**和**白名单过滤**:
|
||||
|
||||
```go
|
||||
func ListUsers(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
// 排序字段白名单 — 建议抽到配置文件中统一管理
|
||||
var allowedOrders = map[string]string{
|
||||
"created_at": "created_at DESC",
|
||||
"updated_at": "updated_at DESC",
|
||||
"name": "name ASC",
|
||||
"age": "age ASC",
|
||||
}
|
||||
|
||||
// 白名单校验排序字段
|
||||
order := "created_at DESC"
|
||||
if field := c.Query("order_by"); field != "" {
|
||||
if allowedOrder[field] {
|
||||
order = field + " DESC"
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
result, err := Paginate[User](c.MustGet("db").(*gorm.DB), nil, order, page, pageSize)
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
result, err := Paginate[User](db, nil, order, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
log.Printf("paginate failed: %v", err)
|
||||
c.JSON(500, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -258,6 +385,8 @@ func ListUsers(c *gin.Context) {
|
||||
> - `page`:页码(从 1 开始)
|
||||
> - `page_size`:每页条数
|
||||
> - `order_by`:排序字段(配合后端白名单)
|
||||
>
|
||||
> **约定优于配置**:全项目统一一套参数名可以避免前后端沟通成本,也方便封装通用中间件。
|
||||
|
||||
## DefaultPageSize 配置
|
||||
|
||||
|
||||
Reference in New Issue
Block a user