在并发场景下,“先查后插”会存在竞态条件问题,因非原子操作导致唯一键冲突。解决方案是将判断与写入合并为一条SQL语句,使用插入忽略或重复键更新语句,依赖唯一索引的锁机制保证原子性,从而彻底消除并发窗口。
MySQL并发插入竞态问题:原子写入实战指南
时间:2026-07-20
浏览:106
文章目录
在实际开发中,涉及唯一约束的业务场景,比如用户注册、唯一编号数据写入等需求,许多开发者首先想到的写法是“先查询是否存在,不存在再插入”。这在绝大多数具有一定编程基础的开发者看来,几乎是理所当然的操作。

单元测试通过,低并发环境下运行正常。然而,一旦请求量上升,日志中便会频繁出现“唯一键冲突”报错。此时很多人感到困惑:明明已经判断过记录不存在,为什么还是写入失败?
问题的根源在于:分步执行的查询和插入并非原子操作。在并发环境下,“先查后插”的流程天然存在竞态条件。下面我们将拆解问题,并给出基于数据库原生能力的标准解决方案。
一、“先查后插”的并发漏洞
以用户表的邮箱唯一约束为例。典型的分步代码实现如下:
// 检查邮箱是否已注册var count int64db.Model(&model.User{}).Where("email = ", email).Count(&count)if count == 0 { // 不存在则创建用户 db.Create(&newUser)}
单线程执行时,这段代码没有毛病。但假如两个相同邮箱的请求几乎同时到达,执行时序会交叉:
时间轴:T1 请求A执行SELECT -> 结果为0,判定邮箱未注册T2 请求B执行SELECT -> 结果为0,同样判定邮箱未注册T3 请求A执行INSERT -> 写入成功T4 请求B执行INSERT -> 触发唯一索引约束,返回 1062 Duplicate entry 错误
两个请求都在对方完成写入前完成了查询,都得出了“数据不存在”的结论,后写入的那一个必然触发冲突。
更值得注意的是,即使将这两步放在同一个数据库事务中,问题依然存在。在InnoDB默认的可重复读隔离级别下,普通SELECT是快照读,不会为不存在的行加锁,因此无法阻止其他事务的并发写入。事务只能保证自身操作的原子性,但无法消除分步判断留下的时间窗口。
二、基于数据库原生能力的原子方案
解决问题的核心思路,是将“判断+写入”合并为单条SQL操作,借助数据库唯一索引的锁机制确保执行原子性。MySQL提供了两种标准语法,分别对应不同的业务场景。
1. INSERT IGNORE:冲突时静默跳过
在INSERT语句中添加IGNORE关键字,一旦遇到唯一键冲突,数据库不会抛出错误,而是直接忽略本次写入,返回受影响行数为0。
INSERT IGNORE INTO users (email, name, password) VALUES ('a@example.com', '张三', 'encrypted_pwd');
- 无冲突:正常写入,受影响行数为1
- 有冲突:静默跳过,无报错,受影响行数为0
该方案适用于幂等性写入场景,例如批量数据导入、初始化数据去重。重复数据无需额外处理,通过受影响行数即可判断实际写入情况。其最大优势是避免将正常业务冲突转化为系统错误,减少无效的错误日志。
2. ON DUPLICATE KEY UPDATE:冲突时执行更新
若冲突时需要更新部分字段,则使用此语法——即常说的Upsert(更新或插入)。唯一键命中时,执行后面的UPDATE逻辑;未命中时正常插入。
INSERT INTO users (email, name, last_login_time) VALUES ('a@example.com', '张三', NOW())ON DUPLICATE KEY UPDATE name = VALUES(name), last_login_time = VALUES(last_login_time);
- 无冲突:执行插入,受影响行数为1
- 有冲突:执行更新,受影响行数为2
典型场景包括用户登录态刷新、配置项写入——数据不存在则创建,存在则刷新。整条语句在数据库层面原子执行,没有任何并发窗口。
三、GORM中的工程化实现
GORM提供了clause.OnConflict原生支持上述两种冲突处理逻辑,无需手动拼接原生SQL。
冲突忽略模式
对应INSERT IGNORE语义,适用于注册、幂等写入场景:
func (r *userRepo) CreateIfNotExist(ctx context.Context, user *model.User) (bool, error) { res := r.db.WithContext(ctx). Clause(clause.OnConflict{DoNothing: true}). Create(user) if res.Error != nil { return false, res.Error } return res.RowsAffected > 0, nil}
调用方通过返回的布尔值判断实际是否创建成功,错误分支仅处理真正的系统异常。
冲突更新模式
对应ON DUPLICATE KEY UPDATE语义,指定冲突时需要更新的字段:
func (r *userRepo) Upsert(ctx context.Context, user *model.User) error { return r.db.WithContext(ctx). Clause(clause.OnConflict{ Columns: []clause.Column{{Name: "email"}}, DoUpdates: clause.AssignmentColumns([]string{"name", "avatar", "updated_at"}), }). Create(user).Error}
Columns指定用于判断冲突的唯一键列,DoUpdates指定冲突发生后需要更新的字段列表。语义清晰,与数据库特性对齐。
误区:SELECT FOR UPDATE并非解法
部分开发者考虑在事务中使用SELECT ... FOR UPDATE加行锁以消除竞态,但这条路行不通——行锁的前提是数据行存在。
当查询的目标数据尚未写入时,查询结果为空,没有实际的行可施加行锁。两个并发事务均会查到空结果,然后都认为自己可以安全写入,最终仍然触发唯一键冲突。
间隙锁在部分场景下的确会对写入产生限制,但其生效条件与隔离级别、索引类型、查询范围高度相关,依赖间隙锁来保证写入安全并不稳定。相比之下,直接使用数据库原生的冲突处理语法,是成本最低、可靠性最高的方案。
实践总结
- 避免分步判断。只要存在并发可能,先查后插就必然存在冲突概率,不应作为生产环境的首选方案。
- 只需去重、无需更新:选用
INSERT IGNORE/OnConflict{DoNothing: true},通过受影响行数区分写入结果。 - 存在即需更新字段:选用
ON DUPLICATE KEY UPDATE/OnConflict{DoUpdates},实现原子Upsert。 - 唯一索引是基础前提。冲突处理语法依赖唯一索引才能生效,建表时约束必须完整——这是数据一致性的最后防线。
本质上,这是一个经典的工程设计原则:能通过底层组件的原子能力解决的问题,就不应在上层通过分步逻辑模拟。数据库本身已提供成熟的并发写入处理机制,善用这些特性,既能降低代码复杂度,也能获得更可靠的并发表现。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。