You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

高并发场景下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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 17:01:05