ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/9/8 13:24:58 拓冰建站 浏览量
.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 被负载均衡分发到实例 1Request 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 5Redis 发现该 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 WebAPISpring Data Redis / LettuceStackExchange.RedisRedisson 分布式锁自己封装 RedisLockMaven / GradleNuGetNacos 配置中心Configuration Options 模式在 AI 辅助开发的背景下Spring Boot 和 .NET WebAPI 的样板代码生成效率已经没太大差别脚手架、CRUD、Redis 基础操作几乎都能让 AI 一次性生成。但分布式锁、超卖、幂等这类型的问题恰恰是 AI 最容易出错的地方因为它们涉及原子性超时异常路径等边界条件。这也是本篇文章要重点拆解原理和代码的原因。2. 环境准备安装 Redis 与创建 .NET10 WebAPI 项目2.1 版本与环境说明本文的开发环境如下操作系统Windows 11 / Windows ServerLinux 服务器同样适用开发工具Visual Studio 202217.12 以上版本或 Visual Studio Code框架版本.NET 10 SDKRedis 版本Redis 7.xRedis 客户端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 集成 Redis3.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 连接字符串。这里使用了abortConnectfalse这个配置项的意义是即使 Redis 暂时不可用也允许应用继续启动并在 Redis 恢复后自动重连避免因为缓存中间件故障导致整个网站启动失败。{ ConnectionStrings: { Redis: localhost:6379,abortConnectfalse,defaultDatabase0 }, 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.AddSingletonIConnectionMultiplexer(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.csusing 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 Taskstring? GetStringAsync(string key) { var value await _database.StringGetAsync(key); return value.IsNullOrEmpty ? null : value.ToString(); } public async Tasklong IncrementAsync(string key, long value 1) { return await _database.StringIncrementAsync(key, value); } public async Taskbool SetNxAsync(string key, string value, TimeSpan? expiry null) { return await _database.StringSetAsync(key, value, expiry, When.NotExists); } public async Taskbool DeleteAsync(string key) { return await _database.KeyDeleteAsync(key); } }这里的方法都是对IDatabase的二次封装。StringIncrementAsync是 Redis 的INCR命令因为 Redis 单线程执行命令所以单条INCRBY本身是原子安全的。SetNxAsync对应的是SET key value NX EX它是后面实现分布式锁的基础命令。注册 RedisServicebuilder.Services.AddScopedRedisService();因为 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 TaskIActionResult Set(string key, string value) { await _redis.SetStringAsync(key, value, TimeSpan.FromMinutes(5)); return Ok(new { message 写入成功, key, value }); } [HttpGet(get/{key})] public async TaskIActionResult Get(string key) { var value await _redis.GetStringAsync(key); return Ok(new { key, value }); } }启动项目后调用POST /api/Redis/set?keyhellovalueworld 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.csusing 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 Taskbool AcquireAsync() { return await _database.StringSetAsync(_lockKey, _lockValue, _expiry, When.NotExists); } /// summary /// 在指定时间内不断重试获取锁 /// /summary public async Taskbool 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 Taskbool 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.AddSingletonDistributedLockService();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.csusing 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 ILoggerSeckillService _logger; public SeckillService( IConnectionMultiplexer connectionMultiplexer, DistributedLockService lockService, ILoggerSeckillService 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 Taskint GetStockAsync(int productId) { var value await _database.StringGetAsync($stock:product:{productId}); return value.IsNull ? 0 : (int)value; } public async TaskSeckillResult 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.AddScopedSeckillService();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 TaskIActionResult InitStock(int productId, int count) { await _seckillService.InitStockAsync(productId, count); return Ok(new { message 库存初始化成功, productId, count }); } [HttpPost({productId})] public async TaskIActionResult Seckill(int productId, string userId) { var result await _seckillService.SeckillAsync(productId, userId); return result.Success ? Ok(result) : BadRequest(result); } [HttpGet(stock/{productId})] public async TaskIActionResult GetStock(int productId) { var stock await _seckillService.GetStockAsync(productId); return Ok(new { productId, stock }); } }启动项目后按顺序调用POST /api/Seckill/init?productId1count10 POST /api/Seckill/1?userIduser001 POST /api/Seckill/1?userIduser002第一次调用返回抢购成功第二次调用返回您已经参与过该商品的秒杀。5.4 更优方案Lua 脚本原子扣减使用分布式锁虽然解决了超卖但带来了一个副作用请求需要排队吞吐量受锁等待时间影响。在秒杀场景中如果商品库存只有几十件但请求量每秒有几万大量线程都会阻塞在AcquireAsync的轮询上。更极致的做法是将检查库存、检查用户、扣库存、记录用户这四条 Redis 操作合并到一个 Lua 脚本中执行。由于 Redis 在执行脚本期间不会处理其他命令Lua 脚本天然就是原子的根本不需要分布式锁。public async TaskSeckillResult 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 ListTaskSeckillResult(); 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.csusing 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 ILoggerOrderController _logger; public OrderController(IConnectionMultiplexer connectionMultiplexer, ILoggerOrderController logger) { _database connectionMultiplexer.GetDatabase(); _logger logger; } [HttpPost(create)] public async TaskIActionResult 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 执行慢命令、网络抖动连接串添加abortConnectfalse排查慢命令考虑扩大连接池获取锁时死等接口响应慢轮询间隔太小业务执行耗时长设置等待超时上限拿不到锁直接快速失败多个请求都拿到了锁锁过期时间太短业务没有执行完设置合理过期时间或实现续期机制释放锁时误删了别人的锁没有校验 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 中的库存变化这个过程比只看理论更能加深对分布式锁的理解。