GORM 一条 map 批量插入也越界,recover 后记录可能已提交
GORM 1.31.2 的 map 批处理会把 RETURNING 结果追加后越界。改用明确模型切片,核对主键与批次输入;recover 不代表已经回滚。
PostgreSQL 批量插入只传了一条 map,批大小设成 500,GORM 却报 reflect: slice index out of range。更棘手的是,程序 recover 后再查表,那一条记录可能已经写入。
这条路径在 GORM 1.31.2、PostgreSQL 驱动 1.6.3 上具备明确条件:通过 Model(&User{}) 指定模型,向 CreateInBatches 传 map 切片,模型含数据库生成的主键,INSERT 自动追加 RETURNING "id"。不是只有切成多个批次才会触发。
RETURNING 扩大了本应回填的切片
例如下面这种调用形状,其中 User 是与 users 表对应的现有模型:
rows := []map[string]interface{}{
{"Name": "alice", "Age": 18},
}
err := db.Model(&User{}).CreateInBatches(rows, 500).Error
1.31.2 的批处理构造了可寻址的批次切片,底层数组与输入共享。map 切片的扫描分支却将 RETURNING 结果直接 append,而不是按更新模式回填已有 map。随后复制结果时,循环使用了扩大的长度,可能索引到原输入范围之外。
底层数组有剩余容量时,还可能先把下一批输入覆盖成只有返回主键的 map,后续插入因此出现重复主键或其他错误。只看最后一个 SQL 错误,容易漏掉前一批已经改坏了输入。
将 rows 改成 &rows,或改用 CreateBatchSize 配合 Create,并没有避开这一套扫描和回填路径。
用明确的模型切片保留正常回填
字段结构已知时,可改用模型切片。下面定义的是示意 User,实际合入应使用项目已有模型,并保留其表名、字段、标签和 hook:
type User struct {
ID uint
Name string
Age int
}
users := []User{
{Name: "alice", Age: 18},
}
err := db.CreateInBatches(&users, 500).Error
从 map 转换前应验证允许的字段、值类型和必填约束,而非遇到无法转换的值就填零。map 创建与模型创建在 hook、关联和主键回填上有差别;应用曾有意绕过 hook 或保存关联时,必须核对这些行为,而不是仅把容器类型改掉。
不要为了避开 RETURNING 就关掉事务或随意删掉主键返回。主键可能被后续记录引用,失去回填会把故障移到下一步。采用包含后续修正的正式版本时,应同时验证 map 输入、返回字段和批次边界,不能只凭不再 panic 判断已正确。
recover 不等于数据库已回滚
只有一个批次时,内部创建回调的事务可能已经提交,异常才发生在外层复制结果阶段。因而 recover 只是接住 Go 的 panic,不能撤销此前的提交。
发生过这类失败后,先按业务唯一标识核对已经存在的记录,再决定补偿、跳过还是重新提交。不要直接重跑整个批次;给请求增加一个新的随机身份,也无法识别先前已成功的写入。
验收至少包含单条、多个批次、有剩余容量的输入切片,以及数据库真实唯一约束失败。检查输入字段、写入内容、主键、长度和错误时的事务结果;主键正常返回但第二批内容被覆盖,同样不能算通过。保留既有幂等和恢复约定,避免把批处理异常处理成成功响应。


