Skip to content

第15章:排序、分页与限定查询 ​

本章目标 ​

学完本章后,你将能够:

  1. 使用 Order 进行单列和多列排序
  2. 使用 Limit + Offset 实现分页查询
  3. 封装通用的 Paginate Scope
  4. 理解大偏移量分页的性能问题及优化方案
  5. 初识 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)

练习题 ​

  1. 按年龄降序、创建时间升序查询用户。
  2. 实现一个通用的 Paginate Scope,参数是 page 和 pageSize。
  3. 写一个完整的列表查询接口:接收 page、pageSize、sortField、sortOrder 参数,返回分页数据 + 总数。
  4. 实现游标分页函数:接收 lastID 和 pageSize,返回"下一页"数据。
  5. 为什么大偏移量的 OFFSET 查询会变慢?给出至少一种优化方案。
  6. (思考题)游标分页的缺点是什么?什么场景下你不能用游标分页?

把零散知识,整理成可以反复走的路。