高并发场景下SQL Server如何防止票务预订重复插入记录
高并发票务超卖/重复预订问题解决方案
你当前代码出问题的核心原因是Check-Then-Act竞态漏洞:校验票状态、校验预订记录、更新票状态+插入新预订这三步完全独立,不具备原子性。3000并发场景下,会有大量请求同时通过前两步校验,最终重复写入预订记录造成超卖。
以下是可落地的修复方案,按优先级从高到低排列:
1. 数据库层加唯一约束(必做,最可靠的兜底防线)
数据库层面的约束是所有方案里最不容易被绕过的,是解决重复插入的根本手段:
- 先修正现有模型的语法/字段缺失问题:当前
tblTicket模型里查询用到了subject_id但未定义,tblResevation模型里的public <int> subject_id是语法错误,应修正为public int subject_id { get; set; }。 - 给
tblResevation表创建联合唯一索引,覆盖subject_id、date、time三个票务唯一标识字段,从表结构上保证同一场次的票只会存在一条预订记录。以SQL Server为例,建索引语句如下,其他数据库语法逻辑一致:
CREATE UNIQUE INDEX UX_Reservation_TicketMatch ON tblResevation(subject_id, date, time);
- 加完索引后,哪怕多个并发请求同时走到插入预订记录的步骤,数据库也会直接抛出唯一键冲突异常,你只需要在代码里捕获该异常,返回“票已被其他用户预订”即可,从根源杜绝重复插入。
2. 替换先查后改逻辑为原子操作,消除校验与更新的时间差
你现在先独立查询状态、再执行更新的逻辑是快照读,并发下拿到的结果很容易过期,要把校验逻辑直接写到更新语句的判断条件里,用更新影响行数判断操作是否成功,废弃掉独立的两次前置校验查询:
// 所有预订操作必须包裹在事务中,保证更新票状态和插入预订记录要么全成功要么全回滚 using var transaction = context.Database.BeginTransaction(); try { // 直接尝试更新处于激活状态的对应票,把st=true的校验条件直接写到更新WHERE里 int affectedRows = context.tblTicket .Where(t => t.subject_id == id && t.date == dateNow && t.time == timeNow && t.st == true) .ExecuteUpdate(setter => setter.SetProperty(p => p.st, false)); // 影响行数为0,说明票不存在/已经被置为非激活状态 if (affectedRows == 0) { transaction.Rollback(); j.st = false; j.message = "Sorry, this ticket is not available"; return Json(j); } // 直接组装预订记录插入 var newReservation = new tblResevation { subject_id = id, date = dateNow, time = timeNow, // 补充name、mob、email等用户字段赋值 }; context.tblResevation.Add(newReservation); context.SaveChanges(); // 触发唯一键冲突时会抛出数据库异常 transaction.Commit(); // 此处返回预订成功逻辑 } catch (DbUpdateException ex) when (ex.InnerException is SqlException sqlEx && (sqlEx.Number == 2601 || sqlEx.Number == 2627)) { // 捕获唯一键冲突异常,说明并发请求已经抢先完成预订 transaction.Rollback(); j.st = false; j.message = "Sorry, this ticket reserved by another user"; return Json(j); }
注意:不要用
AsNoTracking()做预订记录的前置校验,AsNoTracking仅用于无状态查询,拿到的是查询时间点的快照数据,并发场景下结果会立刻过期,完全无法阻挡重复请求。
如果你使用EF Core 7以下版本不支持ExecuteUpdate,查询票实体时要加更新锁避免幻读,示例:var ticket = context.tblTicket .FromSqlRaw("SELECT TOP 1 * FROM tblTicket WITH (UPDLOCK) WHERE subject_id = {0} AND date = {1} AND time = {2} AND st = 1", id, dateNow, timeNow) .FirstOrDefault(); if (ticket == null) { /* 返回票不可用 */ } ticket.st = false; // 后续插入预订记录逻辑不变
3. 高并发峰值下的前置防护(可选,用于降低数据库压力)
如果并发量持续超过数据库单表处理阈值,可以在应用层加前置防护,减少无效请求打到数据库:
- 对同一
subject_id+date+time维度的预订请求加分布式锁(比如基于Redis的SETNX实现),同一时间只允许一个请求处理同一场次票的预订流程,其余请求直接返回“系统繁忙请稍后重试”,避免大量并发请求同时争抢数据库资源。 - 加接口限流规则,比如单用户1秒内只能提交1次预订请求,过滤掉用户重复点击、脚本刷票产生的无效请求。
额外优化建议
- 你当前用
string类型存储date和time字段很容易出现格式不一致导致的匹配错误,建议换成数据库原生的DateTime/DateTimeOffset类型存储时间。 - 不要用应用进程内的内存锁做并发控制,如果你的服务是多实例部署,内存锁完全无法跨实例生效,必须用分布式锁或者数据库层约束做兜底。
内容的提问来源于stack exchange,提问作者user3150241
相关产品推荐
相关产品推荐

