后端开发做到一定阶段,几乎都会遇到这类需求:秒杀、抢券、预约报名。业务逻辑本身并不复杂——查库存、判断状态、扣减库存、写订单,但一旦把服务从单机部署扩展成多台实例,原本好用的内存锁就全部失效了。这也是很多团队从单体架构转向分布式后遇到的第一道坎。
本文围绕 .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 同时发起秒杀请求,并且请求被分发到两台不同的服务器。
两个请求都执行了下面的流程:
- 从数据库或缓存中读取当前库存,发现库存为 10。
- 判断库存大于 0,允许购买。
- 执行库存减一操作,库存变成 9。
- 写入订单记录。
这样的流程在没有并发控制时,即使只有一个实例,也可能出现问题,因为在"读取库存"和"扣减库存"之间存在时间窗口。更不用说在集群环境下,多个实例同时执行这段代码,超卖几乎是必然的。
真正严谨的说法是:"查库存 + 扣库存 + 写订单"不是 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 Web | ASP.NET Core WebAPI |
| Spring Data Redis / Lettuce | StackExchange.Redis |
| Redisson 分布式锁 | 自己封装 RedisLock |
| Maven / Gradle | NuGet |
| 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,创建项目的步骤非常简单:
- 打开 Visual Studio,选择"创建新项目"。
- 选择"ASP.NET Core Web API"模板。
- 项目名称填写
ConcurrencyDemo。 - 框架选择 .NET 10.0。
- 点击"创建"。
如果不喜欢图形界面,也可以直接使用命令行:
dotnet new webapi -n ConcurrencyDemo cd ConcurrencyDemo dotnet run创建完成后,项目结构是这样的:
ConcurrencyDemo/ ├── Properties/ │ └── launchSettings.json ├── Controllers/ │ └── WeatherForecastController.cs ├── Program.cs ├── ConcurrencyDemo.csproj └── appsettings.json接下来,我们会在Controllers下新增业务控制器,在项目根目录新增Services和Locks两个文件夹,分别存放 Redis 服务封装和分布式锁实现。
3. .NET10 WebAPI 集成 Redis
3.1 添加 NuGet 包
在项目中使用 Redis,通常需要两个包。
第一个是StackExchange.Redis,它是 .NET 生态最主流的 Redis 客户端,提供ConnectionMultiplexer、IDatabase等核心 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 内部没有缓存状态,所以用AddScoped或AddSingleton都可以,这里按作用域注册更符合依赖注入习惯。
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 5NX表示只有当 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 秒杀需求分析
秒杀接口的需求可以概括为:
- 一个商品只能卖出有限数量的库存。
- 一个用户只能秒杀一次该商品。
- 秒杀成功后,需要写订单记录。
整个流程可以拆成三步:
- 检查库存是否充足。
- 检查该用户是否已经购买过该商品。
- 扣减库存,记录购买关系,写订单。
这三步组合起来就是一个典型的复合业务流,必须保证串行执行,才能避免出现两个请求同时判定"库存充足"。
为了方便演示,这里将数据库写入替换为日志输出,重点展示 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. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 连接超时,无法访问 Redis | Redis 服务未启动、端口被占用、防火墙拦截 | 先用redis-cli ping测试,再检查端口和进程 |
| StackExchange.Redis 高并发时抛出超时异常 | 连接池不足、Redis 执行慢命令、网络抖动 | 连接串添加abortConnect=false,排查慢命令,考虑扩大连接池 |
| 获取锁时死等,接口响应慢 | 轮询间隔太小,业务执行耗时长 | 设置等待超时上限,拿不到锁直接快速失败 |
| 多个请求都拿到了锁 | 锁过期时间太短,业务没有执行完 | 设置合理过期时间,或实现续期机制 |
| 释放锁时误删了别人的锁 | 没有校验 value 直接调用DEL | 使用 Lua 脚本先比较 value 再删除 |
| 程序异常后锁一直不释放 | 没有使用 finally,或将 release 放在 return 之前 | 使用try-finally或IAsyncDisposable |
| 库存仍然出现超卖 | 分布式锁没有覆盖完整业务流 | 确认"查库存+扣库存+写订单"全部在锁内执行 |
| 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 中的库存变化,这个过程比只看理论更能加深对分布式锁的理解。