第15章:排序、分页与限定查询
本章目标
学完本章后,你将能够:
- 使用
Order进行单列和多列排序 - 使用
Limit+Offset实现分页查询 - 封装通用的 Paginate Scope
- 理解大偏移量分页的性能问题及优化方案
- 初识
Scopes作用域
前置知识
- 需要先学习:第11章(WHERE 条件查询)
- 需要了解:MySQL 的 ORDER BY、LIMIT、OFFSET 语法
15.1 Order:排序
基本用法
go
// 单列排序
db.Order("age desc").Find(&users)
// SQL: SELECT * FROM `users` ORDER BY age DESC
db.Order("age asc").Find(&users)
// SQL: SELECT * FROM `users` ORDER BY age ASC
db.Order("age").Find(&users)
// SQL: SELECT * FROM `users` ORDER BY age(默认 ASC)多列排序
go
// 先按年龄降序,年龄相同按名字升序
db.Order("age desc, name asc").Find(&users)
// SQL: SELECT * FROM `users` ORDER BY age DESC, name ASC
// 链式添加排序
db.Order("age desc").Order("name asc").Find(&users)
// 效果同上安全的结构化排序
当排序字段来自用户输入时(如 API 参数 ?sort=name&order=desc),不要直接拼字符串:
go
// ❌ 危险:用户输入直接拼 SQL
sortField := req.Query("sort") // 用户可能传 "1; DROP TABLE users--"
db.Order(sortField + " desc").Find(&users)
// ✅ 安全:白名单验证
allowedFields := map[string]bool{"name": true, "age": true, "created_at": true}
if !allowedFields[sortField] {
return errors.New("无效的排序字段")
}
db.Order(clause.OrderByColumn{
Column: clause.Column{Name: sortField},
Desc: true,
}).Find(&users)15.2 Limit + Offset:分页查询
基础分页
go
// 第一页:每页 10 条
db.Limit(10).Offset(0).Find(&users)
// SQL: SELECT * FROM `users` LIMIT 10 OFFSET 0
// 第二页:跳过前 10 条,取 10 条
db.Limit(10).Offset(10).Find(&users)
// SQL: SELECT * FROM `users` LIMIT 10 OFFSET 10
// 第三页
db.Limit(10).Offset(20).Find(&users)
// SQL: SELECT * FROM `users` LIMIT 10 OFFSET 20📌
Limit和Offset的顺序可以互换,GORM 会智能处理。db.Offset(20).Limit(10)效果相同。
取消 Limit
go
// 传入 -1 取消之前的 Limit/Offset 设置
db.Limit(10).Offset(20).Limit(-1).Offset(-1).Find(&users)
// SQL: SELECT * FROM `users`(无 LIMIT 和 OFFSET)15.3 分页封装:通用 Paginate Scope
每次分页都写 Limit + Offset + Count 太麻烦了,封装一个通用的:
go
// Paginate 返回一个 Scope 函数
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 10
}
offset := (page - 1) * pageSize
return db.Offset(offset).Limit(pageSize)
}
}使用:
go
var users []User
var total int64
page := 2
pageSize := 10
// 先查总数
db.Model(&User{}).Where("age > ?", 18).Count(&total)
// 再查分页数据
db.Scopes(Paginate(page, pageSize)).Where("age > ?", 18).Order("id desc").Find(&users)
fmt.Printf("第 %d 页 / 共 %d 条, 每页 %d 条\n", page, total, pageSize)更完善的封装
go
type Pagination struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
func (p *Pagination) Scope() func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if p.Page <= 0 {
p.Page = 1
}
if p.PageSize <= 0 {
p.PageSize = 10
}
if p.PageSize > 100 {
p.PageSize = 100 // 防止一次查太多
}
return db.Offset((p.Page - 1) * p.PageSize).Limit(p.PageSize)
}
}
// 使用
func ListUsers(db *gorm.DB, page Pagination) ([]User, Pagination, error) {
var users []User
query := db.Model(&User{})
// 总数
query.Count(&page.Total)
// 分页数据
err := query.Scopes(page.Scope()).Order("id desc").Find(&users).Error
return users, page, err
}15.4 Scopes 初识:可复用的查询片段
Scopes 让你把常用的查询逻辑封装成可复用的函数:
go
// 定义一个 Scope
func ActiveUsers(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "active")
}
func AdultUsers(db *gorm.DB) *gorm.DB {
return db.Where("age >= ?", 18)
}
func RecentUsers(db *gorm.DB) *gorm.DB {
return db.Where("created_at > ?", time.Now().Add(-7*24*time.Hour))
}
// 使用(可以组合多个 Scope!)
db.Scopes(ActiveUsers, AdultUsers, RecentUsers).Find(&users)
// SQL: SELECT * FROM `users` WHERE status='active' AND age>=18 AND created_at>'2024-01-08'📌 Scopes 是第19章的重点内容,但这里先预热一下。
Paginate就是最常用的 Scope。
15.5 大偏移量分页的性能问题
问题
go
// 第 1000 页,每页 10 条
db.Limit(10).Offset(10000).Find(&users)
// SQL: SELECT * FROM `users` LIMIT 10 OFFSET 10000
// MySQL 实际上扫描了 10010 行,然后扔掉前 10000 行!
// offset 越大,越慢优化方案一:基于索引的游标分页(Keyset Pagination)
go
// 不用 OFFSET,用上次最后一条记录的 ID 作为起点
var lastID uint = 12345 // 上一页最后一条记录的 ID
db.Where("id > ?", lastID).Limit(10).Order("id").Find(&users)
// SQL: SELECT * FROM `users` WHERE id > 12345 ORDER BY id LIMIT 10
// 利用主键索引,无论 id 多大,速度都很快
// 封装
func CursorPaginate(db *gorm.DB, cursor uint, pageSize int) *gorm.DB {
return db.Where("id > ?", cursor).Limit(pageSize).Order("id")
}优点:任何时候都很快(利用主键索引) 缺点:不能跳页(只能"上一页"和"下一页")
优化方案二:子查询定位
go
// 先定位起始 ID,再做查询
var startID uint
db.Model(&User{}).Select("id").Order("id").Limit(1).Offset(10000).Pluck("id", &startID)
db.Where("id >= ?", startID).Limit(10).Find(&users)
// 子查询用了索引,比全表扫描扔 10000 行快很多选择建议
| 场景 | 推荐方案 |
|---|---|
| 管理后台(数据量 < 10 万) | Offset + Limit 足够 |
| 用户端无限滚动(APP/Web) | 游标分页(基于 ID 或时间) |
| 管理后台(数据量 > 10 万) | 子查询定位或搜索引擎 |
常见错误
错误1:分页忘记排序
go
// ❌ 没指定排序,分页结果不确定
db.Limit(10).Offset(20).Find(&users)
// 每次查询的"第3页"可能是不同的数据!
// ✅ 始终加 Order
db.Order("id desc").Limit(10).Offset(20).Find(&users)错误2:Offset 过大导致慢查询
go
// ❌ 第 10000 页
db.Limit(10).Offset(100000).Find(&users)
// 扫描 100010 行,扔掉 100000 行,慢!
// ✅ 改用游标分页
db.Where("id > ?", lastID).Limit(10).Order("id").Find(&users)错误3:Count 包含 LIMIT
go
// ❌ Count 会被前面的 Limit 影响
query := db.Limit(10).Offset(20).Where("age > ?", 18)
var count int64
query.Count(&count) // Count 会自动清除 Limit,但 Query 链还在
// 最好分开用独立的查询
// ✅ 分开写
db.Model(&User{}).Where("age > ?", 18).Count(&count)
db.Model(&User{}).Where("age > ?", 18).Limit(10).Offset(20).Find(&users)本章小结
Order("age desc, name asc")多列排序Limit(10).Offset(20)分页查询(第3页,每页10条)Paginate(page, pageSize)封装为 Scope,复用方便Scopes()将查询逻辑模块化- 大偏移量用游标分页(
WHERE id > lastID)
练习题
- 按年龄降序、创建时间升序查询用户。
- 实现一个通用的
PaginateScope,参数是page和pageSize。 - 写一个完整的列表查询接口:接收 page、pageSize、sortField、sortOrder 参数,返回分页数据 + 总数。
- 实现游标分页函数:接收 lastID 和 pageSize,返回"下一页"数据。
- 为什么大偏移量的
OFFSET查询会变慢?给出至少一种优化方案。 - (思考题)游标分页的缺点是什么?什么场景下你不能用游标分页?