GORM v1.20+ 的 clause.OnConflict 是最可靠、原子性最强的 Upsert 实现方式,但必须满足数据库表存在唯一约束(主键或唯一索引)这一硬性前提;Columns 需对应真实约束字段,DoUpdates 字段名须为结构体字段名(非列名),RowsAffected 行为因驱动而异,不可跨库通用判断插入/更新,且需配合应用层幂等 Key 防重放。

直接结论:GORM v1.20+ 的 clause.OnConflict 是目前最可靠、原子性最强的 Upsert 实现方式,但必须满足唯一约束前提,且字段名、驱动行为、更新逻辑稍有不慎就会静默失效或覆盖错误字段。
OnConflict 要求数据库表存在唯一约束
这是硬性前提,不是可选项。GORM 的 OnConflict 不会帮你建索引,它只是翻译成 ON CONFLICT (col) 或 ON DUPLICATE KEY UPDATE 这类原生 SQL,而这些语法底层依赖数据库的唯一键(PRIMARY KEY 或 UNIQUE INDEX)来触发冲突检测。
- 如果你只在 struct tag 里写了
gorm:"unique"却没执行AutoMigrate,或者迁移时出错被忽略,OnConflict会退化为普通INSERT,遇到重复数据直接报duplicate key violation错误 - 联合唯一约束也支持,比如订单表按
user_id + order_type + date去重,那就得在Columns里写[]clause.Column{{Name: "user_id"}, {Name: "order_type"}, {Name: "date"}},且 DB 表中该联合索引必须真实存在 - SQLite 需要额外启用
sqlite3扩展(如ENABLE_FTS5)并使用gorm.io/driver/sqlitev1.5.0+,否则ON CONFLICT会被忽略
DoUpdates 字段名必须与结构体字段名一致(非数据库列名)
GORM 在构建 SET 子句时,用的是 Go struct 字段名(经 snake_case 映射后),不是你手动写的数据库列名。一旦写错,就等于没更新——而且不报错、不提示。
- 假设结构体字段是
UpdatedAt time.Time `gorm:"column:updated_at"`,你在DoUpdates里写"updated_at"是无效的,必须写"UpdatedAt" - 想只更新部分字段(比如不覆盖
Email),就明确列出:DoUpdates: clause.AssignmentColumns([]string{"Name", "Age", "UpdatedAt"}) - 如果误写成
UpdateAll: true,它会把所有非零值字段都塞进SET,包括你不希望覆盖的默认值字段(比如Status原本是"pending",但 struct 初始化时没赋值,就会被设成空字符串)
RowsAffected 返回值不可跨数据库通用判断“是否插入”
result.RowsAffected 的含义因驱动而异,不能当成布尔开关用。
- PostgreSQL:冲突更新返回
1,新插入也返回1(因为DO UPDATE视为影响一行) - MySQL:新插入返回
1,冲突更新返回2(INSERT + UPDATE 各算一行) - SQL Server:MERGE 语句返回实际受影响行数,但可能为
0(比如WHERE条件不匹配) - 真正安全的做法是:业务上需要区分场景时,先用
First查一次,或在 Upsert 后再Where("id = ?", user.ID).First(&u)拉取当前状态,而不是依赖RowsAffected
并发下仍需配合应用层幂等 Key
数据库级 Upsert 解决的是「单次请求写入的原子性」,但挡不住客户端重试、网关超时重发、消息队列重复投递这类问题。
- 仅靠
OnConflict无法识别两次内容完全相同的请求(比如两次提交order_id="ORD-001"),它只会照常更新,不会告诉你“这其实是重复” - 必须在 HTTP 层或消息消费层生成幂等 Key(如
md5(user_id + order_id + timestamp + nonce)),用 Redis 记录该 Key 是否已成功落库,并设置过期时间(建议 ≥ 业务最长处理链路耗时) - Redis 写入必须和 DB 写入构成原子操作:要么用 Redis Lua 脚本保证 SETNX + EXPIRE 一起执行,要么在 DB 事务提交成功后再异步写 Redis(但得加补偿机制,防 Redis 失败导致下次请求误判)
最容易被忽略的一点:OnConflict 的 Where 子句(用于限制更新范围)和 OnConstraint(指定具体唯一约束名)极少被用到,但一旦业务要求“仅当旧状态为 pending 时才允许更新为 paid”,就必须靠 Where 控制更新条件,否则会绕过业务规则强行覆盖。


















