不能直接用GORM的Find+CreateInBatches做“分页写入”,因为LIMIT OFFSET在大数据量下需全表扫描前N行,性能断崖式下跌;必须改用基于有索引单调字段(如id)的游标分页(WHERE id > ?),并分批次加事务与行锁确保一致性。

为什么不能直接用 GORM 的 Find + CreateInBatches 做“分页写入”
因为 CreateInBatches 是写入端的批量控制,而“分页查询 + 分批写入”本质是读写双阶段任务:先查一批(比如 offset/limit),再写一批。但 MySQL 的 LIMIT 10000, 100 在数据量大时会全表扫描前 10000 行,性能断崖式下跌;PostgreSQL 同理。这不是 GORM 的锅,是 SQL 分页模型本身的缺陷。
常见错误现象:panic: runtime error: index out of range(因 Scan 到空结果却未检查 rows.Err())、写入重复主键、下游消息队列堆积超时。
- 必须用游标分页(cursor-based pagination),而非 offset/limit
- 游标字段必须是**有索引的单调递增字段**(如
id或created_at),且查询条件中要带上WHERE id > ? - 不要在事务里跨多批次读写——GORM 的
Session不自动复用连接,长事务会卡死连接池
如何用 GORM + SELECT ... FOR UPDATE 安全地分批消费数据库
适用于“从旧表迁移数据到新表+发 MQ 消息”的场景,要求强一致性、不漏不重。关键不是 GORM 多厉害,而是你敢不敢加行锁、敢不敢控制事务边界。
- 每次只查 100 条:
db.Where("id > ?", lastID).Order("id ASC").Limit(100).Find(&records) - 查完立刻用
db.Session(&gorm.Session{NewDB: true}).Transaction(...)开新事务,对这批记录逐条SELECT ... FOR UPDATE再更新状态字段(如processed_at) - 更新成功后,才调用消息队列客户端(如
sarama.SyncProducer)发送消息;失败则 rollback,下次从 samelastID重试 - 避免用
db.Transaction包整个批次——一旦第 99 条失败,前 98 条的锁就白持有了
CreateInBatches 和手动循环 Create 的吞吐差异在哪
差距主要来自预处理语句复用和网络往返次数。CreateInBatches 底层生成一条 INSERT INTO ... VALUES (...), (...), (...),而循环 Create 是 N 条独立 INSERT。但在消息队列写入场景下,这个差异常被掩盖——真正瓶颈是 MQ 生产者序列化、网络 IO、Broker 确认延迟。
- 当批次大小 > 1000,
CreateInBatches可能触发 MySQL 的max_allowed_packet报错,错误信息类似:ERROR 1153 (08S01): Got a packet bigger than 'max_allowed_packet' bytes - 建议批次设为 100~500,配合
WithContext(ctx).Debug()观察生成的 SQL 长度 - 如果目标表有唯一索引或外键约束,
CreateInBatches失败时整批回滚,不如单条Create+ 重试策略灵活
消息体结构怎么设计才能让下游消费不翻车
别传 GORM model 实例,也别传原始 map[string]interface{}。下游消费者不关心你用没用泛型、有没有 json:"user_id" tag,它只认稳定字段名和类型。
- 定义显式 DTO 结构体,如
type UserMigrationEvent struct { ID uint64 `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` } - 用
db.Raw("SELECT id, name, created_at FROM users WHERE ...").Scan(&dtoList)直接映射,绕过 GORM model 的零值陷阱(比如bool字段为false时无法区分是 DB NULL 还是真实 false) - 消息里必须带版本号字段(如
"version": "v1"),下游按 version 分流处理逻辑,避免某次 model 字段改名导致全量消息解析 panic
最易被忽略的一点:游标分页的初始 lastID 如果设为 0,而表里最小 id 是 1,那第一轮就查不到数据——得先 SELECT MIN(id) FROM table 获取起点,且要考虑该表是否真有数据。


















