游标分页是数据同步管道中避免漏同步、重复同步的刚性要求,必须弃用OFFSET;其核心是用last_id等构建可续接查询边界,依赖单调排序字段与索引,GORM中需手动控制Order、精准提取游标且禁用Preload。

游标分页不是“换种写法”,而是数据同步管道里避免漏同步、重复同步的刚性要求——用 OFFSET 在同步场景下基本等于埋雷。
为什么同步管道必须弃用 Offset 分页
数据同步是持续、增量、不可逆的过程,而 OFFSET 依赖“物理行序”:它跳过前 N 行,但插入/删除会动态改变行位置。哪怕只有一条记录在分页间隙被写入,OFFSET 10000 下一页就可能跳过它;若被删除,同一条记录又可能在下轮重复出现。
- 同步任务常运行数小时甚至数天,期间源表持续写入,
OFFSET的“快照语义”完全失效 - MySQL/PostgreSQL 执行大
OFFSET时真实扫描并丢弃前 N 行,CPU 和 I/O 压力随偏移线性增长,容易触发超时或连接中断 - 同步失败重试时,无法保证从断点精确续跑——
OFFSET没有锚点,只有页码,页码本身已失真
游标分页的核心:用 last_id 或 last_updated 构建可续接的查询边界
游标分页本质是“基于已知最后一条记录,向后取下一批”。它不关心页码,只依赖排序字段的单调性和索引支持。
- 首选排序字段:
id ASC(自增主键),稳定、唯一、有索引,SQL 简洁:WHERE id > ? ORDER BY id LIMIT 20 - 时间敏感场景(如日志、事件流)用:
created_at DESC, id DESC,避免毫秒级时间重复导致顺序不确定 - 前端或下游服务必须透传
last_id(或last_created_at+last_id组合),后端不做任何转换,直接拼进WHERE - 首次同步查第一页:不带
WHERE,只ORDER BY ... LIMIT N;后续所有请求都带游标条件
GORM 中手写游标分页的关键细节
GORM 不提供原生游标分页方法,需手动控制链式调用,稍有疏忽就会漏数据或死循环。
- 必须显式调用
.Order("id ASC")(或对应排序),缺了这句,WHERE id > ?返回结果无序,游标失效 -
last_id必须来自上一页最后一条记录的id字段值,不能取错字段,也不能用MAX(id)这类聚合——它可能跳过中间空洞 - 查完当前页后,要安全提取游标:
if len(users) > 0 { cursor = users[len(users)-1].ID },空切片时不能继续传游标 - 别在游标查询中混用
Preload:先查主表 ID 列表,再用IN批量加载关联数据,否则游标逻辑被破坏 - 确保
id(或created_at)字段有索引,联合查询时把 WHERE 和 ORDER 字段放进同一联合索引,避免回表
总数统计在同步管道里通常该被砍掉
同步是流式过程,总数既难算准(源表持续写入),又无业务意义(你不需要“还剩多少没同步完”,只需要“下一批从哪开始”)。
- 删掉
Count()查询,省去一次全表或子查询扫描,降低源库压力 - 用“是否查满一页”判断是否还有下一页:查
LIMIT N+1,如果返回 N+1 条,说明还有数据,去掉最后一条后返回,并把第 N 条的id作为下一轮游标 - 若业务强依赖进度(如迁移仪表盘),改用时间戳水位(
MAX(created_at))代替行数,更稳定且可对齐业务语义
真正卡住同步管道的,往往不是代码没写对,而是把游标当成“另一种分页方式”来用——它其实是状态机:每一页输出必须精确成为下一页输入的起点,少一个字段校验、多一次错误赋值,整个同步链就脱节了。


















