GORM在分库分表下Offset分页必然失效,因其仅向单个物理库发送Limit/Offset语句,无法感知全局数据分布,导致重复、跳号、漏页;唯一可靠方案是应用层实现基于排序字段的游标分页,并并发查询各分片后全局合并排序。

分库分表后用 GORM 做分页,PageHelper 那套根本不能直接搬;GORM 本身不感知分片,Limit/Offset 会发到单个物理库,结果错得离谱——不是少数据,就是重复、跳号、漏页。
为什么 GORM 的 Offset 分页在分库分表下必然失效
分库分表后,数据按 shard_key(比如 user_id)分散到多个库/表,每个库只存局部有序数据。你调 db.Offset(1000).Limit(10),GORM 会把这条语句原样发给它当前连着的那个 *sql.DB 实例——而那个实例只管自己那一片,完全不知道全局排序位置。
常见错误现象:
- 第 100 页返回空结果,但其实数据还在别的库
- 同一页反复出现相同 ID,不同页之间 ID 跳变无规律
- 用
Count()得到总数,再按这个总数算Offset,结果越往后偏差越大
替代方案:游标分页(cursor-based pagination)是唯一靠谱选择
放弃 OFFSET,改用基于排序字段(如 created_at、id)的不等式条件 + 限流,才能保证跨分片一致性。
实操要点:
- 排序字段必须是分片键或其强相关字段(例如
user_id+created_at复合),否则无法对齐各分片的“时间线” - 查询时先并发请求所有分片:
SELECT * FROM t_order WHERE created_at > ? ORDER BY created_at ASC LIMIT 11(多取 1 条用于下一页游标) - Go 层合并结果,按
created_at全局排序,截取前 10 条,最后一条的created_at(+id防重)作为下次请求的cursor - 注意时区:连接参数必须带
parseTime=true&loc=UTC,否则各分片时间比较失准
GORM 里怎么写游标分页逻辑
别指望 Scopes 或插件自动帮你做跨库合并;必须在 DAO 层手动控制路由和聚合。一个最小可行模式:
// 假设已知分片数为 4,shard_key = user_id
func (r *OrderRepo) ListByCursor(ctx context.Context, cursor *OrderCursor, limit int) ([]*Order, *OrderCursor, error) {
var all []Order
var wg sync.WaitGroup
ch := make(chan []Order, 4)
<pre class="brush:php;toolbar:false;">for i := 0; i < 4; i++ {
wg.Add(1)
go func(shardIdx int) {
defer wg.Done()
db := r.getShardDB(shardIdx) // 根据 shardIdx 拿对应 *gorm.DB
var list []Order
cond := db.Where("created_at > ?", cursor.CreatedAt)
if cursor.ID > 0 {
cond = cond.Where("created_at = ? AND id > ?", cursor.CreatedAt, cursor.ID)
}
cond.Order("created_at ASC, id ASC").Limit(limit + 1).Find(&list)
ch <- list
}(i)
}
wg.Wait()
close(ch)
for list := range ch {
all = append(all, list...)
}
// 全局排序并截断
sort.Slice(all, func(i, j int) bool {
if all[i].CreatedAt != all[j].CreatedAt {
return all[i].CreatedAt < all[j].CreatedAt
}
return all[i].ID < all[j].ID
})
nextCursor := (*OrderCursor)(nil)
if len(all) > limit {
nextCursor = &OrderCursor{
CreatedAt: all[limit].CreatedAt,
ID: all[limit].ID,
}
}
return all[:min(len(all), limit)], nextCursor, nil}
关键细节:
- 每分片查
limit + 1条,是为了确保至少能凑够limit条全局结果,并拿到下一页游标 - 不能依赖 GORM 的
Count()获取总数——跨分片 COUNT 成本高且无业务意义 - 如果排序字段有大量重复值(如秒级
created_at),务必加入id作为二级排序,否则游标不可靠
别碰“二次查询法”这类模拟 OFFSET 的方案
网上流传的“把 OFFSET 1000 拆成各库查 OFFSET 250”,看似省事,实际漏洞明显:各分片数据分布不均时,t_order_1 可能有 999 条满足条件的数据,t_order_2 只有 1 条——那 OFFSET 250 在 _2 上就直接越界,结果缺失。
真正要落地,必须接受一个事实:分库分表后的分页,本质是应用层聚合问题,不是 SQL 语法问题。GORM 只负责单库操作,跨库逻辑必须由你亲手缝合——包括错误重试、超时控制、结果去重(如有必要)、以及最关键的:游标生成与校验逻辑。漏掉任意一环,线上翻车只是时间问题。


















