不能直接用limit+offset,因其基于物理行偏移,高并发下因插入/删除导致漏数或重复;应使用created_at与id组合游标分页,确保顺序一致和数据准确。

待办列表分页为什么不能直接用 Limit + Offset
因为工作流任务常带状态流转和并发更新,Limit+Offset 在高并发下会漏数据或重复:用户 A 刚处理完第 20 条,用户 B 同时刷新第 2 页(Offset=20),此时新插入的待办可能挤进前 20 条,导致 B 跳过某条。GORM 的 Limit/Offset 是纯 SQL 级偏移,不感知业务语义。
真实场景中,待办列表通常按「创建时间 + 状态 + 优先级」复合排序,且需支持“下拉加载更多”——这时必须用游标分页(cursor-based pagination)。
- 用上一页最后一条的
created_at和id作为下一页起点,例如:WHERE created_at - 避免
OFFSET,消除数据漂移风险 - GORM 不内置游标分页支持,需手动拼条件,但可封装成复用函数:
func PaginateByCursor(db *gorm.DB, cursor Cursor, limit int) *gorm.DB
Preload 关联待办节点时容易 N+1 和内存爆炸
一个待办(Task)常关联流程定义(Workflow)、发起人(User)、当前审批人(Assignee),若用 Preload 全部加载,一次查 50 条待办可能触发 150 次额外查询(N+1),或一次性拉回数 MB 冗余 JSON 字段。
更合理的方式是按需投影(projection):
- 只
Select必需字段:db.Select("tasks.id, tasks.title, tasks.status, tasks.created_at, workflows.name as workflow_name").Joins("left join workflows on workflows.id = tasks.workflow_id") - 用
map[string]interface{}接收,避免结构体全量解码 - 若真需结构体,定义轻量 DTO 类型,如
type TaskListItem struct { ID uint; Title string; WorkflowName string },不嵌套完整Workflow模型
分页结果里状态过滤要走数据库索引,别靠 Go 层 filter
待办列表前端常提供「我发起的」「待我审批」「已拒绝」等筛选项。如果在 GORM 查询后用 for range 在内存里过滤,等于把全表扫出来再裁剪——尤其当待办表有百万行时,OOM 或超时是必然的。
正确做法是把过滤条件下沉到 WHERE 子句,并确保对应字段建了联合索引:
- 常见组合:
WHERE status IN ('pending', 'reviewing') AND assignee_id = ?→ 建索引:INDEX idx_status_assignee (status, assignee_id) - 时间范围过滤(如“近 7 天待办”)必须加
created_at索引,否则ORDER BY created_at DESC LIMIT 20会全表扫描 - GORM 的
Where链式调用没问题,但别写Where("status = ?", "pending").Where("assignee_id = ?", uid)这种拆开的,GORM v2 会合并为单个 WHERE,放心用
总数统计和分页数据不该共用同一查询
很多开发者习惯先 Count(&total) 再 Limit().Offset(),认为这样“准确”。但问题在于:两次查询之间状态可能变化,总数和分页结果对不上;更糟的是,Count 会忽略 Select 字段、Preload 关联,导致统计逻辑和实际列表逻辑不一致(比如你只查 pending 待办,但 Count 没加 WHERE)。
推荐方案是用子查询或窗口函数一次拿到分页数据 + 总数:
- PostgreSQL 可用
SELECT *, COUNT(*) OVER() AS total FROM ... LIMIT 20 OFFSET 0,GORM 支持原生 SQL:db.Raw(...).Scan(&results) - MySQL 8.0+ 同样支持
COUNT(*) OVER();老版本可用SELECT SQL_CALC_FOUND_ROWS ... LIMIT 20; SELECT FOUND_ROWS()(注意:需两次 Exec) - 最稳妥的兼容写法:用事务包住两个查询,
db.Transaction(func(tx *gorm.DB) error { tx.Model(&Task{}).Where(...).Count(&total); tx.Where(...).Limit(...).Find(&list); return nil }),但仅适用于读多写少场景
游标分页天然不需要总数,所以如果你的前端接受“无总页数”的无限滚动,就彻底绕开这个坑。











