绝不能用 limit+offset 分页写入——因为 offset 语义是跳过前 n 行物理记录,适用于读取存量数据,而爬虫清洗处理的是持续流入的新数据流,应基于可控切片与唯一性约束校验分批写入。

爬虫数据清洗阶段用 GORM 做分批写入,不是“能不能分页”,而是“绝不能用 Limit+Offset 分页写入”——因为写入过程本身不依赖页码,而依赖数据流的可控切片与唯一性约束校验。
为什么不能用 Offset 分页做批量写入
Offset 是为“读取已存在数据”设计的语义,本质是跳过前 N 行物理记录;而爬虫清洗是持续流入的新数据,不存在“第几页”的概念。若强行用 Offset(100).Limit(20) 拆分一个切片 data,实际只是取 data[100:120],和数据库无关,反而误导逻辑。
- 误用示例:
for i := 0; i —— <code>Offset在Create中完全无效,GORM 会忽略它,或 panic - 真正需要的是按长度切片 + 批量
CreateInBatches,不是模拟分页 - 去重必须靠数据库唯一索引(如
UNIQUE INDEX ON (url, source))或ON CONFLICT DO NOTHING(PostgreSQL)/INSERT IGNORE(MySQL),不能靠应用层“查再判”
安全的分批写入:用 CreateInBatches 而非手撕 Offset
GORM v2 提供了原生批量写入支持,它自动拆解、事务封装、错误定位清晰,且不触发 SELECT 查询,适合清洗阶段高吞吐写入。
- 必须显式指定
batchSize,建议 100–500(太大易 OOM 或锁表,太小性能差) - 调用前确保结构体字段已设好默认值(如
CreatedAt),否则批量插入时可能为零值 - 如果某批失败,GORM 默认停止后续批次;需继续可捕获 error 后手动跳过该批,但更推荐先
db.Session(&gorm.Session{SkipHooks: true})避免钩子干扰 - 示例:
db.CreateInBatches(&cleanedData, 200)—— 自动按 200 条一批提交,无需手算 offset
去重必须由数据库层兜底,不能靠 Go 层“查重再插”
爬虫数据并发写入频繁,应用层先 First 再 Create 必然产生竞态:两个协程同时查不到记录,然后都插入成功。唯一可靠路径是让数据库执行原子判断。
- MySQL:建
UNIQUE KEY (url, domain),写入时用db.Clauses(clause.OnConflict{DoNothing: true}).Create(&item) - PostgreSQL:同样用
OnConflict,支持更细粒度(如DoUpdate更新last_seen字段) - SQLite:不支持
ON CONFLICT的完整语法,需改用REPLACE INTO或提前建UNIQUE索引 + 捕获ErrDuplicatedKey错误后忽略 - 切忌在循环里对每条数据都
Where("url = ?").First()—— I/O 放大百倍,且无法真正去重
清洗阶段要不要 COUNT 总数?基本不要
爬虫数据是流式到达、持续清洗的,所谓“总条数”没有业务意义;即使要监控,也应统计入库成功数(通过 CreateInBatches 返回的 RowsAffected),而非查 COUNT(*)。
-
db.Model(&Item{}).Count(&total)在百万级表上会变慢,且结果滞后——刚写入的还没刷盘就查,总数不准 - 若真需进度反馈(如 Celery 风格任务),可用 Redis INCR 记录已处理 URL 数,比查 DB 快且实时
- 日志中打印每批
RowsAffected即可:例如 “batch #3: inserted 197 / 200 (3 dup ignored)”
真正容易被忽略的点是:清洗阶段的“去重”和“分批”本质是两个正交问题——分批只管吞吐节奏,去重只管数据一致性;把它们混在一起想(比如“第 X 页去重后写入”)只会引入不必要的复杂度和 bug。数据库唯一索引 + CreateInBatches + 显式错误分类,就是最轻量也最可靠的组合。











