跨平台迁移禁用limit+offset分页,因mysql/postgresql/sqlite对order by稳定性、null处理、时区及排序规则不一致,易致漏数或重复;必须改用基于主键的游标分页,绑定显式排序方向,并通过源库独立count和落库checkpoint保障一致性。

跨平台数据迁移场景下,GORM 的 Limit + Offset 分页极易导致漏数据或重复迁移,根本原因是不同数据库对 ORDER BY 稳定性、NULL 处理、时区/字符集排序规则不一致——不是“能跑”,而是“跑错”。
为什么跨平台迁移不能直接用 Offset 分页
迁移过程常需从 MySQL 拉数据写入 PostgreSQL 或 SQLite,而各数据库对无索引字段排序、NULLS FIRST/LAST 默认行为、甚至 utf8mb4 vs UTF-8 排序权重都不同。比如:
-
ORDER BY created_at DESC在 MySQL 中NULL排最后,在 PostgreSQL 中默认排最前,同一查询在两库中分页边界可能错位 - MySQL 的
OFFSET 10000实际扫描行数 ≈ 10000 + 20,PostgreSQL 可能因 MVCC 快照机制多扫数万行,迁移卡顿不可控 - SQLite 不支持
OFFSET超过约 100 万行(32 位整数限制),迁移中途 panic
GORM 游标分页必须绑定主键 + 显式排序方向
游标分页绕过 OFFSET,靠上一批最后一条记录的排序字段值续查,天然规避跨库偏移偏差。但 GORM 不自动推导游标条件,必须手写:
- 排序字段必须是**有索引的非空主键或时间戳**(如
id或created_at),且类型在源/目标库中语义一致 - WHERE 条件要显式带方向:迁移第一页用
db.Order("id ASC").First(&first),第二页起必须用db.Where("id > ?", lastID).Order("id ASC").Limit(pageSize) - 避免用
created_at当游标字段——若源库时区为+08:00、目标库为UTC,同毫秒级时间戳可能被解析为不同秒数 - 复合游标慎用:如
ORDER BY status, id,需确保两库对status字符串排序规则完全一致(例如 collation 都是utf8mb4_0900_as_cs)
总数统计必须在源库单独执行,且禁用链式复用
迁移任务常需预估进度(如 “共需迁移 128476 条”),但跨平台时不能依赖目标库 COUNT(*),必须查源库。关键陷阱在于:
- 别在同一个
*gorm.DB实例上连写Where().Count().Limit().Find()——GORM v2 的链式调用会残留LIMIT到COUNT查询,返回错误总数 - 源库 COUNT 必须用独立 DB 实例:例如
srcDB.Model(&User{}).Where("processed = ?", false).Count(&total),不能复用已加Order或Where的迁移查询实例 - MySQL 的
SELECT COUNT(*)在大表上可能锁表,PostgreSQL 的pg_class.reltuples估算值误差可达 ±10%,生产迁移建议用采样估算(如TABLESAMPLE SYSTEM (0.1))
迁移中间状态必须落库,不能只靠内存游标
跨平台迁移常持续数小时,进程崩溃后需从断点续迁。仅靠上一批 lastID 变量不可靠:
- 内存游标在崩溃后丢失,重试可能重复写或跳过数据
- 应在源库建一张
migration_checkpoint表,字段含task_id、last_cursor_value、updated_at,每次成功写入一批后UPDATE该记录 - 游标值必须和排序字段类型严格一致:若排序字段是
INT UNSIGNED,last_cursor_value也必须是uint64,避免负数溢出(如 MySQLTINYINT迁移到 PostgreSQLSMALLINT) - 不要用
time.Now().UnixMilli()当游标——系统时钟回拨会导致迁移卡死,改用数据库服务端时间(如srcDB.Raw("SELECT UNIX_TIMESTAMP(NOW(3))").Scan(&ts))
跨平台迁移的分页本质是状态同步问题,不是 SQL 技巧问题。游标值、排序一致性、中断恢复这三点任一缺失,都可能导致数据不一致,且这种错误往往静默发生,上线后才暴露。











