MySQL 并发插入竞态问题:原子写入实践指南
MySQL 并发插入竞态问题原子写入实践指南在涉及唯一约束的业务场景中比如用户注册、唯一编号数据写入“先查询是否存在不存在再插入”是很容易想到的直觉写法。单测与低并发下通常表现正常但随着请求量上升日志中会不定期出现唯一键冲突报错很多开发者对此感到困惑——明明已经做了存在性判断为什么还会重复写入问题的根源在于分步的查询与插入并非原子操作在并发环境下天然存在竞态条件。本文将拆解这一问题的成因并介绍基于数据库原生能力的标准解决方案。一、“先查后插”的并发漏洞以用户表的邮箱唯一约束为例典型的分步写代码逻辑如下// 检查邮箱是否已注册varcountint64db.Model(model.User{}).Where(email ?,email).Count(count)ifcount0{// 不存在则创建用户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。INSERTIGNOREINTOusers(email,name,password)VALUES(aexample.com,张三,encrypted_pwd);无冲突正常写入受影响行数为 1有冲突静默跳过无报错受影响行数为 0该方案适用于幂等性写入场景例如批量数据导入、初始化数据去重重复数据无需处理仅需通过受影响行数判断是否实际写入即可。其最大价值是避免将正常业务冲突转化为系统错误减少无效错误日志。2. ON DUPLICATE KEY UPDATE冲突则执行更新若冲突时需要更新部分字段可使用该语法即常说的 Upsert更新或插入。当唯一键命中时执行后续的 UPDATE 逻辑未命中则正常插入。INSERTINTOusers(email,name,last_login_time)VALUES(aexample.com,张三,NOW())ONDUPLICATEKEYUPDATEnameVALUES(name),last_login_timeVALUES(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)ifres.Error!nil{returnfalse,res.Error}returnres.RowsAffected0,nil}调用方通过返回布尔值判断是否实际创建成功错误分支仅处理真正的系统异常。冲突更新模式对应ON DUPLICATE KEY UPDATE语义指定冲突时需要更新的字段func(r*userRepo)Upsert(ctx context.Context,user*model.User)error{returnr.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。唯一索引是基础前提。冲突处理语法依赖唯一索引生效建表时约束必须完整这是数据一致性的最后防线。本质上这是一个很典型的工程设计原则能通过底层组件原子能力解决的问题就不要在上层通过分步逻辑模拟。数据库本身已经提供了成熟的并发写入处理机制善用这些特性既能减少代码复杂度也能获得更可靠的并发表现。