不能用 page=1&size=20 查审核列表,因状态频繁变更、排序字段重复导致 OFFSET 分页出现记录重复或丢失,且 OFFSET 超 5000 后性能骤降;应改用带唯一递增 id 的游标分页。

直接用 Limit + Offset 做多状态标签分页,在审核类系统里大概率出问题——状态变更频繁、并发更新、前端无限滚动,会导致重复/丢失记录,且 OFFSET 超过 5000 后查询延迟飙升。
为什么不能用 page=1&size=20 查审核列表
审核系统中,一条记录可能在用户查看第 3 页时被后台批处理更新了 status,导致它从“待审”变成“已通过”。此时若下一页还按 OFFSET 40 继续扫,就可能跳过这条刚变状态的记录,或因排序不稳定重复出现。更关键的是:ORDER BY status, created_at 这类多字段排序,若 status 值高度重复(比如 80% 都是 "pending"),数据库无法利用索引高效跳过前 N 行,OFFSET 会退化成全表扫描。
- 常见错误现象:
page=100&size=20返回空数组,但总数查出来有 2050 条 - 复合排序字段必须建联合索引,例如
INDEX idx_status_created (status, created_at DESC, id),只对status单独建索引无效 -
Count(&total)必须和主查询条件完全一致,且不能复用同一个*gorm.DB实例——否则Limit会污染Count结果
游标分页必须带唯一递增字段
审核列表的游标不能只传 status 和 created_at,因为时间可能重复(尤其批量导入或高并发提交)。必须拼上 id 作为最终去重依据,SQL 形如 WHERE (status, created_at, id) > (?, ?, ?) ORDER BY status, created_at DESC, id LIMIT 20。GORM 不支持原生元组比较,得手写 Where 条件或用 Scopes 封装。
- 前端首次请求不带游标,后端返回第 20 条的
status、created_at、id三元组作为next_cursor - 后续请求解码游标时,必须校验三个值都存在且类型匹配,缺一不可,否则 fallback 到首页
- 避免用
Preload加载关联审核人信息——先查出 20 个id列表,再用db.Where("id IN ?", ids).Find(&users)批量查,否则 N+1 查询会拖垮性能
GORM 中安全拼接多状态 WHERE 条件
审核列表常需按多个状态标签筛选(如 status IN ("pending", "rejected")),但直接拼字符串易 SQL 注入;用 Where("status IN ?", statuses) 又要求 statuses 是切片。若前端传的是逗号分隔字符串(如 status=pending,rejected),得先做清洗。
- 禁止:
Where("status IN ('" + strings.Join(statuses, "','") + "')")—— 无转义,可注入 - 推荐:
db.Where("status IN ?", statuses).Where("deleted_at IS NULL"),其中statuses是[]string{"pending", "rejected"} - 如果状态值来自用户输入且未严格白名单校验,务必加枚举检查:
validStatus := map[string]bool{"pending": true, "approved": true, "rejected": true}; if !validStatus[s] { return errors.New("invalid status") } - 注意:GORM 的
IN参数超过 65535 个时会报错,审核列表通常不会到这量级,但若支持全量导出需拆批次
Count 查询不准的典型陷阱
最常踩的坑是写了 db.Where(...).Order(...).Limit(...).Count(&total),结果 total 总是等于 Limit 值。这是因为 Count 方法会继承前面链式调用的所有条件,包括 Limit——它不是“查总数”,而是“查当前查询能返回几条”。
- 正确做法:单独构造一个无
Limit/Offset的*gorm.DB实例,例如db.Model(&Audit{}).Where("status IN ?", statuses).Count(&total) - 别复用接口层传进来的
db实例,尤其当它已被中间件加了软删除或租户过滤时 - 如果总数超过 10 万,考虑用近似值(MySQL 的
TABLE_ROWS)或缓存计数,避免每次查都触发全表扫描
游标值的序列化和解析容易被忽略——created_at 是 time.Time,JSON 编码默认带纳秒和时区,前端 JS 解析可能出错;建议统一转为 Unix 毫秒整数,id 用字符串避免大整数溢出,三者拼成 base64 或 URL-safe 字符串传输。


















