news 2026/9/8 13:24:49

.NET 10高并发实战:Redis分布式锁与秒杀防超卖方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
.NET 10高并发实战:Redis分布式锁与秒杀防超卖方案

后端开发做到一定阶段,几乎都会遇到这类需求:秒杀、抢券、预约报名。业务逻辑本身并不复杂——查库存、判断状态、扣减库存、写订单,但一旦把服务从单机部署扩展成多台实例,原本好用的内存锁就全部失效了。这也是很多团队从单体架构转向分布式后遇到的第一道坎。

本文围绕 .NET 10 高并发后端架构展开,完整讲解如何在 WebAPI 项目中整合 Redis,并基于 Redis 实现分布式锁,解决集群并发冲突、超卖、重复请求这三大经典问题。全文包含完整的环境搭建步骤、可复制源码、并发验证思路和常见问题排查方法。文章中的代码以 .NET 10 为目标框架,在 .NET 8 / 9 项目中同样可以运行。

考虑到标题中提到了 AI 辅助开发,我也会在最后的生产实践部分,聊聊如何用 AI 生成高并发相关代码时避免踩到分布式锁、幂等性这些"看似正确、实际埋雷"的坑。

1. 为什么高并发后端需要 Redis 分布式锁

1.1 单机锁在集群环境下的失效场景

在单台服务器上,我们可以使用 C# 原生的lock关键字、SemaphoreSlim或者Monitor来保证同一时刻只有一个线程执行关键代码。它们之所以有效,是因为多个线程共享同一个进程内存,锁的状态对当前进程内的所有线程可见。

但是当服务横向扩展成两台、三台甚至更多的实例时,情况就变了。假设有两个请求同时进入系统,Request A 被负载均衡分发到实例 1,Request B 被分发到实例 2。实例 1 使用lock锁住了库存扣减逻辑,但实例 2 完全感知不到这把锁的存在。两个实例同时读到库存为 1,同时执行扣减,最终库存变成负数——这就是典型的超卖问题。

从这个角度看,分布式锁要解决的核心问题很简单:多个进程之间,如何实现互斥

1.2 超卖问题是怎么发生的

先来看一个具体的超卖过程。假设商品库存为 10,用户 A 和用户 B 同时发起秒杀请求,并且请求被分发到两台不同的服务器。

两个请求都执行了下面的流程:

  1. 从数据库或缓存中读取当前库存,发现库存为 10。
  2. 判断库存大于 0,允许购买。
  3. 执行库存减一操作,库存变成 9。
  4. 写入订单记录。

这样的流程在没有并发控制时,即使只有一个实例,也可能出现问题,因为在"读取库存"和"扣减库存"之间存在时间窗口。更不用说在集群环境下,多个实例同时执行这段代码,超卖几乎是必然的。

真正严谨的说法是:"查库存 + 扣库存 + 写订单"不是 Redis 或数据库单条命令就能完成的原子流程。无论是单机还是集群,都需要一种机制保证整个复合流程的串行执行。

1.3 Redis 分布式锁的核心价值

分布式锁的核心思想是引入一个所有服务实例都能访问到的"协调者",让这个协调者来记录锁的状态。Redis 因为天生支持高性能读写、单线程执行命令、以及SET NX EX这样的原子操作,成为了实现分布式锁最常用的中间件。

它的执行过程可以这样理解:

  • 服务 A 执行SET lock:product:1 requestA NX EX 5,Redis 发现该 key 不存在,设置成功,相当于拿到了锁。
  • 服务 B 执行同样的命令,Redis 发现该 key 已经存在,设置失败,说明锁被占用,只能等待。
  • 服务 A 业务执行完毕,删除这个 key,释放锁。
  • 服务 B 再次尝试,此时设置成功,开始执行自己的业务。

Redis 分布式锁之所以能够解决集群下的并发冲突,是因为它把锁的判定逻辑交给了所有节点都能访问的 Redis 服务,而不是绑定在某一个进程内。只要 Redis 服务本身可用,所有实例就能以它为准进行互斥。

1.4 .NET 10 WebAPI + Redis 技术组合的定位

.NET 10 是微软当前最新主推版本,按官方节奏在 2025 年 11 月发布,是长期支持版本。相比 Java 技术栈,.NET 10 体系下可以用最小的代码量搭建出高性能 WebAPI 服务,内置了配置、依赖注入、日志、JWT 认证等常用基础设施,非常适合用来实现秒杀、订单这类需要快速交付的业务。

如果你熟悉 Java 生态,可以做这样一组对位:

Java 技术栈.NET 技术栈
Spring Boot WebASP.NET Core WebAPI
Spring Data Redis / LettuceStackExchange.Redis
Redisson 分布式锁自己封装 RedisLock
Maven / GradleNuGet
Nacos 配置中心Configuration + Options 模式

在 AI 辅助开发的背景下,Spring Boot 和 .NET WebAPI 的样板代码生成效率已经没太大差别,脚手架、CRUD、Redis 基础操作几乎都能让 AI 一次性生成。但分布式锁、超卖、幂等这类型的问题,恰恰是 AI 最容易出错的地方,因为它们涉及"原子性""超时""异常路径"等边界条件。这也是本篇文章要重点拆解原理和代码的原因。

2. 环境准备:安装 Redis 与创建 .NET10 WebAPI 项目

2.1 版本与环境说明

本文的开发环境如下:

  • 操作系统:Windows 11 / Windows Server(Linux 服务器同样适用)
  • 开发工具:Visual Studio 2022(17.12 以上版本)或 Visual Studio Code
  • 框架版本:.NET 10 SDK
  • Redis 版本:Redis 7.x
  • Redis 客户端:StackExchange.Redis 2.8.x
  • 缓存扩展包:Microsoft.Extensions.Caching.StackExchangeRedis 10.0.x

需要注意的是,.NET 版本迭代速度较快,如果你的开发机安装的 SDK 还不是 .NET 10,建议先到微软官网下载最新 SDK。本文代码在 .NET 8 / 9 项目中也可以直接使用,影响不大。

2.2 安装 Redis 的三种方式

Redis 官方一直未提供 Windows 原生产品,生产环境推荐使用 Linux 或 Docker 部署。在 Windows 本机开发调试时,可以按下面的方式选择。

第一种方式是使用 Docker。如果你本机已经安装了 Docker Desktop,这是最干净、最省心的方式:

docker run -d --name redis7 -p 6379:6379 -v redis-data:/data redis:7

启动后可以使用docker exec验证 Redis 是否正常运行:

docker exec -it redis7 redis-cli ping

如果返回PONG,说明 Redis 已正常启动。

第二种方式是 Windows 下的本地安装。Redis 官方不提供 Windows 安装包,常见的替代方案有三个:

  • 使用 WSL 2 安装 Linux 版 Redis;
  • 使用 Memurai,这是一个兼容 Redis 协议的 Windows 原生服务;
  • 使用开源社区维护的 Windows 移植版。

从稳定性和维护成本考虑,我推荐普通开发者在 Windows 上优先使用 WSL 2 或 Docker,生产环境一律使用 Linux 服务器。

第三种方式是安装可视化客户端。日常开发调试时,可以用 Redis Desktop Manager 或 Another Redis Desktop Manager 查看 key、设置过期时间、执行命令,能显著提高定位问题效率。

2.3 创建 WebAPI 项目

如果你使用的是 Visual Studio 2022,创建项目的步骤非常简单:

  1. 打开 Visual Studio,选择"创建新项目"。
  2. 选择"ASP.NET Core Web API"模板。
  3. 项目名称填写ConcurrencyDemo
  4. 框架选择 .NET 10.0。
  5. 点击"创建"。

如果不喜欢图形界面,也可以直接使用命令行:

dotnet new webapi -n ConcurrencyDemo cd ConcurrencyDemo dotnet run

创建完成后,项目结构是这样的:

ConcurrencyDemo/ ├── Properties/ │ └── launchSettings.json ├── Controllers/ │ └── WeatherForecastController.cs ├── Program.cs ├── ConcurrencyDemo.csproj └── appsettings.json

接下来,我们会在Controllers下新增业务控制器,在项目根目录新增ServicesLocks两个文件夹,分别存放 Redis 服务封装和分布式锁实现。

3. .NET10 WebAPI 集成 Redis

3.1 添加 NuGet 包

在项目中使用 Redis,通常需要两个包。

第一个是StackExchange.Redis,它是 .NET 生态最主流的 Redis 客户端,提供ConnectionMultiplexerIDatabase等核心 API,分布式锁必须直接基于它来实现,因为官方缓存扩展包并没有暴露足够的锁操作接口。

第二个是Microsoft.Extensions.Caching.StackExchangeRedis,它是 ASP.NET Core 官方提供的缓存扩展包,底层依赖 StackExchange.Redis,用于实现IDistributedCache,适合做字符串、对象缓存。

使用命令行添加包:

dotnet add package StackExchange.Redis dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

如果使用 Visual Studio,也可以打开"管理 NuGet 程序包"界面搜索安装,版本选择最新的稳定版即可。

3.2 注册 Redis 服务

先修改appsettings.json,加入 Redis 连接字符串。这里使用了abortConnect=false,这个配置项的意义是:即使 Redis 暂时不可用,也允许应用继续启动,并在 Redis 恢复后自动重连,避免因为缓存中间件故障导致整个网站启动失败。

{ "ConnectionStrings": { "Redis": "localhost:6379,abortConnect=false,defaultDatabase=0" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*" }

然后在Program.cs中注册两个 Redis 相关服务:

using StackExchange.Redis; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // 注册 Redis 连接对象 builder.Services.AddSingleton<IConnectionMultiplexer>(sp => { var configuration = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379"; return ConnectionMultiplexer.Connect(configuration); }); // 注册 IDistributedCache 缓存 builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379"; options.InstanceName = "ConcurrencyDemo:"; }); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseAuthorization(); app.MapControllers(); app.Run();

这里注册IConnectionMultiplexer时使用了单例模式。在 ASP.NET Core 中,一个 Redis 连接应当被整个应用共享,而不是每个请求创建一次。ConnectionMultiplexer内部实现了连接池、自动重连等机制,设计上就是用来长生命周期复用的。

3.3 封装 RedisService

实际项目中,不建议直接在 Controller 里操作IDatabase,因为读取、写入、过期、删除等逻辑到处重复,后续维护成本很高。我习惯先封装一个RedisService,把常用操作收敛起来。

在项目根目录新建Services/RedisService.cs

using StackExchange.Redis; namespace ConcurrencyDemo.Services; public class RedisService { private readonly IDatabase _database; public RedisService(IConnectionMultiplexer connectionMultiplexer) { _database = connectionMultiplexer.GetDatabase(); } public async Task SetStringAsync(string key, string value, TimeSpan? expiry = null) { await _database.StringSetAsync(key, value, expiry); } public async Task<string?> GetStringAsync(string key) { var value = await _database.StringGetAsync(key); return value.IsNullOrEmpty ? null : value.ToString(); } public async Task<long> IncrementAsync(string key, long value = 1) { return await _database.StringIncrementAsync(key, value); } public async Task<bool> SetNxAsync(string key, string value, TimeSpan? expiry = null) { return await _database.StringSetAsync(key, value, expiry, When.NotExists); } public async Task<bool> DeleteAsync(string key) { return await _database.KeyDeleteAsync(key); } }

这里的方法都是对IDatabase的二次封装。StringIncrementAsync是 Redis 的INCR命令,因为 Redis 单线程执行命令,所以单条INCRBY本身是原子安全的。SetNxAsync对应的是SET key value NX EX,它是后面实现分布式锁的基础命令。

注册 RedisService:

builder.Services.AddScoped<RedisService>();

因为 RedisService 内部没有缓存状态,所以用AddScopedAddSingleton都可以,这里按作用域注册更符合依赖注入习惯。

3.4 验证 Redis 连接

新增Controllers/RedisController.cs,用来测试 Redis 读写:

using ConcurrencyDemo.Services; using Microsoft.AspNetCore.Mvc; namespace ConcurrencyDemo.Controllers; [ApiController] [Route("api/[controller]")] public class RedisController : ControllerBase { private readonly RedisService _redis; public RedisController(RedisService redis) { _redis = redis; } [HttpPost("set")] public async Task<IActionResult> Set(string key, string value) { await _redis.SetStringAsync(key, value, TimeSpan.FromMinutes(5)); return Ok(new { message = "写入成功", key, value }); } [HttpGet("get/{key}")] public async Task<IActionResult> Get(string key) { var value = await _redis.GetStringAsync(key); return Ok(new { key, value }); } }

启动项目后,调用:

POST /api/Redis/set?key=hello&value=world GET /api/Redis/get/hello

返回结果中能拿到value = "world",就说明 Redis 集成成功。此时打开 Redis Desktop Manager,也可以看到名为hello的字符串 key。

4. 动手实现一个可用的 Redis 分布式锁

4.1 分布式锁需要满足的条件

一个能上生产环境的分布式锁,至少需要满足以下条件:

  • 互斥性。任意时刻,只能有一个客户端持有锁。
  • 防死锁。持有锁的客户端崩溃后,锁也能自动释放。
  • 误删防护。释放锁时,只能删除自己持有的锁,不能误删别人的锁。
  • 原子性。获取锁和释放锁的操作必须原子执行,不能拆成多条命令。
  • 容错性。在 Redis 节点可用的情况下,分布式锁功能可用。

其中防死锁对应锁的过期时间,误删防护对应锁 value 校验,原子性对应十二条 Redis 命令的组合,在实现时每一步都有对应的代码设计。

4.2 两条核心 Redis 命令

获取锁的核心命令是:

SET lock:product:1 requestA NX EX 5

NX表示只有当 key 不存在时才能设置成功,EX 5表示设置 5 秒过期时间。如果设置成功,说明当前客户端拿到了锁;如果返回空,说明锁已被其他客户端持有。

释放锁时,不能直接使用DEL。假如请求 A 执行业务时间超过了锁过期时间,锁已经自动释放,此时请求 B 获取到了锁。请求 A 处理完后直接执行DEL,会把请求 B 的锁也删除掉,导致互斥失败。

正确的释放方式是使用 Lua 脚本,先比较 value 是否一致,一致才删除:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

这里使用 Lua 脚本的根本原因在于,比较 value 和删除 key 必须是一个原子操作。如果拆成"先 GET 比较,再 DEL",中间还是可能被其他命令穿插。

4.3 RedisLock 完整实现

在项目根目录新建Locks/RedisLock.cs

using System.Diagnostics; using StackExchange.Redis; namespace ConcurrencyDemo.Locks; public class RedisLock : IAsyncDisposable { private readonly IDatabase _database; private readonly string _lockKey; private readonly string _lockValue; private readonly TimeSpan _expiry; public RedisLock(IDatabase database, string lockKey, TimeSpan expiry) { _database = database; _lockKey = lockKey; _lockValue = Guid.NewGuid().ToString("N"); _expiry = expiry; } /// <summary> /// 尝试获取锁,立即返回结果 /// </summary> public async Task<bool> AcquireAsync() { return await _database.StringSetAsync(_lockKey, _lockValue, _expiry, When.NotExists); } /// <summary> /// 在指定时间内不断重试获取锁 /// </summary> public async Task<bool> AcquireAsync(TimeSpan waitTimeout) { var stopwatch = Stopwatch.StartNew(); while (stopwatch.Elapsed < waitTimeout) { if (await AcquireAsync()) { return true; } await Task.Delay(100); } return false; } /// <summary> /// 释放锁,使用 Lua 脚本保证原子性 /// </summary> public async Task<bool> ReleaseAsync() { const string script = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """; var result = await _database.ScriptEvaluateAsync( script, new RedisKey[] { _lockKey }, new RedisValue[] { _lockValue }); return result != null && (long)result == 1; } public async ValueTask DisposeAsync() { await ReleaseAsync(); } }

_lockValue使用Guid.NewGuid().ToString("N")生成唯一标识,这一步是防止误删的关键。每个线程获取锁时都会生成不同的 value,释放锁时只有 value 匹配才能删除。

AcquireAsync(TimeSpan waitTimeout)方法中,Task.Delay(100)是重试间隔。秒杀这类场景通常希望快速失败而非长时间排队,因此也可以不等待、直接返回获取锁失败。实际项目中可以根据业务容忍度调整轮询间隔和等待时间。

再封装一个获取 RedisLock 的服务,方便业务层调用:

using ConcurrencyDemo.Locks; using StackExchange.Redis; namespace ConcurrencyDemo.Services; public class DistributedLockService { private readonly IConnectionMultiplexer _connectionMultiplexer; public DistributedLockService(IConnectionMultiplexer connectionMultiplexer) { _connectionMultiplexer = connectionMultiplexer; } public RedisLock CreateLock(string lockKey, TimeSpan? expiry = null) { var database = _connectionMultiplexer.GetDatabase(); return new RedisLock(database, lockKey, expiry ?? TimeSpan.FromSeconds(5)); } }

Program.cs中注册:

builder.Services.AddSingleton<DistributedLockService>();

4.4 锁续期与 RedLock 的取舍

上面的实现已经能满足大多数业务场景。不过还存在一个边界问题:如果业务执行时间超过了锁过期时间,当前线程的锁会自动释放,后继线程拿到锁,两个线程就会同时执行,互斥被破坏。

解决思路是"看门狗"机制:在持有锁期间,定期延长锁的过期时间。Java 的 Redisson 内置了这种机制,.NET 中需要自己实现,通常可以使用CancellationTokenSource配合一个后台任务,在业务执行期间每隔一段时间调用一次PEXPIRE续期。

不过续期机制也有成本。Redis 官方推荐的RedLock算法是另一种高可用方案,它能容忍单个 Redis 节点故障。要实现 RedLock,需要向多个独立部署的 Redis 节点依次请求锁,超过半数节点设置成功才算获得锁。RedLock 的问题在于实现复杂,且在极端情况下面临时钟漂移、网络分区等争议,实际业务中如果对一致性要求极高,通常还会配合数据库唯一约束兜底。

对于大多数业务场景,单节点 Redis + 合理过期时间 + Lua 释放,已经可以解决 90% 的并发问题。

5. 实战:秒杀接口解决超卖问题

5.1 秒杀需求分析

秒杀接口的需求可以概括为:

  1. 一个商品只能卖出有限数量的库存。
  2. 一个用户只能秒杀一次该商品。
  3. 秒杀成功后,需要写订单记录。

整个流程可以拆成三步:

  • 检查库存是否充足。
  • 检查该用户是否已经购买过该商品。
  • 扣减库存,记录购买关系,写订单。

这三步组合起来就是一个典型的复合业务流,必须保证串行执行,才能避免出现两个请求同时判定"库存充足"。

为了方便演示,这里将数据库写入替换为日志输出,重点展示 Redis 这一层的并发控制。生产项目中,订单表通常写入 MySQL、PostgreSQL 或 SQL Server,核心并发控制思路一致。

5.2 无锁实现为什么不可靠

先用最直白的方式实现一个秒杀逻辑,读者可以直观看到问题所在:

var stock = (int)await _db.StringGetAsync("stock:product:1"); if (stock <= 0) { return "已售罄"; } await Task.Delay(50); // 模拟业务耗时 await _db.StringSetAsync("stock:product:1", stock - 1);

在低并发下,这段代码似乎没有问题。但高并发下,两个请求可能同时读到stock = 1,然后都执行stock - 1,最终库存变成 0 或负数,订单却生成了两份。

问题出在"读取库存"和"扣减库存"之间不是原子的。即使StringSetAsync本身是一条原子命令,从读到写之间的时间窗口依然会产生竞态。

5.3 基于 RedisLock 的秒杀服务

在项目根目录新建Services/SeckillService.cs

using ConcurrencyDemo.Locks; using StackExchange.Redis; namespace ConcurrencyDemo.Services; public record SeckillResult(bool Success, string Message); public class SeckillService { private readonly IDatabase _database; private readonly DistributedLockService _lockService; private readonly ILogger<SeckillService> _logger; public SeckillService( IConnectionMultiplexer connectionMultiplexer, DistributedLockService lockService, ILogger<SeckillService> logger) { _database = connectionMultiplexer.GetDatabase(); _lockService = lockService; _logger = logger; } public async Task InitStockAsync(int productId, int count) { var stockKey = $"stock:product:{productId}"; await _database.StringSetAsync(stockKey, count); _logger.LogInformation("商品 {ProductId} 库存初始化为 {Count}", productId, count); } public async Task<int> GetStockAsync(int productId) { var value = await _database.StringGetAsync($"stock:product:{productId}"); return value.IsNull ? 0 : (int)value; } public async Task<SeckillResult> SeckillAsync(int productId, string userId) { if (string.IsNullOrWhiteSpace(userId)) { return new SeckillResult(false, "用户标识不能为空"); } var lockKey = $"lock:seckill:{productId}"; var redisLock = _lockService.CreateLock(lockKey, TimeSpan.FromSeconds(5)); // 最多等待 3 秒,拿不到锁直接返回失败 var acquired = await redisLock.AcquireAsync(TimeSpan.FromSeconds(3)); if (!acquired) { return new SeckillResult(false, "系统繁忙,请稍后重试"); } try { var stockKey = $"stock:product:{productId}"; var stock = (int)await _database.StringGetAsync(stockKey); if (stock <= 0) { return new SeckillResult(false, "商品已售罄"); } var userKey = $"seckill:user:{productId}:{userId}"; var isFirst = await _database.StringSetAsync( userKey, DateTimeOffset.Now.ToString("O"), TimeSpan.FromDays(1), When.NotExists); if (!isFirst) { return new SeckillResult(false, "您已经参与过该商品的秒杀"); } await _database.StringIncrementAsync(stockKey, -1); // 生产环境中,这里应写入订单表 _logger.LogInformation( "用户 {UserId} 秒杀商品 {ProductId} 成功,剩余库存 {Stock}", userId, productId, stock - 1); return new SeckillResult(true, "抢购成功"); } catch (Exception ex) { _logger.LogError(ex, "秒杀异常:ProductId={ProductId}, UserId={UserId}", productId, userId); return new SeckillResult(false, "系统异常,请稍后重试"); } finally { await redisLock.ReleaseAsync(); } } }

这段代码的核心是锁住了整个秒杀流程。锁 Key 使用lock:seckill:{productId},意思是锁的粒度是"商品维度"。同一商品的秒杀请求会串行执行,不同商品的秒杀请求互不影响。这个粒度设计在秒杀场景非常关键,如果所有商品共用一把锁,会导致不同商品的请求互相等待,吞吐量大幅下降。

接下来注册 SeckillService 并添加控制器:

builder.Services.AddScoped<SeckillService>();
using ConcurrencyDemo.Services; using Microsoft.AspNetCore.Mvc; namespace ConcurrencyDemo.Controllers; [ApiController] [Route("api/[controller]")] public class SeckillController : ControllerBase { private readonly SeckillService _seckillService; public SeckillController(SeckillService seckillService) { _seckillService = seckillService; } [HttpPost("init")] public async Task<IActionResult> InitStock(int productId, int count) { await _seckillService.InitStockAsync(productId, count); return Ok(new { message = "库存初始化成功", productId, count }); } [HttpPost("{productId}")] public async Task<IActionResult> Seckill(int productId, string userId) { var result = await _seckillService.SeckillAsync(productId, userId); return result.Success ? Ok(result) : BadRequest(result); } [HttpGet("stock/{productId}")] public async Task<IActionResult> GetStock(int productId) { var stock = await _seckillService.GetStockAsync(productId); return Ok(new { productId, stock }); } }

启动项目后,按顺序调用:

POST /api/Seckill/init?productId=1&count=10 POST /api/Seckill/1?userId=user001 POST /api/Seckill/1?userId=user002

第一次调用返回"抢购成功",第二次调用返回"您已经参与过该商品的秒杀"。

5.4 更优方案:Lua 脚本原子扣减

使用分布式锁虽然解决了超卖,但带来了一个副作用:请求需要排队,吞吐量受锁等待时间影响。在秒杀场景中,如果商品库存只有几十件,但请求量每秒有几万,大量线程都会阻塞在AcquireAsync的轮询上。

更极致的做法是,将"检查库存、检查用户、扣库存、记录用户"这四条 Redis 操作合并到一个 Lua 脚本中执行。由于 Redis 在执行脚本期间不会处理其他命令,Lua 脚本天然就是原子的,根本不需要分布式锁。

public async Task<SeckillResult> SeckillWithLuaAsync(int productId, string userId) { const string script = """ -- KEYS[1]: 库存 Key -- KEYS[2]: 用户购买记录 Key -- ARGV[1]: 用户ID -- 返回值:0 表示售罄,1 表示成功,2 表示重复购买 if redis.call('exists', KEYS[2]) == 1 then return 2 end local stock = tonumber(redis.call('get', KEYS[1]) or '0') if stock <= 0 then return 0 end redis.call('decrby', KEYS[1], 1) redis.call('set', KEYS[2], 1, 'EX', 86400) return 1 """; var result = (long)await _database.ScriptEvaluateAsync( script, new RedisKey[] { $"stock:product:{productId}", $"seckill:user:{productId}:{userId}" }, new RedisValue[] { userId }); return result switch { 0 => new SeckillResult(false, "商品已售罄"), 1 => new SeckillResult(true, "抢购成功"), 2 => new SeckillResult(false, "您已经参与过该商品的秒杀"), _ => new SeckillResult(false, "系统异常") }; }

Lua 方案的优势非常明显:不需要等待锁、不需要释放锁、没有锁超时续期问题,性能和正确性都优于分布式锁方案。

但它有一个前提:业务逻辑必须完全收敛在 Redis 内部执行。如果扣减库存后还要写 MySQL 订单表,写订单这个动作无法放进 Redis 脚本里,此时分布式锁仍然有用武之地。因此,Lua 脚本和分布式锁并不是互斥关系,而是适用于不同的业务边界。

5.5 并发验证与结果分析

本地可以用多线程并发调用来模拟压测,观察 Redis 中的库存变化:

public async Task TestSeckillConcurrencyAsync() { var tasks = new List<Task<SeckillResult>>(); for (int i = 0; i < 1000; i++) { var userId = $"user{i:000}"; tasks.Add(SeckillAsync(1, userId)); } var results = await Task.WhenAll(tasks); var successCount = results.Count(r => r.Success); var failCount = results.Length - successCount; _logger.LogInformation("成功 {SuccessCount},失败 {FailCount}", successCount, failCount); }

库存初始为 10,理论上有且仅有 10 个用户能秒杀成功。运行多次后,如果成功数量始终不超过 10,说明超卖问题已被解决。

在压测过程中,要注意观察两点:

  • 库存不能为负数。如果出现负数,说明两个请求同时进入到了扣减库存的代码块。
  • 同一用户不能秒杀多次。如果同一用户重复成功,说明用户去重逻辑失效。

6. 实战:防重复请求与接口幂等

6.1 重复请求的常见来源

接口被重复调用,在高并发项目中非常常见,主要来源包括:

  • 前端按钮没有做提交锁,用户快速点击多次。
  • 前端调用接口后网络超时,用户刷新页面重新提交。
  • 后端业务执行成功,但响应报文丢失,客户端重试。
  • 消息队列消费场景中,同一条消息被重复投递。

以创建订单为例,如果接口不做防重处理,客户端超时重试会导致生成多笔相同订单,给用户造成重复扣款。

6.2 基于 SETNX 的防重设计

防重复请求与分布式锁本质上是同一个原语:SETNX

客户端在创建请求时生成一个requestId,服务端以requestId为 key 调用SETNX。如果设置成功,说明这是第一次请求,执行真正的下单逻辑;如果设置失败,说明之前已经有相同requestId的请求进来了,直接拦截。

和秒杀场景的锁不同,防重 key 不应该在业务结束后立即删除,而是需要保留一段时间。否则请求 B 在请求 A 刚结束、还没返回响应时再次请求,SETNX又会成功,防重就失效了。

所以防重 key 的过期时间应覆盖"从请求开始到客户端收到响应"的最长周期,比如 5 分钟。

6.3 创建订单接口完整实现

新建请求模型:

namespace ConcurrencyDemo.Models; public class CreateOrderRequest { public string RequestId { get; init; } = ""; public string ProductId { get; init; } = ""; public int Count { get; init; } = 1; }

新建Controllers/OrderController.cs

using ConcurrencyDemo.Models; using Microsoft.AspNetCore.Mvc; using StackExchange.Redis; namespace ConcurrencyDemo.Controllers; [ApiController] [Route("api/[controller]")] public class OrderController : ControllerBase { private readonly IDatabase _database; private readonly ILogger<OrderController> _logger; public OrderController(IConnectionMultiplexer connectionMultiplexer, ILogger<OrderController> logger) { _database = connectionMultiplexer.GetDatabase(); _logger = logger; } [HttpPost("create")] public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request) { if (string.IsNullOrEmpty(request.RequestId)) { return BadRequest(new { message = "requestId 不能为空" }); } var idempotentKey = $"idempotent:order:{request.RequestId}"; var lockValue = Guid.NewGuid().ToString("N"); var acquired = await _database.StringSetAsync( idempotentKey, lockValue, TimeSpan.FromMinutes(5), When.NotExists); if (!acquired) { return Conflict(new { message = "重复请求,请勿多次提交", requestId = request.RequestId }); } try { // 模拟创建订单 var orderId = Guid.NewGuid().ToString("N"); _logger.LogInformation( "创建订单成功,OrderId={OrderId}, RequestId={RequestId}, ProductId={ProductId}, Count={Count}", orderId, request.RequestId, request.ProductId, request.Count); return Ok(new { orderId, message = "下单成功" }); } catch (Exception ex) { _logger.LogError(ex, "创建订单失败, RequestId={RequestId}", request.RequestId); // 业务失败时删除防重 key,允许客户端重新提交 await _database.KeyDeleteAsync(idempotentKey); return StatusCode(500, new { message = "创建订单失败" }); } } }

调用方式:

POST /api/Order/create Content-Type: application/json { "requestId": "a1b2c3d4-0001", "productId": "P001", "count": 1 }

第一次调用返回成功,第二次使用相同requestId调用,会返回 409 Conflict。如果第一次请求因为业务异常失败,防重 key 会被删除,此时客户端可以重新提交。

这里的细节和秒杀锁有些差异:秒杀锁是"拿到锁 -> 执行业务 -> 释放锁",防重 key 是"设置成功 -> 执行业务 -> 保留 key 直到过期",更贴近"token 防重"方案。两种方式本质相同,但过期策略不同,需要根据业务场景区分使用。

7. 常见问题与排查思路

问题现象常见原因解决思路
连接超时,无法访问 RedisRedis 服务未启动、端口被占用、防火墙拦截先用redis-cli ping测试,再检查端口和进程
StackExchange.Redis 高并发时抛出超时异常连接池不足、Redis 执行慢命令、网络抖动连接串添加abortConnect=false,排查慢命令,考虑扩大连接池
获取锁时死等,接口响应慢轮询间隔太小,业务执行耗时长设置等待超时上限,拿不到锁直接快速失败
多个请求都拿到了锁锁过期时间太短,业务没有执行完设置合理过期时间,或实现续期机制
释放锁时误删了别人的锁没有校验 value 直接调用DEL使用 Lua 脚本先比较 value 再删除
程序异常后锁一直不释放没有使用 finally,或将 release 放在 return 之前使用try-finallyIAsyncDisposable
库存仍然出现超卖分布式锁没有覆盖完整业务流确认"查库存+扣库存+写订单"全部在锁内执行
Redis 内存持续增长key 没有设置过期时间给锁 key、防重 key、缓存 key 统一设置 TTL

如果遇到秒杀接口已经加锁但还是超卖,最常见的排查思路是确认锁的粒度是否正确。检查业务方法中是否还有别的地方绕过锁直接操作了库存 key,例如后台管理功能、定时任务、报表统计中直接执行了库存修改语句,这类场景容易被忽略。

如果使用了 Redis 主从或哨兵架构,还需要考虑主节点故障时锁数据是否已经同步到从节点。在极端情况下,主节点宕机后从节点升主,新主节点可能没有锁数据,导致其他客户端重新获取到锁。这时候要么接受极小概率的并发冲突,通过数据库唯一约束兜底,要么使用 RedLock 这类严格多节点方案。

8. 最佳实践与生产建议

8.1 优先缩小锁粒度

分布式锁的本质是让并发请求排队,锁粒度越大,排队时间越长,系统吞吐量越低。在秒杀场景中,锁 Key 至少应该包含商品 ID,也就是lock:seckill:{productId}。如果把所有商品都放到同一把锁里,一个商品库存被抢光后,其他商品的请求也会被无限阻塞。

如果业务逻辑允许,还可以把锁粒度细化到用户维度,例如lock:user:{userId}:{productId},但要注意,如果商品库存本身只有几十件,用户粒度的锁可能导致同一用户多次抢购时所有请求都进场,最终仍然在商品维度上串行。

8.2 把能放进 Redis 的逻辑尽量原子化

这是我在实际项目中感受最深的一条经验。分布式锁虽然好用,但它的正确性依赖锁超时、续期、异常释放等细节。如果业务核心操作能完全收敛到 Redis 内部,优先使用 Lua 脚本而不是分布式锁。

一个典型的判断标准是:操作需要修改多个 Redis key,并要求整体原子性。此时 Lua 脚本是最合适的工具。比如商品库存、用户购买记录、限购数量这几个 key,可以通过一个脚本完成所有修改,既没有锁,也不存在锁超时问题。

只有当业务需要跨越多个存储系统,例如同时操作 Redis 和 MySQL,或者在 Redis 操作后还要调用远程接口时,分布式锁才更有价值。

8.3 生产环境的异常处理与监控

分布式锁直接暴露在业务代码中,最常见的问题是"忘记释放锁"。我建议在业务代码中一定使用try-finally,或者让RedisLock实现IAsyncDisposable,配合await using语法。同时要给锁设置兜底过期时间,即使代码出现异常或服务宕机,锁也能在几秒后自动释放,不会造成永久死锁。

监控方面至少要看三个指标:

  • 获取锁失败率。如果失败率高,说明锁竞争激烈或等待超时设置不合理。
  • 锁等待耗时。如果大量请求需要等待 1 秒以上,需要评估锁粒度是否需要拆分。
  • Redis 内存增长趋势。锁 key 和防重 key 如果没有清理,会持续占用内存。

8.4 数据库层不要裸奔

分布式锁和 Lua 脚本解决的是"并发扣减"这一层的冲突。但在真正的订单系统中,订单表最终还是要写入数据库。即使 Redis 层控制得再好,数据库本身也可能因为连接数过高、主键冲突、唯一索引冲突等原因出现问题。

推荐的思路是"多层防线":

  • Redis 层用 Lua 脚本做原子扣减和用户去重。
  • 数据库层为订单表设计创建时间、用户 ID、商品 ID 的联合唯一索引。
  • 如果出现极端情况导致 Redis 判断失误,数据库的唯一索引也能阻止重复订单。

这样即使 Redis 层出现 bug,数据库仍然能兜底。

8.5 AI 辅助开发时的代码审查清单

现在用 AI 生成分布式锁、秒杀、幂等接口的代码已经非常普遍,但 AI 生成的代码往往只覆盖了"主流程",很容易漏掉异常路径。这里整理一份适合放到代码审查阶段的检查清单:

  • 锁是否设置了过期时间?
  • 释放锁是否使用了 Lua 脚本校验 value?
  • 锁是否放在finally中释放?
  • 锁粒度是否最小化?
  • 秒杀流程中的"查库存、扣库存、写订单"是否全部在锁内?
  • 防重 key 是否设置了合理的过期时间?
  • 业务失败时防重 key 是否会删除?
  • 高并发压测下是否出现过库存负数、重复订单?

如果 AI 生成的代码无法清晰回答这些问题,不要直接提交。让 AI 补充注释和单元测试,再逐项核对,才能避免生产事故。

9. 小结与下一步

这篇文章从高并发后端最常见的三个问题入手,完整实现了 .NET 10 WebAPI 集成 Redis 的过程,并基于 Redis 的SET NX EX命令实现了一个可用的分布式锁。秒杀实战展示了如何在锁的保护下正确完成"查库存、判用户、扣库存、写订单",同时给出了不用锁、直接用 Lua 脚本做原子扣减的更高性能方案。防重复请求实战则演示了如何用同一个SETNX原语解决接口幂等和订单防重问题。

下一步可以继续深入的方向包括:Redis 持久化策略对分布式锁数据安全的影响、Redis 哨兵和集群部署下的故障转移表现、以及如何结合消息队列削峰填谷,进一步降低秒杀接口的瞬时压力。建议读者把本文的 SeckillService 和 OrderController 部署到本地环境,用并发工具模拟 1000 个请求,实际观察 Redis 中的库存变化,这个过程比只看理论更能加深对分布式锁的理解。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 13:24:38

ComfyUI V9.5中文整合包:全界面汉化与中文提示词优化指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 13:24:18

免费AI编程工具真实评测:从代码补全到选型避坑全指南

1. 免费AI编程工具的真实门槛 先说结论&#xff1a;市面上的AI编程工具确实有不少免费选项&#xff0c;但"免费"两个字背后藏着很多你一开始看不到的限制。我过去半年把主流产品基本都试了一遍&#xff0c;从GitHub Copilot免费版到国内的通义灵码、Codeium、Continu…

作者头像 李华
网站建设 2026/9/8 13:23:29

电机MRAC自适应控制仿真:MATLAB/Simulink模型搭建与参数整定实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 13:21:38

用模拟器和SGDK构建MD自制游戏:从ROM运行到源码编译

这次我们来看一个不是大模型、不占显存、也不吃显卡的项目&#xff1a;世嘉 Mega Drive 平台的玩家自制游戏《机器战警》MD版。欧美玩家通常把 MD 叫 Genesis&#xff0c;所以标题里的 [Genesis] 指的就是同一种机器。它的本质是 homebrew 社区里非常典型的产物——作者用 MD 平…

作者头像 李华
网站建设 2026/9/8 13:19:06

Python搭建可扩展BI流水线:从数据清洗到自动化报告全实战

1. 项目概述与核心需求拆解1.1 为什么我最终选择了Python来搭这套BI流水线这几年做数据相关工作&#xff0c;有个感受越来越强烈&#xff1a;业务方要的东西变化太快了。今天要看销售漏斗&#xff0c;明天要分析用户流失&#xff0c;后天又想把库存周转率和天气数据放一起看。传…

作者头像 李华
网站建设 2026/9/8 13:17:40

基于微信小程序的中医养生智慧系统(源码+文档+讲解视频)

温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台…

作者头像 李华