C# .NET Core 并发控制:悲观原子、悲观锁、乐观锁

C# .NET Core 并发控制:悲观原子、悲观锁、乐观锁

前言:在活动报名、名额限额这类业务中,高并发下的超卖、数据不一致是绕不开的问题。很多开发者对悲观原子、悲观锁、乐观锁三者的定位和边界理解模糊,尤其是悲观原子什么时候加事务、什么时候不加,是上线高频踩坑点。本文基于 .NET Core + EF Core,统一使用活动报名、名额上限作为业务案例,依次讲解悲观原子、悲观锁、乐观锁的原理、代码示例、优缺点以及适用场景,方便直接落地。

前置说明:数据库采用 MySQL InnoDB,默认 RR(可重复读)隔离级别;所有示例围绕两张实体:活动表(名额控制)、报名表(报名记录)。

/// <summary>
/// 活动表,管控报名名额上限
/// </summary>
public class Activity
{
    public long Id { get; set; }
    public string ActivityName { get; set; }
    /// <summary>
    /// 总名额
    /// </summary>
    public int TotalQuota { get; set; }
    /// <summary>
    /// 剩余可报名名额
    /// </summary>
    public int RemainQuota { get; set; }
}

/// <summary>
/// 报名记录表,报名成功后写入
/// </summary>
public class ActivitySignUp
{
    public long Id { get; set; }
    public long ActivityId { get; set; }
    public long UserId { get; set; }
    public DateTime CreateTime { get; set; }
}

并发冲突基础概念:

  1. 脏读:读到别的事务未提交的数据
  2. 不可重复读:同一个事务内,同一条数据两次查询结果不一样(被其他事务修改)
  3. 幻读:同一个事务,相同查询条件,两次返回行数不一样(别的事务新增/删除)

并发控制,本质就是解决多个线程/多个服务实例同时读写同一份数据时的数据不一致。

概念先行区分:
悲观原子 = 带业务判断条件的原子UPDATE;原子性是语句级,不等于事务,区分无事务、事务包裹两种写法
悲观锁 = 数据库行排他锁 ​SELECT ... FOR UPDATE​
乐观锁 = 版本号/并发令牌校验

一、悲观原子(重点区分:无事务 / 有事务)

核心定义:悲观原子不是悲观锁,没有 ​FOR UPDATE​;它把【名额判断 + 名额扣减】合并在一条带WHERE条件的UPDATE语句中,依靠单条DML语句的数据库原子性实现防超卖。 重点:原子性 = 语句级别原子,​不等于事务;是否包裹事务,是这个方案最大的分水岭,直接决定能不能配套写入报名记录。

基础SQL模板:

UPDATE Activity 
SET RemainQuota = RemainQuota - 1 
WHERE Id = @ActivityId AND RemainQuota > 0;

受影响行数=1:扣名额成功;行数=0:名额已满。 单条DML执行时InnoDB会加瞬时行锁,语句执行完毕,锁是否立刻释放,由是否处于手动事务决定。

1.1 无事务版本的悲观原子

适用边界:仅适合可以容忍极小概率数据不一致、不想开启事务、追求极致吞吐的场景。生产常规报名业务,依然优先事务包裹版本。 风险点:补偿语句执行前程序宕机、数据库断连,补偿逻辑不会执行,名额扣减无法撤销,出现数据不一致;补偿逻辑本身也会并发竞争问题。

/// <summary>
/// 无事务悲观原子 + 业务补偿回退(不推荐优先使用,仅作为备选方案)
/// </summary>
public async Task<bool> SignUpAtomicCompensate(long activityId, long userId)
{
    // 第一步:原子扣名额,自动提交
    var affectRows = await _dbContext.Database.ExecuteSqlInterpolatedAsync($@"
        UPDATE Activity 
        SET RemainQuota = RemainQuota - 1
        WHERE Id = {activityId} AND RemainQuota > 0
    ");
    if (affectRows <= 0)
    {
        return false;
    }

    try
    {
        // 尝试写入报名记录
        var signUpRecord = new ActivitySignUp
        {
            ActivityId = activityId,
            UserId = userId,
            CreateTime = DateTime.Now
        };
        await _dbContext.ActivitySignUps.AddAsync(signUpRecord);
        await _dbContext.SaveChangesAsync();
        return true;
    }
    catch
    {
        // 写入失败,补偿:把名额加回去(业务回退)
        await _dbContext.Database.ExecuteSqlInterpolatedAsync($@"
            UPDATE Activity 
            SET RemainQuota = RemainQuota + 1
            WHERE Id = {activityId}
        ");
        throw;
    }
}

原理:MySQL autocommit默认开启,这条UPDATE本身就是一个自动提交的独立短事务,SQL执行完成,自动提交、锁立刻释放。 优点:锁持有时间极短、并发吞吐量最高、无死锁风险。
致命短板(报名业务几乎不能单独使用): 如果扣名额成功之后程序宕机、网络中断,后续的报名记录无法写入。出现:名额扣掉了,但是没有用户报名记录,数据不一致。

1.2 事务包裹版本的悲观原子

业务诉求:​扣名额 和 新增报名记录,必须同时成功、同时回滚,不允许出现半成功状态。 此时需要手动开启事务,把原子UPDATE、插入报名记录放到同一个事务中。

/// <summary>
/// 事务包裹悲观原子:扣名额 + 写入报名记录,原子绑定
/// </summary>
public async Task<bool> SignUpAtomicWithTrans(long activityId, long userId)
{
    using var tran = await _dbContext.Database.BeginTransactionAsync();
    try
    {
        // 原子UPDATE,条件内完成名额判断+扣减
        var affectRows = await _dbContext.Database.ExecuteSqlInterpolatedAsync($@"
            UPDATE Activity 
            SET RemainQuota = RemainQuota - 1
            WHERE Id = {activityId} AND RemainQuota > 0
        ");

        if (affectRows <= 0)
        {
            await tran.RollbackAsync();
            return false;
        }

        // 同事务写入报名记录
        var signUpRecord = new ActivitySignUp
        {
            ActivityId = activityId,
            UserId = userId,
            CreateTime = DateTime.Now
        };
        await _dbContext.ActivitySignUps.AddAsync(signUpRecord);
        await _dbContext.SaveChangesAsync();

        await tran.CommitAsync();
        return true;
    }
    catch (Exception ex)
    {
        await tran.RollbackAsync();
        throw;
    }
}

关键变化:UPDATE执行拿到行锁之后,​锁不会在SQL执行完释放,会一直持有到整个事务Commit或者Rollback。

优点:名额扣减、报名记录写入满足ACID,不会出现数据不一致;相比FOR UPDATE悲观锁,不需要单独SELECT查询,写法简洁,是名额争抢场景性价比最高的方案。
风险:锁持有周期拉长为整个事务周期,事务内不能放慢速逻辑,否则会引发排队超时;存在极低概率死锁。

1.3 悲观原子高频坑汇总

  1. 判断名额的条件​必须写在SQL的WHERE里面,不能先C#查询判断再UPDATE,会丢失原子性,出现超卖;
  2. 不要在原子UPDATE之前,用普通查询读取活动数据做判断(快照读拿到旧数据);如果需要额外复杂校验,改用FOR UPDATE悲观锁;
  3. 适合单表条件判断;多表复杂联动、大量校验逻辑不适合;
  4. 无事务版本只适合单纯扣配额,正规报名业务优先事务包裹版本。

1.4 无事务 vs 事务包裹悲观原子对比

模式 锁释放时机 原子范围 适合报名场景 风险
无事务悲观原子 UPDATE执行完成立刻释放(自动提交) 仅这条UPDATE语句 只扣名额,不需要写报名记录、流水 扣名额成功后宕机,附属业务记录丢失
事务包裹悲观原子 事务Commit/Rollback后释放 UPDATE + 同事务内全部DML 标准报名:扣名额 + 新增报名记录 事务过长拉长锁持有时间,并发排队

二、悲观锁(Pessimistic Lock)

2.1 原理

思想:默认争抢名额的冲突一定会发生,​先加排他行锁,锁成功之后再执行业务校验与修改,事务提交或者回滚之后才释放锁。 其他请求来争抢同一条活动行锁时会阻塞等待,或者锁等待超时直接报错。

注意:EF Core 没有原生封装 ​FOR UPDATE,需要手写原生SQL;锁必须包裹在事务内部。

2.2 代码示例(活动报名场景)

适合场景:除名额外,还需要读取活动数据做多重业务校验(校验活动时间、活动状态、用户黑名单等)

/// <summary>
/// 悲观锁报名:FOR UPDATE 行排他锁,先锁行、再校验、再扣名额+写入报名记录
/// </summary>
public async Task<bool> SignUpPessimistic(long activityId, long userId)
{
    using var tran = await _dbContext.Database.BeginTransactionAsync();
    try
    {
        // FOR UPDATE 加行排他锁,事务未提交前,其他事务无法修改该行
        var activity = await _dbContext.Activities
            .FromSqlRaw("SELECT * FROM Activity WHERE Id = {0} FOR UPDATE", activityId)
            .FirstOrDefaultAsync();

        // 多重业务校验:名额、活动状态、时间等都可以在这里写
        if (activity == null || activity.RemainQuota <= 0)
        {
            await tran.RollbackAsync();
            return false;
        }

        // 扣减名额
        activity.RemainQuota -= 1;
        _dbContext.Activities.Update(activity);

        // 新增报名记录
        var signUp = new ActivitySignUp
        {
            ActivityId = activityId,
            UserId = userId,
            CreateTime = DateTime.Now
        };
        await _dbContext.ActivitySignUps.AddAsync(signUp);

        await _dbContext.SaveChangesAsync();
        await tran.CommitAsync();
        return true;
    }
    catch (Exception ex)
    {
        await tran.RollbackAsync();
        // 捕获锁超时、死锁异常
        throw;
    }
}

2.3 优缺点与坑点

优点:

  • 强一致性,拿到锁之后,该行数据不会被其他事务篡改;
  • 适合校验逻辑复杂,需要先读取实体、在C#代码中做多种业务判断的报名场景。

缺点:

  • 锁会阻塞其他请求,事务越长,锁持有时间越久,高并发下容易排队、锁超时、产生死锁;
  • 长事务是悲观锁大忌,事务内部禁止写HTTP调用、复杂计算、慢速逻辑;
  • 仅适用于单库场景;分库分表、跨服务分布式场景,数据库行锁无效。

️ 核心坑:​FOR UPDATE 如果没有命中索引,会升级为表锁,整张活动表被锁住,并发直接雪崩;锁只有在事务提交/回滚后释放。

2.4 适用场景

报名业务校验规则复杂、冲突概率高、一致性要求极高;需要读出活动实体做多种业务判断。

三、乐观锁(Optimistic Lock)

3.1 原理

思想: 冲突是小概率事件,不加锁,正常读取数据;更新的时候校验版本号,如果版本和读取时不一致,说明中间名额已经被别人抢走,更新失败,交给上层重试或者返回用户“操作冲突,请重试”。
主流实现: 版本号 Version(long/int,每次更新自动+1,推荐);时间戳DateTime存在精度问题,高并发不推荐。
更新逻辑: 读取活动、业务判断 → 更新时附带版本条件;受影响行数为0代表并发冲突。

EF Core 使用 ​[ConcurrencyToken] 标记版本字段,自动生成并发校验SQL。 改造后的活动实体:

public class Activity
{
    public long Id { get; set; }
    public string ActivityName { get; set; }
    public int TotalQuota { get; set; }
    public int RemainQuota { get; set; }

    [ConcurrencyToken]
    public long Version { get; set; }
}

3.2 基础代码示例

/// <summary>
/// 乐观锁报名,冲突直接返回失败
/// </summary>
public async Task<bool> SignUpOptimistic(long activityId, long userId)
{
    var activity = await _dbContext.Activities.FindAsync(activityId);
    if (activity == null || activity.RemainQuota <= 0)
        return false;

    activity.RemainQuota -= 1;
    var signUp = new ActivitySignUp
    {
        ActivityId = activityId,
        UserId = userId,
        CreateTime = DateTime.Now
    };
    await _dbContext.ActivitySignUps.AddAsync(signUp);

    try
    {
        await _dbContext.SaveChangesAsync();
        return true;
    }
    catch (DbUpdateConcurrencyException)
    {
        // EF Core检测到版本不匹配,抛出并发异常,代表名额已经被抢占
        return false;
    }
}

EF Core底层生成SQL类似:

UPDATE Activity
SET RemainQuota = @p0, Version = @p1
WHERE Id = @p2 AND Version = @p3;

3.3 带重试的封装

冲突后短时间重试,提高报名成功率,必须限制最大重试次数,防止死循环:

/// <summary>
/// 乐观锁报名,带有限重试
/// </summary>
public async Task<bool> SignUpOptimisticWithRetry(long activityId, long userId, int maxRetry = 3)
{
    for (int i = 0; i < maxRetry; i++)
    {
        // AsNoTracking 避免本地缓存干扰版本
        var activity = await _dbContext.Activities.AsNoTracking().FirstOrDefaultAsync(x => x.Id == activityId);
        if (activity == null || activity.RemainQuota <= 0)
            return false;

        activity.RemainQuota -= 1;
        _dbContext.Activities.Update(activity);
        var signUp = new ActivitySignUp
        {
            ActivityId = activityId,
            UserId = userId,
            CreateTime = DateTime.Now
        };
        await _dbContext.ActivitySignUps.AddAsync(signUp);

        try
        {
            await _dbContext.SaveChangesAsync();
            return true;
        }
        catch (DbUpdateConcurrencyException)
        {
            _dbContext.ChangeTracker.Clear();
            await Task.Delay(20); // 简单退避
        }
    }
    return false;
}

3.4 优缺点与坑点

优点:

  • 没有长锁,读操作不会被阻塞,数据库性能好;
  • 不存在死锁问题;
  • 适合读多写少、报名争抢不激烈的活动。

缺点:

  • 冲突发生时会失败,需要自己实现重试策略;
  • 大量并发争抢名额时,重试会持续打数据库,压力上涨;
  • 无法杜绝“查询时有名额,长时间等待后再提交”的窗口问题。

️ 核心坑: 版本号必须由数据库更新自增,不能在C#代码手动赋值;不能无限制重试。

3.5 适用场景

读多写少、并发争抢名额概率低,能够接受提示用户重试的活动报名场景。

四、三大方案横向总对比

方案 核心手段 一致性 是否长锁 阻塞 死锁风险 适合活动报名场景
悲观原子(事务包裹) 带条件原子UPDATE,放在事务内 强一致 锁持有至事务结束 争抢同名额会排队 低 只校验名额是否充足,单表判断,报名业务首选
悲观锁 SELECT … FOR UPDATE 事务行锁 强一致 事务周期持有锁 会阻塞等待 有 除名额外还有大量业务校验,需要读取活动实体做判断
乐观锁 Version版本号,提交校验 强一致 无长锁 不阻塞读,冲突直接失败 无 读多写少,名额争抢概率低,可接受重试提示

五、选型决策

  1. 只校验剩余名额,业务简单,扣名额同时要写入报名记录 → 事务包裹悲观原子(优先推荐)
  2. 除名额外,还要校验活动时间、上下架状态、用户黑名单等,需要读取实体做复杂逻辑 → 悲观锁 FOR UPDATE
  3. 活动流量大、但是报名争抢名额很少,读远大于写,允许用户提示重试 → 乐观锁
  4. 多服务分布式争抢同一个名额,数据库锁全部失效 → 需要Redis分布式锁(本文不展开)

六、总结

  1. 悲观原子​:依靠带条件UPDATE实现语句级原子;​有无事务是它的核心分支。无事务仅单语句安全,无法配套写报名记录;事务包裹版本兼顾防超卖与多步写入的一致性,是大部分简单名额报名场景的最优解。
  2. 悲观锁:先上锁再执行业务,强一致;代价是锁阻塞、长事务风险,适合校验复杂的报名流程。
  3. 乐观锁:不加锁,依靠版本号检测冲突,冲突后重试;适合争抢不激烈的活动。

日常 .NET Core 报名系统开发里,绝大多数名额扣减需求,事务包裹的悲观原子就足够稳定落地;只有校验逻辑变复杂时,再切换为悲观锁。

误区提醒:不要把悲观原子等同于乐观锁。乐观锁的设计思想是“冲突很少,冲突重试”;悲观原子的目标是直接在数据库层面拦截超卖,业务上是悲观的思路。

文章摘自:https://www.cnblogs.com/ykbb/p/23532279/c-2voqmz