MySQL 并发插入竞态问题:原子写入实践指南

MySQL 并发插入竞态问题:原子写入实践指南

在涉及唯一约束的业务场景中,比如用户注册、唯一编号数据写入,"先查询是否存在,不存在再插入"是很容易想到的直觉写法。

单测与低并发下通常表现正常,但随着请求量上升,日志中会不定期出现唯一键冲突报错,很多开发者对此感到困惑------明明已经做了存在性判断,为什么还会重复写入?

问题的根源在于,分步的查询与插入并非原子操作,在并发环境下天然存在竞态条件。本文将拆解这一问题的成因,并介绍基于数据库原生能力的标准解决方案。

一、"先查后插"的并发漏洞

以用户表的邮箱唯一约束为例,典型的分步写代码逻辑如下:

go 复制代码
// 检查邮箱是否已注册
var count int64
db.Model(&model.User{}).Where("email = ?", email).Count(&count)
if count == 0 {
    // 不存在则创建用户
    db.Create(&newUser)
}

单线程执行时,这段逻辑完全符合预期。但当两个相同邮箱的请求几乎同时到达时,执行时序会出现交叉:

css 复制代码
时间轴:
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。

sql 复制代码
INSERT IGNORE INTO users (email, name, password) 
VALUES ('a@example.com', '张三', 'encrypted_pwd');
  • 无冲突:正常写入,受影响行数为 1
  • 有冲突:静默跳过,无报错,受影响行数为 0

该方案适用于幂等性写入场景,例如批量数据导入、初始化数据去重,重复数据无需处理,仅需通过受影响行数判断是否实际写入即可。其最大价值是避免将正常业务冲突转化为系统错误,减少无效错误日志。

2. ON DUPLICATE KEY UPDATE:冲突则执行更新

若冲突时需要更新部分字段,可使用该语法,即常说的 Upsert(更新或插入)。当唯一键命中时,执行后续的 UPDATE 逻辑;未命中则正常插入。

sql 复制代码
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 语义,适合注册、幂等写入场景:

go 复制代码
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 语义,指定冲突时需要更新的字段:

go 复制代码
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 加行锁,试图消除竞态。但该方案存在本质缺陷:行锁的前提是数据行存在。

当查询的目标数据尚未写入时,查询结果为空,没有实际的行可以施加行锁。两个并发事务都会查询到空结果,并同时认为可以安全写入,最终依然会触发唯一键冲突。

间隙锁在部分场景下会对写入产生限制,但其生效条件与隔离级别、索引类型、查询范围密切相关,依赖间隙锁保证写入安全并不稳定。相比之下,直接使用数据库原生的冲突处理语法,是成本最低、可靠性最高的方案。

实践总结

  1. 优先放弃分步判断写法。只要存在并发可能,先查后插就存在冲突概率,不应作为生产环境的首选方案。
  2. 仅需去重、无需更新 :选择 INSERT IGNORE / OnConflict{DoNothing: true},通过受影响行数区分写入结果。
  3. 存在即需更新字段 :选择 ON DUPLICATE KEY UPDATE / OnConflict{DoUpdates},实现原子 Upsert。
  4. 唯一索引是基础前提。冲突处理语法依赖唯一索引生效,建表时约束必须完整,这是数据一致性的最后防线。

本质上,这是一个很典型的工程设计原则:能通过底层组件原子能力解决的问题,就不要在上层通过分步逻辑模拟。数据库本身已经提供了成熟的并发写入处理机制,善用这些特性,既能减少代码复杂度,也能获得更可靠的并发表现。

相关推荐
智驭未来掌门人4 分钟前
利用FFmpeg快速实现一个RTSP推流服务器
后端
垚垚学技术_聚焦云原生17 分钟前
K8s Pod 完整生命周期详解
后端
JavaGuide21 分钟前
Github 史诗级故障,与此同时,Cursor 版「GitHub」正式上线!
前端·后端
不一样的少年_25 分钟前
修了 Bug、做了重构,为什么老板还是觉得你没产出?
前端·后端·程序员
水深火乐44 分钟前
安全地生成验证码、密码和 Token
后端
小强19881 小时前
SQL Server 慢查询怎么定位?一套从“卡”到“快”的完整排查流程
后端
水深火乐1 小时前
interface和any
后端
步行cgn1 小时前
MyBatis Error evaluating expression ‘ids‘. Return value (3) was not iterable 错误详
java·后端
神奇小汤圆1 小时前
Spring Boot + LangChain4j实现RAG——从零搭建企业级知识库问答系统
后端
H_Peak1 小时前
Python运算符与流程控制:if条件判断
后端