传统limit+offset分页在高并发、数据频繁写入等场景下必然导致漏数据、重复和慢查询;通用解法是游标分页,需参数、sql、索引三者对齐,并解耦排序、条件、总数与关联加载。

直接用 Limit + Offset 做分页,能跑通但不通用——尤其在高并发、数据频繁写入、页码靠后或需关联查询的场景下,漏数据、重复、慢查询几乎是必然结果。真正可复用的“通用复杂列表分页”,必须绕开 Offset 的物理跳过逻辑,改用游标(cursor)驱动,同时把排序、条件、总数、关联加载全部解耦控制。
为什么传统 Limit+Offset 在复杂列表里大概率失效
不是 GORM 有问题,是 SQL 语义本身就不支撑“第 N 页”这种抽象。数据库执行 OFFSET 10000 时,必须先扫描并丢弃前 10000 行,哪怕只查 20 条。更关键的是:
- 没显式
Order("id ASC"),同一查询多次执行返回顺序可能不同——MySQL/PostgreSQL 都不保证无序结果的稳定性 - 插入/删除发生在分页过程中时,
page=50可能跳过某条记录,而它又出现在page=51里 -
Preload("Orders")后再Limit(20),GORM 先取 20 个用户,再为每个发一条子查询,实际加载的订单数可能是几百甚至上千 -
Count(&total)如果复用带Where和Preload的链式 DB 实例,会因 GORM 内部重写 SQL 导致计数错误(比如只算主表,忽略 JOIN 条件过滤)
游标分页怎么落地:参数、SQL、索引三者必须对齐
游标分页本质是“从上一页最后一条继续往后翻”,不是“跳过前 N 条”。它要求前端传回上一页末尾记录的排序字段值(如 cursor=12345),后端生成 WHERE 条件而非 OFFSET。
- 前端必须传
cursor(字符串,建议用 base64 编码复合值,如base64("2024-05-01T10:00:00Z|12345")),不能只依赖page - 后端解析 cursor 得到
created_at和id,SQL 写成WHERE created_at > ? OR (created_at = ? AND id > ?) ORDER BY created_at ASC, id ASC LIMIT 20 - 数据库必须有联合索引
INDEX(created_at, id),否则这个 WHERE 无法走索引,性能反而更差 - 首次请求没 cursor,就用
WHERE created_at > '1970-01-01' ORDER BY ... LIMIT 20起手,避免全表扫
总数查询不能省,但也不能硬 COUNT(*)
用户需要知道“还有多少页”,但对百万级表执行 COUNT(*) 是反模式。折中方案是:
- 数据变动不剧烈(如后台管理):用
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where(...).Count(&total)隔离上下文,确保条件纯净 - 数据高频写入(如用户 Feed):放弃精确总数,前端显示“已加载 20 条,更多内容正在加载…”;或缓存一个近似值(如定时任务更新
cache.Set("user_total", count, 10*time.Minute)) - 带 JOIN 的复杂查询:手写子查询,例如
db.Raw("SELECT COUNT(*) FROM (SELECT u.id FROM users u LEFT JOIN profiles p ON u.id = p.user_id WHERE u.status = ?) t", "active").Scan(&total),避免 GORM 自动 JOIN 导致笛卡尔积放大计数
关联数据加载必须和分页解耦
别在分页主查询里用 Preload。它会让 GORM 拆成 N+1 查询,且无法控制每个关联项的数量(比如“每个用户只取最新 3 个订单”)。
- 先查主表分页结果:
db.Where(...).Order(...).Limit(20).Find(&users) - 提取主键切片:
ids := make([]uint, 0, len(users)); for _, u := range users { ids = append(ids, u.ID) } - 批量查关联:
db.Where("user_id IN ?", ids).Order("created_at DESC").Limit(3 * len(ids)).Find(&orders),再按 user_id 分组 - 如果关联表也很大,给
user_id加索引,且Limit要乘以页大小(避免一次拉太多)
最易被忽略的一点:游标值必须是**确定性、单调、不可变**的。别用 updated_at 当游标字段——它会被更新,导致同一条记录在不同时间点生成不同游标,翻页错乱。优先选 created_at + 主键,或自增 ID 单字段(前提是业务允许按 ID 排序)。











