--- 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 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) } err := q.Find(&users).Error hasNextPage := false if len(users) > perPage { hasNextPage = true users = users[:perPage] // 去掉多余的「探测」记录 cursor = 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, 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) ``` ## HTTP Handler 中的分页 在实际项目中,分页参数通常来自 URL query string。以 Gin 框架为例: ```go func ListUsers(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) // 白名单校验排序字段 order := "created_at DESC" if field := c.Query("order_by"); field != "" { if allowedOrder[field] { order = field + " DESC" } } result, err := Paginate[User](c.MustGet("db").(*gorm.DB), nil, order, page, pageSize) if err != nil { c.JSON(500, gin.H{"error": err.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
GORM 默认排序"] SortQ -->|"是"| Whitelist{"字段在白名单中?"} Whitelist -->|"是"| CustomSort["db.Order(field + direction)"] Whitelist -->|"否"| DefaultSort DefaultSort --> PagesQ{"要跳页还是滚动加载?"} CustomSort --> PagesQ PagesQ -->|"跳页"| OffsetPaginate["LIMIT / OFFSET 分页
简单直白,适合浅翻页"] PagesQ -->|"滚动加载"| CursorPaginate["游标分页
性能好、防漏数据"] 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-性能优化]]