C# MySQL事务并发时出现唯一键重复插入异常原因排查
问题根源与解决方案
嘿,这个问题我之前也踩过坑!你的代码核心问题在于**“检查-插入”的操作不是原子性的**——哪怕包裹在事务里也没用。默认情况下,你的SELECT查询是“快照读”,不会锁住任何行或间隙,多个并发事务能同时通过检查步骤,接着一起执行插入,这时候第二个插入就会触发唯一键的Duplicate Entry异常。
举个直观的场景:
- 事务A执行
SELECT ... WHERE name_col='xxx',发现无匹配记录 - 同时事务B执行相同的
SELECT,也判定无记录 - 事务A先完成
INSERT并提交 - 事务B再执行
INSERT,直接触发唯一键冲突抛出异常
哪怕两个事务都在独立事务中,这个场景也会发生,因为默认隔离级别(比如InnoDB的REPEATABLE READ)下,快照读不会阻止其他事务的写入操作。
两种靠谱的解决办法,按需选择:
1. 用SELECT ... FOR UPDATE加排他锁,让检查+插入变成原子操作
把查询语句改成带FOR UPDATE的版本,这样第一个事务执行查询时,会锁住对应行(如果不存在则锁住对应间隙,防止其他事务插入相同值),其他事务必须等待第一个事务提交/回滚后才能继续。
修改后的核心代码:
var tr = mysql.BeginTransaction(); try { // 加排他锁查询,确保并发下只有一个事务能走到插入步骤 var checkCmd = new MySqlCommand("SELECT id FROM your_table WHERE name_col = @name FOR UPDATE", mysql, tr); checkCmd.Parameters.AddWithValue("@name", new_name); // 必须用参数化! int existingId = -1; using (var reader = checkCmd.ExecuteReader()) { if (reader.HasRows) { reader.Read(); existingId = reader.GetInt32(0); } } if (existingId != -1) { tr.Rollback(); return existingId; } // 执行插入 var insertCmd = new MySqlCommand("INSERT INTO your_table (name_col) VALUES (@name)", mysql, tr); insertCmd.Parameters.AddWithValue("@name", new_name); insertCmd.ExecuteNonQuery(); int newId = (int)insertCmd.LastInsertedId; tr.Commit(); return newId; } catch (Exception e) { tr.Rollback(); throw; }
⚠️ 注意:这个方法要求表是InnoDB引擎(MyISAM不支持行锁和事务),同时要控制事务时长,避免锁持有太久导致其他请求等待。
2. 直接尝试插入,捕获唯一键异常后查询现有ID
另一种“乐观”思路:直接执行插入,如果碰到Duplicate Entry异常(MySQL错误码1062),再去查询已存在的记录ID。这种方式不需要加锁,并发性能更好,适合高流量场景。
代码示例:
int resultId = -1; var tr = mysql.BeginTransaction(); try { var insertCmd = new MySqlCommand("INSERT INTO your_table (name_col) VALUES (@name)", mysql, tr); insertCmd.Parameters.AddWithValue("@name", new_name); insertCmd.ExecuteNonQuery(); resultId = (int)insertCmd.LastInsertedId; tr.Commit(); } catch (MySqlException ex) { // 判定是否为唯一键冲突异常 if (ex.Number == 1062) { tr.Rollback(); // 查询已存在的ID(读操作无需事务,安全) var queryCmd = new MySqlCommand("SELECT id FROM your_table WHERE name_col = @name", mysql); queryCmd.Parameters.AddWithValue("@name", new_name); using (var reader = queryCmd.ExecuteReader()) { if (reader.HasRows) { reader.Read(); resultId = reader.GetInt32(0); } } } else { tr.Rollback(); throw; // 其他异常直接抛出 } } return resultId;
重中之重:修复SQL注入漏洞!
你的原代码用String.Format拼接SQL,这是极高危操作——如果new_name传入'); DROP TABLE your_table;--这类恶意字符串,数据库直接遭殃。一定要用参数化查询,就像上面例子里的@name参数,既能防注入,还能提升查询性能(MySQL可缓存查询计划)。
内容的提问来源于stack exchange,提问作者jw_




