分页读取不能用于数据同步,因offset分页无法保证不漏、不重、可续;必须改用游标分页,基于id或updated_at等稳定字段分页,配合limit(n+1)判断下一页,并拆分preload以保障批量加载和断点续传。

分页读取不是为同步设计的,硬用 Limit+Offset 做数据同步会丢数据、重复、性能崩塌——这不是 GORM 的问题,是分页语义和同步需求根本冲突。
为什么 Offset 分页不能用于数据同步
同步要求“不漏、不重、可续”,而 Offset 是基于“跳过前 N 行”的位置偏移,它不绑定任何业务状态。只要源表在分页过程中有 INSERT/DELETE,后续页就可能跳过新插入记录,或重复拉取已被删除后又插入的 ID。
- 常见错误现象:
Offset(10000).Limit(100)拉完第 100 页后,上游新增了 50 条记录,下一轮从Offset(10100)开始,这 50 条就永远丢失 - 即使加了
Order("id ASC"),也只保证单次查询有序,无法抵抗并发写入导致的“中间插入” - MySQL/PostgreSQL 对大
Offset的实现是真实扫描并丢弃前 N 行,同步任务越往后越慢,10 万偏移后常超 2s
同步必须用游标分页(Cursor-based Pagination)
游标分页靠上一批最后一条记录的稳定字段值(如 id 或 updated_at)驱动下一页,天然支持断点续传,且性能恒定。
- 首次请求:
db.Order("id ASC").Limit(100).Find(&items) - 后续请求:取
items[len(items)-1].ID作为游标,查db.Where("id > ?", lastID).Order("id ASC").Limit(100).Find(&items) - 时间戳场景更常用:
db.Where("updated_at > ?", lastUpdatedAt).Order("updated_at ASC, id ASC").Limit(100).Find(&items),注意必须加id二级排序防时间重复 - 确保
ORDER BY字段有索引,否则游标查询也会变慢
Count 查询在同步中通常多余且危险
同步任务不需要总条数,要的是“还有没有下一页”。强行查 Count 不仅拖慢速度,还可能因查询间隙被写入干扰,导致总数和实际拉取不一致。
- 正确做法:每轮查
Limit(n + 1),如果返回n + 1条,说明还有下一页;只取前n条入库,把第n + 1条的游标存下来 - 避免
db.Count(),尤其不要复用带Where条件的同一*gorm.DB实例——它会继承前面的Limit/Offset,结果永远 ≤ n - 如果必须知道剩余量(如进度条),改用时间范围估算,例如统计
WHERE updated_at BETWEEN ? AND ?的粗略行数
Preload 关联数据时必须拆开两步
同步过程中的关联加载不能依赖 Preload,它会在主查询后发起 N+1 或 IN 查询,游标逻辑会被破坏,且无法控制关联数据的同步顺序。
- 第一步:用游标分页查出主表 ID 列表(例如
[]uint64) - 第二步:用
db.Where("user_id IN ?", ids).Find(&orders)批量加载关联数据,再按user_id归组 - 别在游标查询里写
Preload("Orders")——GORM 会先查 100 个用户,再为每个用户发一次SELECT ... WHERE user_id = ?,完全失去批量优势 - 如果关联表也要同步,对它单独跑一套游标分页,用外键字段(如
user_id)做游标
游标分页的麻烦在于前端或调度器要维护 last_id/last_updated_at,但这是同步的刚性成本。想省这事,不如直接用 CDC 工具(如 Debezium)替代手写同步逻辑。











