尧图精选

.NET秒杀系统并发控制:SemaphoreSlim限流实战指南

🕒 发布时间:2026/9/15 9:42:46 📁 来源:尧图网络
干过.NET后端的同学应该都遇到过这种场景用户疯狂点按钮数据库连接打满库存被扣成负数。我去年接手一个秒杀活动第一版没做任何并发控制压测600并发直接把SQL Server搞挂超卖还不少。后来在接口入口加了一层 SemaphoreSlim把同时进到业务代码里的请求量限制在数据库能承受的范围内问题立刻缓解。这篇文章我会把 SemaphoreSlim 的用法、设计思路、压测参数怎么定、常见的坑以及一套可以直接复用的秒杀示例完整讲一遍。内容更适合正在做秒杀、抢购、抽奖这类高并发场景的.NET开发工程师也欢迎刚入门的朋友参考。1. 秒杀场景的并发挑战与 SemaphoreSlim 的定位1.1 秒杀系统要解决的不只是超卖提到秒杀很多人第一反应就是“别超卖”。但我实际做下来发现超卖只是表面现象。更核心的问题是瞬时流量远远超过系统资源能够处理的粒度导致数据库连接池被占满、线程池饥饿、依赖服务超时甚至整个站点不可用。正常情况下一个接口每秒几百QPS已经不少了但秒杀会把成百上千的请求压缩到同一瞬间打进来。如果不对流量做削峰数据库就会成为整个系统的短板。库存只有100件来10万请求我们不需要让10万请求都去查库存、扣库存那样做不仅浪费资源还会把业务数据库当成攻击目标一样拖垮。所以秒杀系统第一步要做的不是“处理所有请求”而是“只放行少量请求进入核心资源”。SemaphoreSlim 在这个环节里起的就是一个门卫的作用手上只有N张通行证拿不到通行证的人直接在门口返回失败别进去捣乱。1.2 为什么先做好“进程内限流”网上很多秒杀教程一上来就讲分布式锁、消息队列、Redis预扣库存这些方案本身没错但对大多数中小团队来说过于重。如果业务系统本来就是单实例部署或者每个实例有自己独立连接的数据源直接在进程内用 SemaphoreSlim 控制并发是最快、成本最低、见效最明显的手段。SemaphoreSlim 是 .NET 提供的一个轻量级信号量专门用来控制同时访问某个共享资源的线程数量。它的特点是支持异步等待不会像传统锁那样阻塞线程。对于秒杀这种大量请求涌入的场景异步等待可以帮我们节省线程资源减少上下文切换。还有一点容易被忽略SemaphoreSlim 的粒度是“进程内”也就是一个应用程序域内全局共享。假设你服务开了4个实例每个实例里信号量计数是50那同时进入核心业务的请求并不是50个而是最多50×4200个。所以它更多是保护单实例内部资源比如本地内存缓存、单个数据库连接池或者本机的CPU密集计算。如果要做全局精确限流还需要配合分布式锁或者网关层限流这个我在后面会展开讲。1.3 SemaphoreSlim 基础用法一个停车场模型理解 SemaphoreSlim 最好的方式是想一个停车场。停车场一共50个车位管理员手里有50张卡。车来了先找管理员拿卡拿到卡才能进门停车没卡就在门口排队或者直接离开。等车走了把卡还回去管理员把它交给下一辆车。对应到代码里private readonly SemaphoreSlim _semaphore new SemaphoreSlim(50); await _semaphore.WaitAsync(); try { // 这里执行需要限流的业务逻辑 } finally { _semaphore.Release(); }第一次调用 WaitAsync 时当前线程或异步上下文会尝试获取信号量。如果当前已用的“车位”没满就直接拿卡进入如果满了调用方会等待。等 Release 被调用等待队列里就会有一个调用方拿到卡继续执行。这里有个细节很多人刚接触时会混淆构造函数里的参数是什么意思。看下面两个写法new SemaphoreSlim(50); // 可用计数初始化为50最大计数也是50 new SemaphoreSlim(50, 50); // 同样可用计数50最大计数50 new SemaphoreSlim(10, 50); // 可用计数10最大计数50初始化后立刻可以放行10个实际写代码我们基本只用前两种。第三种适合那种“一开始只放行一部分后面再动态增加释放次数”的复杂场景。如果需要调整信号量可用的通行证数量可以通过CurrentCount去观察当前可用数但不要直接手动修改不是在所有版本都能随意改。2. 用 SemaphoreSlim 控制秒杀的落地方案2.1 整体流程分层接入层、信号量、库存服务把 SemaphoreSlim 加到秒杀接口里并不是随便包一下就行。我建议把整个流程分为三层来理解。第一层是接入层。ASP.NET Core 的 Controller 或者中间件收到请求后先做最轻量的参数校验和风控判断比如用户是否登录、商品是否在秒杀时段、用户是否已经参与过。这一层不要碰数据库能用缓存就用缓存。第二层是信号量层。这里就是 SemaphoreSlim 工作的地方。请求通过接入层之后先尝试获取信号量拿到的人继续走拿不到的直接返回“当前参与人数过多”。第三层是库存服务层。只有拿到信号量的请求才会去操作库存、创建订单、写流水。这层才是真正有业务逻辑的地方。这样做的好处是压力最大的流量在信号量层被“削掉”一大截库存服务层任何时候都只有少数请求数据库压力自然降下来。下面是简化版的流程用户请求 - 接入层校验 - 等待 SemaphoreSlim 信号量 - 获取成功 - 扣减库存 - 创建订单 | 获取失败或超时 - 返回“系统繁忙”2.2 核心代码异步秒杀接口的完整实现我用一个简化版的秒杀接口来演示代码能直接跑在 ASP.NET Core 里。关键点有几个SemaphoreSlim 必须是单例、请求等待要有超时、Release 必须在 finally 中执行、库存扣减必须使用条件更新。public class SeckillService { private readonly SemaphoreSlim _semaphore new SemaphoreSlim(50); public async TaskSeckillResult TrySeckill(int userId, int productId) { // 1. 尝试获取信号量最多等500毫秒 bool hasToken await _semaphore.WaitAsync(TimeSpan.FromMilliseconds(500)); if (!hasToken) { return SeckillResult.Busy(当前参与人数过多请稍后重试); } try { // 2. 到这里说明成功拿到令牌可以安全访问核心资源 using var db new AppDbContext(); // 3. 条件更新库存stock 0 才扣减 int affected await db.Products .Where(p p.Id productId p.Stock 0) .ExecuteUpdateAsync(p p.SetProperty(x x.Stock, x x.Stock - 1)); if (affected 0) { return SeckillResult.Fail(商品已抢光); } // 4. 创建订单 db.Orders.Add(new Order { UserId userId, ProductId productId, CreatedAt DateTime.UtcNow }); await db.SaveChangesAsync(); return SeckillResult.Success(); } catch (Exception ex) { // 5. 记日志捕获异常避免污染接口返回 ILoggerSeckillService logger null; logger.LogError(ex, 秒杀异常 userId{UserId} productId{ProductId}, userId, productId); throw; } finally { // 6. 释放令牌这一步无论如何都要执行 _semaphore.Release(); } } }这里的ExecuteUpdateAsync是 EF Core 7 提供的方法会生成一条UPDATE ... SET Stock Stock - 1 WHERE Id id AND Stock 0的 SQL。数据库执行时同一行记录会加行锁确保只有一个请求能成功更新库存行。受影响行数如果是0说明库存已经为0直接返回抢光。有人可能会问既然数据库已经用条件更新保证了不超卖为什么还需要信号量因为条件更新只能保证“这一条扣库存SQL不超卖”可如果同时有1万个请求一起执行数据库行锁竞争会让连接池耗尽耗时变长最终整个服务被拖垮。信号量相当于在数据库前面装了一个漏斗把同一时刻进来的请求数量控制住。2.3 信号量数值怎么估算用后端能承受的并发来反推SemaphoreSlim 的计数设多少是秒杀设计里最让人纠结的参数。不能拍脑袋定也不能越大越好。比较靠谱的方法是用《并发数QPS×平均响应时间》这个公式来反推。先看关键指标你的库存扣减接口从进入数据库到返回平均耗时是多少。这个值可以通过日志或者链路跟踪拿到假设是50毫秒。再看数据库或者核心服务最多能承受多大QPS假设是2000 QPS。那么信号量计数可以粗略估算为允许并发数 2000 QPS × 0.05 秒 100意思是如果你的平均响应时间是50毫秒那么在50毫秒内需要处理的最大并发请求数就是100。让100个请求同时进入核心业务数据库理论QPS正好接近2000。再多就不安全了。这个估算属于理论值真正上线前一定还要压测校准。我习惯先用理论值的60%起步比如算出100就先设成60再逐步往上调。调到数据库CPU、连接池、依赖服务都处于安全水位才算找到最优值。另外注意这个计数是所有服务实例共享还是单实例独占要提前想清楚。如果是单实例部署直接设置成刚才算出的值如果是多实例每实例的计数应该用全局目标并发除以实例数或者依赖全局限流组件控制。2.4 别忘了信号量不保证库存不超卖很多刚上手的朋友会有一个误区以为加了 SemaphoreSlim库存就安全了。其实信号量只负责“限流”不负责“数据一致”。在并发条件下就算同时只有50个请求进入业务代码如果没有条件更新内存库存或者数据库字段依然可能被扣成负数。我见过一个比较经典的错误写法先查询库存看是否大于0然后执行更新扣减。两个步骤之间其他线程可能已经修改了库存导致判断结果失效。正确做法是把“检查库存大于0”和“扣减库存”合并成一条原子SQL或者使用内存CAS操作。UPDATE Products SET Stock Stock - 1 WHERE Id productId AND Stock 0只有在受影响行数为1时才说明请求真的抢到了库存。如果返回0说明库存不足直接返回失败。这块千万不要省。3. SemaphoreSlim 进阶内部机理与正确使用姿势3.1 WaitAsync 为什么比 Wait 更适合高并发SemaphoreSlim 有同步版本的 Wait() 和异步版本的 WaitAsync()。老项目里有人习惯在异步接口里写_semaphore.Wait()压测一上来线程池直接炸。原因很简单Wait() 会阻塞当前线程而线程池线程数量有限一旦大量请求同时等待信号量线程池就会被占满后续请求连处理的机会都没有。WaitAsync() 则不一样它在等待信号量期间会把线程释放掉让线程去处理其他请求信号量可用时再继续执行后面的逻辑。这本质上是一种异步等待和 Task.Delay 类似特别适合 IO 密集型的秒杀场景。还有一个细节WaitAsync 可以传 CancellationToken用户断开请求时能主动取消等待避免无谓占用。CancellationTokenSource cts new CancellationTokenSource(TimeSpan.FromSeconds(2)); bool hasToken await _semaphore.WaitAsync(cts.Token);这样即使用户不主动断开超过2秒也会自动取消。对秒杀这种需要快速响应的接口多一层超时保护不是坏事。3.2 释放信号量的边界条件与异常处理释放信号量的正确姿势是必须和 WaitAsync 配对而且要在 finally 里执行。这个道理写程序的人都懂但在真实项目里我看到过不少漏写的情况。漏一次释放信号量可用数就少一个时间长了接口吞吐量会逐步下降最后表现为“系统越来越慢没有明显报错”。另外要小心 SemaphoreFullException。如果信号量当前的可用计数已经等于最大计数再调用 Release() 会抛出这个异常。也就是说你不能在一个没有成功 WaitAsync 的地方直接 Release。所以代码里最好把释放放在成功获取信号量的路径上并且用 try/finally 保证。bool hasToken await _semaphore.WaitAsync(Timeout.InfiniteTimeSpan); if (!hasToken) { return; } try { // 业务代码 } finally { _semaphore.Release(); }这样做最稳不会出现“释放一个没有获取过的令牌”的问题。3.3 可重入问题、实例生命周期与服务容器注册SemaphoreSlim 不像 Monitor 那样可重入。同一个线程如果连续两次 WaitAsync 同一个信号量第二次会等待第一次 Release很容易造成死锁。所以一定不要在持有信号量的时候又去调用同一个信号量保护的方法需要拆信号量或者改用异步锁组合。还有一个特别容易踩的坑是服务生命周期。假如 SeckillService 注册成 Scoped也就是每次请求都会创建一个新的实例那 SemaphoreSlim 也会跟着每次创建等于没有限流。正确的做法是把包含 SemaphoreSlim 的服务注册为 Singleton或者干脆用静态字段。public static readonly SemaphoreSlim GlobalSemaphore new SemaphoreSlim(50, 50);静态字段在多线程环境中安全吗SemaphoreSlim 本身是线程安全的WaitAsync 和 Release 可以被多个线程并发调用所以放心用。只要注意释放逻辑配对即可。3.4 用 Channel / 分布式锁代替 SemaphoreSlim 的场景SemaphoreSlim 适合保护“临界区”内的短操作比如扣库存、写缓存。但如果你是先扣库存再写订单整套流程可能要几百毫秒甚至更久信号量设置太小会让大量请求排队超时设置太大又挡不住洪峰。这种场景更适合用 Channel 做生产者消费者削峰。用户请求进来后把“秒杀请求”写入 Channel后台消费者按照固定速率批量处理。好处是消费者速度可控不会把数据库压垮坏处是处理是异步的用户不是立刻知道结果需要配合后续通知。如果是多实例部署单个进程的 SemaphoreSlim 管不到其他实例。这时候就得用分布式锁比如 Redis 锁或者用 Redisson 之类的客户端库操作 Lua 脚本。分布式锁解决的是全局互斥问题SemaphoreSlim 解决的是单机限流问题两者不冲突可以结合使用。4. 实战中的常见问题与排查技巧4.1 现象压测时线程数飙升请求全部卡住这个现象我在第一次用 SemaphoreSlim 时遇到过。压测工具像 wrk、jmeter、或者 k6 发过来大量请求后台线程数不断升高请求全部卡在等待信号量上接口超时率飙升。排查后发现问题不在 SemaphoreSlim而是信号量太小并且业务代码里还有同步 Wait()。请求多了以后线程池线程都在等待信号量新的请求没有线程处理最终表现为线程饥饿。解决方法是把同步 Wait 改成 WaitAsync同时给 WaitAsync 加一个超时比如500毫秒拿不到令牌直接返回失败。这样线程永远不会因为信号量无限期等待。4.2 现象偶发 SemaphoreFullException这个异常在日志里很显眼。现象是程序跑着跑着突然抛System.Threading.SemaphoreFullException程序自动重启。原因一般是 Release 的次数超过了 WaitAsync 的次数。常见场景是代码里释放逻辑写在了 catch 外面或者某些异常路径提前 return导致 finally 里多执行了一次 Release。还有一种可能是某个入口调用了两次 Release。遇到这个异常先去检查所有和信号量相关的入口把配对关系梳理清楚。4.3 现象服务多实例部署后仍然被打崩如果服务在 Kubernetes 上扩容到3个副本每副本 SemaphoreSlim 计数设为100理论上同时进入核心业务的请求可能有300个。数据库如果只能承受150个并发那系统依然会崩溃。这种场景要认清 SemaphoreSlim 的边界。它只能限制单实例的并发不能做全局精确限流。解决办法有几个调整每副本的计数比如全局希望150就每副本设50或者引入 Redis 等分布式限流组件还可以在网关层统一限流。对于秒杀这种全局资源竞争我建议至少把网关限流和进程内 SemaphoreSlim 做成兜底两层进程内信号量保证单实例不被击穿网关限流保证入口总量可控。4.4 给排查者的三个监控建议排查信号量问题光靠猜很难。我在实际项目里会加三个监控点推荐给你参考。第一监控CurrentCount。把它定时输出到日志或者 Prometheus观察信号量是否有泄露。正常情况空闲时 CurrentCount 等于初始值如果是0且持续很久说明有人忘记释放。第二监控等待时间。在 WaitAsync 前后加 Stopwatch统计平均等待时间。如果大量请求等待时间超过500毫秒说明信号量太小或者下游处理太慢。第三监控库存操作耗时。扣库存SQL的耗时如果持续上升说明数据库在锁竞争。信号量控制再好SQL本身很慢也会拖整体。5. 从零搭一个秒杀 Demo代码、压测和调优复盘5.1 Demo 的结构与代码为了让你更直观地理解我还原了一个小 Demo。这个 Demo 用 ASP.NET Core 8 实现库存保存在内存的 ConcurrentDictionary 中用 SemaphoreSlim 控制并发。为了模拟真实场景我在业务代码里放了一个50毫秒的Task.Delay代表一次数据库操作的开销。public class SeckillDemoService { private readonly SemaphoreSlim _semaphore new SemaphoreSlim(10); private readonly ConcurrentDictionaryint, int _stock new(); public async TaskSeckillResult TrySeckill(int productId, int userId) { bool hasToken await _semaphore.WaitAsync(TimeSpan.FromSeconds(1)); if (!hasToken) { return SeckillResult.Busy(当前参与人数过多请稍后重试); } try { // 模拟IO耗时 await Task.Delay(50); int currentStock _stock.GetOrAdd(productId, 100); if (currentStock 0) { return SeckillResult.Fail(已抢光); } // CAS更新库存 if (_stock.TryUpdate(productId, currentStock - 1, currentStock)) { return SeckillResult.Success(); } return SeckillResult.Fail(已抢光); } finally { _semaphore.Release(); } } }注意这里用了 CASTryUpdate来保证内存库存的一致性。虽然有信号量但并发环境下返回值依然可能被其他线程改变所以要做乐观更新。5.2 压测数据与信号量调参过程我用本地工具模拟200个并发请求打这个接口分别测试“不加信号量”和“加信号量”两组。结果是指标不加信号量加信号量计数10成功创建订单数11出现超卖10接口平均耗时380ms52ms接口最大耗时2000ms150ms系统线程占用高稳定这里有一个有趣的现象不加信号量时200个并发一起进入业务代码大量请求同时读到库存大于0然后同时执行 TryUpdate最终只有一个成功其他失败。由于失败逻辑处理不仔细看起来像“超卖”。加上信号量后同一时刻只有10个请求在操作库存配合 CAS没有出现一个库存被多次扣减的情况。信号量数值从5调到10再到20我观察到一个规律当信号量计数小于模拟IO耗时影响的吞吐量时很多请求会在等待时超时最后系统吞吐量反而上不去当信号量计数太大数据库压力明显增加。实践下来我将计数定为理论并发的一半左右具体情况还是得结合压测结果调整。5.3 复盘用 SemaphoreSlim 后的收益和仍存在的短板用 SemaphoreSlim 之后收益很直接业务的平均耗时下降了数据库压力被控制住了超卖问题在配合条件更新后也消失了。代码量很少没有引入额外组件对原有系统改动也小。但短板也很明确它管不到其他实例也不能完全替代分布式事务。如果秒杀量极大单纯靠进程内信号量数据库条件更新数据库写压力依然存在。更完善的方案是 Redis 预扣库存把“扣减”放到内存里然后异步落库。不过就算上了 Redis在数据库操作那一层加信号量依然有价值它能防止异步消费者并发过高把数据库打满。我自己做完这个 Demo 后最大的感受是SemaphoreSlim 不是万能的但它放在“离资源最近的位置”效果最好。先算出你后端能承受的真实并发量再把信号量设为略低于这个值用压测去微调比盲目堆机器管用得多。最后再分享一个小技巧线上发布秒杀功能前一定要把信号量计数做成配置项这样出问题时可以直接调阈值不用改代码重新发布。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →