安全分页需校验参数范围、隔离count查询、强制排序与索引、拦截大offset、用游标替代深分页,并返回has_next等语义化响应。

GORM 没有内置 Paginate 方法,所谓“通用分页接口”本质是统一参数校验、SQL 构建和响应结构,不是套个封装就完事——不处理 Offset 和 Limit 的顺序、不隔离 Count 查询、不强制 Order,接口上线三天必出漏数据或慢查询。
怎么写安全的分页参数绑定结构体
别用裸 int 字段接收 page 和 size,必须带默认值、范围约束和空值兜底逻辑。
-
Page字段类型用*int或带binding:"required,numeric,gte=1"的int,避免page=0或page=abc直接进计算 -
Size必须硬限制上限(如 100),超出后自动截断而非报错:size = min(p.Size, 100) - 用
c.ShouldBindQuery(&p)而非c.Query()手动解析,前者能统一触发 validator 校验,后者要自己写strconv.Atoi+if err != nil套路,容易漏判空字符串 - 结构体里别塞
Total字段用于输入——它只该是输出字段,输入时带Total可能被恶意篡改或干扰绑定
为什么 Count(&total) 总是返回错误的总数
因为 db.Where("status = ?", 1).Limit(20).Offset(40).Count(&total) 中的 Count 会复用前面链上的 Limit 和 Offset,最终 total 永远 ≤ 20。
- 正确做法是新建一个干净的
*gorm.DB实例做统计:db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where("status = ?", 1).Count(&total) - 如果查询含
Joins或复杂子查询,Count很可能因关联表字段歧义失败,此时应手写子查询:db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u LEFT JOIN profiles p ON u.id = p.user_id WHERE u.status = ?) t", 1).Scan(&total) - 别在同一个事务里先
Count再Find——两次查询之间若有人插入/删除,总数和当前页数据对不上,业务允许就直接忽略;否则得加SELECT ... FOR UPDATE,但会拖慢并发
Offset 越大越慢?这不是 GORM 的问题,但你得提前拦住
MySQL 执行 OFFSET 100000 LIMIT 20 时,必须扫描并丢弃前 10 万行,GORM 只负责拼出这条 SQL,优化得靠你干预。
- 在参数校验阶段就拦截超高偏移:
if offset > 100000 { c.JSON(400, gin.H{"error": "page too large"}) } - 强制要求前端传排序字段且必须有索引,例如
Order("id ASC")或Order("created_at DESC, id DESC"),避免ORDER BY RAND()这类反模式 - 当
page > 20或总记录数预估超 50 万时,直接拒绝传统分页,提示前端改用游标:返回next_cursor: "1654321"(即最后一条的id),下一次查WHERE id > ? ORDER BY id ASC LIMIT 20 - 别依赖 “GORM 自动加索引”——检查执行计划:
EXPLAIN SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 100000,确认key列命中主键索引
响应结构怎么设计才真正通用
前端需要的不是 “第几页”,而是“能否继续翻页”和“当前页有哪些数据”,响应体里塞 page、size、total 是历史包袱,容易误导。
- 推荐扁平结构:
{"data": [...], "total": 12345, "has_next": true},去掉冗余的page字段,避免前端误拿page做下一页计算 -
total字段只在必要时返回(比如后台管理页需显示总页数),App 端无限滚动场景可只返回has_next,省掉一次COUNT查询 - 不要把分页逻辑塞进 DAO 层返回
[]User—— 应返回封装好的结构体,例如:type PageResult struct { Data interface{}; Total int64; HasNext bool },让 handler 层专注组合,DAO 层只管查 - 如果用了 Gin,别在每个 handler 里重复写
db.Offset().Limit().Order().Find(),抽成func(db *gorm.DB) *gorm.DB类型的 scope,但注意 scope 里不能调Count,它只能修饰查询,不能执行
最常被忽略的点:分页不是加两个参数就完事,而是从参数校验、SQL 构建、索引设计到响应语义的全链路控制。尤其是 Count 查询的上下文隔离和 ORDER BY 的确定性,漏掉任一环,线上就可能出现“用户说某条数据消失了”这种难复现问题。











