EF6处理重复主键、并发与竞争条件:在线用户跟踪库报错排查
看起来你遇到的是典型的并发竞态条件导致的主键约束违反错误——当同一个用户的多个请求几乎同时打过来时,你的类库大概率在执行「先查用户是否存在,再插入/更新」的两步操作,这中间的时间差就会让两个请求都误以为用户记录不存在,双双执行插入,直接触发主键冲突。
下面给你几个从代码到数据库的针对性解决方案,都是生产环境验证过的:
1. 用数据库原子操作彻底解决:MERGE语句
SQL Server 2008支持MERGE语法,能在单个原子操作里完成「存在则更新,不存在则插入」的逻辑,从根源上消除竞态窗口。你可以在EF6里直接执行原生SQL,或者封装成存储过程调用,这样多个应用调用时都能保证原子性。
示例原生SQL(用EF的Database.ExecuteSqlCommand执行):
MERGE INTO UserTracking WITH (HOLDLOCK) AS Target USING (VALUES (@Username, @AppName, @PageUrl, @Timestamp)) AS Source (Username, AppName, PageUrl, Timestamp) ON Target.Username = Source.Username WHEN MATCHED THEN UPDATE SET AppName = Source.AppName, PageUrl = Source.PageUrl, Timestamp = Source.Timestamp WHEN NOT MATCHED THEN INSERT (Username, AppName, PageUrl, Timestamp) VALUES (Source.Username, Source.AppName, Source.PageUrl, Source.Timestamp);
这里的WITH (HOLDLOCK)是关键,它会锁定目标记录,防止其他请求在MERGE执行期间读取或修改该用户的数据,彻底堵住并发漏洞。
2. 调整EF6代码逻辑,兼容并发场景
如果不想用原生SQL,也可以修改EF的操作逻辑,通过异常捕获或事务锁来规避冲突:
方案A:先更后插,捕获主键冲突
核心思路是先尝试更新(假设用户记录已存在),如果因为并发导致更新失败(实际记录不存在),再尝试插入;如果插入时还是触发主键冲突,说明另一个请求已经完成了插入,直接忽略即可:
public void UpdateUserActivity(UserTracking record) { using (var ctx = new YourTrackingDbContext()) { try { ctx.Entry(record).State = EntityState.Modified; ctx.SaveChanges(); } catch (DbUpdateConcurrencyException) { // 更新失败,大概率是记录不存在,转插入 ctx.Entry(record).State = EntityState.Added; try { ctx.SaveChanges(); } catch (DbUpdateException ex) { // 再次失败如果是主键冲突,说明其他请求已插入,忽略 if (!ex.InnerException.Message.Contains("PRIMARY KEY constraint")) { throw; // 非主键异常重新抛出 } } } } }
方案B:用AddOrUpdate+事务锁(注意避坑)
EF6的DbSet.AddOrUpdate方法看起来能实现「增改合一」,但它内部还是先查后改,默认不处理并发。所以需要结合事务和隔离级别缩小竞态窗口:
public void SyncUserActivity(UserTracking record) { using (var ctx = new YourTrackingDbContext()) { using (var tran = ctx.Database.BeginTransaction(System.Data.IsolationLevel.RepeatableRead)) { try { ctx.UserTrackings.AddOrUpdate(u => u.Username, record); ctx.SaveChanges(); tran.Commit(); } catch (Exception ex) { tran.Rollback(); if (ex.InnerException?.Message.Contains("PRIMARY KEY constraint") == true) { // 并发冲突,可选择忽略或重试一次 } else { throw; } } } } }
这里用RepeatableRead隔离级别,确保事务期间查询到的用户记录不会被其他事务修改,虽然不如MERGE彻底,但比无锁的AddOrUpdate靠谱很多。
3. 优化Global.asax的调用逻辑
你的类库是在Global.asax里被调用的,建议调整到Application_EndRequest事件触发,而不是Application_BeginRequest——这样可以减少同一用户的并发请求触发次数;另外,对于Web应用,可以用HttpContext.Current.Items暂存用户的请求信息,等请求结束时统一提交到数据库,避免多次调用带来的并发风险。
4. 数据库层面的辅助优化
- 确认
UserTracking表的主键确实是Username,没有多余的唯一约束或复合主键; - 可以给
Timestamp字段加个非聚集索引,方便后续如果需要清理历史数据(虽然你现在只存最新一条,但留一手总是好的)。
最后提醒:因为你的类库被多个应用引用,最好把解决方案封装在类库内部,让调用方不需要做任何修改——比如把MERGE逻辑封装成类库的核心方法,确保所有应用调用时都能自动规避并发冲突。
内容的提问来源于stack exchange,提问作者Gavin Ward

