453 lines
16 KiB
Markdown
453 lines
16 KiB
Markdown
|
|
---
|
|||
|
|
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-性能优化]]
|