ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

.NET 10 + Redis 实现分布式锁:解决集群并发超卖与重复请求

.NET 10 + Redis 实现分布式锁:解决集群并发超卖与重复请求 “库存明明设置的是 100 件后台订单却出了 500 单”——这是电商后台开发里最典型的并发事故。问题不在查库逻辑写错而在于多个请求在同一个时刻读到了相同的剩余库存然后各自执行扣减最终把数据库的库存扣成了负数。单体应用时代这个问题可以用数据库行锁或者进程内锁压住但服务一旦拆成多个副本比如在 K8s 里跑 2 个以上 Pod进程内的锁就完全失效了——实例 A 的锁管不住实例 B 的请求。真正需要的是跨进程、跨实例的互斥机制也就是分布式锁。这篇文章要讲的是用 .NET 10 构建 WebAPI 后端服务时如何整合 Redis 实现分布式锁解决集群并发冲突、库存超卖和重复请求三类经典问题。全文会给出一个可以直接跑通的最小完整项目包括项目创建、Redis 接入、分布式锁工具类、库存扣减、防重复下单、并发验证和常见问题排查。另外特别想提醒一点AI 辅助开发越来越普及Copilot 可以帮你快速生成加锁、释放锁的骨架代码但它很难替你判断锁过期、误删、主从切换这些边界问题。并发代码恰恰是最需要人工把关的部分也是本文想重点讲透的地方。1. 这篇文章真正要解决的问题1.1 三个典型并发问题先明确本文要解决的三个问题后面所有代码都围绕它们展开。第一是超卖。比如商品库存剩余 10 件同时来了 20 个购买请求最终有 15 个请求都以为自己扣减成功。超卖的本质是“读取库存 - 判断库存 - 扣减库存”这三步不是原子的。如果中间插入了并发请求就会基于同一条旧数据做判断。第二是重复请求。用户手抖点了两次提交或者前端做了重试同一个订单号被提交了多次。系统如果没有幂等保护就会生成多笔订单。这类问题在网关超时重试、消息队列重复消费的场景下同样会出现。第三是集群并发冲突。与单机不同集群环境下每个服务实例的内存是隔离的。进程内的 lock、Monitor、SemaphoreSlim 只能约束当前实例约束不了其他副本。只要请求被负载均衡分发到不同实例这种本地锁就等于没锁。1.2 为什么本地锁在集群环境下失效很多后端开发第一次接触分布式锁时会有一个疑问我明明在代码里用了 lock 关键字为什么还会超卖原因很简单lock 锁住的是当前 CLR 进程内的对象。如果你部署了 3 个实例每个实例各自有一把锁3 个请求分别落到 3 个实例上它们可以同时通过锁的保护区域。就像 3 间教室各有一道门每道门都上了锁但 3 个学生分别进了 3 间教室互不干扰也就无法互斥。所以集群环境下的互斥必须依赖所有实例都能访问到的“公共协调者”比如 Redis、ZooKeeper、etcd。Redis 因为部署简单、性能高成为最常用的分布式锁存储。1.3 AI 辅助开发最容易忽略的并发边界用 AI 写并发代码有一个典型场景你给 Copilot 输入“用 StackExchange.Redis 实现一个分布式锁”它几秒钟就能生成一个看起来完整、能编译通过的代码。但问题往往藏在边界里释放锁时有没有校验当前线程是否仍然持锁如果业务执行时间超过锁过期时间锁提前失效了怎么办Redis 主从切换时持有的锁会不会丢失加锁成功后程序异常退出锁有没有兜底的过期时间这些边界处理得好不好才是分布式锁在生产环境能不能用的关键。AI 可以当脚手架但最终责任人是你自己。2. Redis 分布式锁基础原理2.1 分布式锁的本质分布式锁的本质是在多个进程都能访问的公共存储上占一个只有自己能释放的“坑位”。拿到坑位的请求可以安全地执行临界区代码其他请求只能等待、重试或失败返回。用 Redis 实现分布式锁核心是利用 Redis 单线程执行命令的特性。Redis 的单个命令是原子的不会出现多个客户端同时写入同一个 key 的中间状态。分布式锁正是建立在这个原子性之上。2.2 SET NX EX一条命令完成原子加锁Redis 中加锁的标准命令是SET lock:product:A1001 8f6c2b4e-d1a3-4b2e-9a1f-3c5d6e7f8a9b NX PX 10000参数解释NX表示只有当 key 不存在时才设置成功相当于“占坑”。PX 10000给锁设置 10 秒过期时间防止持有锁的进程崩溃后锁永远不释放。Value 用一个随机 token如 GUID用于释放锁时校验身份。这条命令必须放在一起执行原因是“判断 key 是否存在”和“写入 key”两步必须原子。如果分开执行先判断再写入中间就可能被其他请求插队。对应到 StackExchange.Redis代码是这样var token Guid.NewGuid().ToString(N); bool acquired await db.StringSetAsync(lockKey, token, TimeSpan.FromSeconds(10), When.NotExists);返回 true 表示加锁成功返回 false 表示锁已被其他请求持有。2.3 释放锁为什么要用 Lua 脚本释放锁的直觉写法是直接删除 keyawait db.KeyDeleteAsync(lockKey);但这里有一个隐患如果锁已经过期了而当前线程业务还没执行完此时另一个线程拿到了同一把锁。原来的线程执行完业务后执行删除操作就会把别人持有的锁误删掉。解决方法是删除前先比较 Value 是否还是自己写入的 token如果是才删除。但“比较”和“删除”这两步也必须原子所以要用 Redis 的 Lua 脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本先用 KEYS[1] 拿到锁当前的值和 ARGV[1] 传入的 token 对比一致才删除。整个过程在 Redis 内部原子执行不会出现“刚判断完、还没来得及删锁就被别人抢走”的情况。2.4 原子命令与分布式锁的分工这里要澄清一个很多人容易混淆的点并不是所有并发问题都需要分布式锁。如果业务场景可以设计成“一条原子命令完成”那就优先用原子命令。比如库存直接放 Redis扣减时直接用 INCRBY 扣减负数INCRBY stock:A1001 -1这条命令本身是原子操作多个请求同时执行也不会超卖根本不需要加锁。那什么时候需要锁当你的临界区不是单条命令而是一段由“读 - 判断 - 写 - 调用外部服务 - 记录日志”组合而成的工作单元时原子命令就兜不住了。锁的作用是把整个工作单元保护起来保证同一时刻只有一个请求在执行这个完整流程。比如扣减库存后还要同时计算用户积分、发送消息通知、记录风控日志。这些步骤跨多个存储和中间件不可能全部塞进一个 Redis 脚本于是就需要分布式锁来保证互斥。3. 环境准备与前置条件3.1 开发环境清单开始实操之前先准备好环境。下面是本文示例使用的环境版本不完全相同不影响理解API 基本一致。组件说明操作系统Windows 11 或 Linux/macOS.NET SDK.NET 10 或 .NET 8/9本文示例遵循通用 ASP.NET Core WebAPI 编程模型RedisRedis 7.x本文演示使用 Docker 启动IDEVisual Studio 2022 或 JetBrains Rider 或 VS Code客户端库StackExchange.RedisNuGet说明一下版本问题.NET 10 是 .NET 9 之后的下一个长期支持版本延续了云原生、性能优化的演进方向。如果你本机还没有 .NET 10 SDK用 .NET 8 或 .NET 9 运行本文代码也没有问题因为文中使用的都是 ASP.NET Core WebAPI 的基础能力不依赖特定版本的新 API。3.2 安装 RedisRedis 官方原生支持 Linux。Windows 下通常有三种方式方式一使用 Docker推荐docker run -d --name redis -p 6379:6379 redis:7启动后用 docker ps 确认容器运行状态。这种方式最干净不会污染 Windows 环境。方式二使用 WSL2在 WSL2 中执行sudo apt update sudo apt install redis-server sudo service redis-server start方式三使用 Windows 第三方移植版本Redis 官方目前没有提供 Windows 原生安装包。社区常用的是 tporadowski/redis 这类移植版本但版本通常较老建议只在本地开发测试时使用。启动完成后验证一下redis-cli ping返回 PONG 就代表 Redis 正常工作。3.3 创建 .NET WebAPI 项目打开终端执行下面的命令dotnet new webapi -n WebApiDemo cd WebApiDemo这行命令会生成一个标准的 ASP.NET Core WebAPI 项目。项目结构包含 Controllers、Program.cs、appsettings.json 等核心文件。如果使用 Visual Studio 2022也可以通过“新建项目 - ASP.NET Core Web API”创建模板效果一致。创建时可以不勾选“使用控制器”因为本文需要自行控制结构。3.4 引入 StackExchange.Redis在项目根目录执行dotnet add package StackExchange.RedisStackExchange.Redis 是 .NET 社区最主流的 Redis 客户端也是 Azure Redis 官方推荐的客户端库。它支持连接池、异步操作、集群、哨兵等高级特性足够支撑本文的分布式锁实现。安装完成后可以通过查看 csproj 文件确认依赖已经写入Project SdkMicrosoft.NET.Sdk.Web PropertyGroup TargetFrameworknet10.0/TargetFramework Nullableenable/Nullable ImplicitUsingsenable/ImplicitUsings /PropertyGroup ItemGroup PackageReference IncludeStackExchange.Redis Version2.8.16 / /ItemGroup /Project如果你使用的 .NET SDK 版本不同TargetFramework 会对应变化这不影响后续代码。4. 搭建项目骨架Redis 连接与配置4.1 配置 Redis 连接字符串打开 appsettings.json添加 Redis 连接配置{ ConnectionStrings: { Redis: localhost:6379,abortConnectfalse }, Logging: { LogLevel: { Default: Information, Microsoft.AspNetCore: Warning } }, AllowedHosts: * }参数说明localhost:6379Redis 地址和默认端口。abortConnectfalse即使初始化时 Redis 不可用也不立即抛出异常后续重试连接。这个参数对开发环境很友好但生产环境建议设置为 true让启动阶段就暴露连接问题。如果 Redis 设置了密码连接字符串写成Redis: localhost:6379,passwordyourpassword,abortConnectfalse4.2 注册 Redis 服务修改 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 config builder.Configuration.GetConnectionString(Redis); return ConnectionMultiplexer.Connect(config!); }); var app builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseAuthorization(); app.MapControllers(); app.Run();用 AddSingleton 注册 IConnectionMultiplexer 是推荐做法。ConnectionMultiplexer 内部维护了连接池是整个进程共享的不应该每个请求都新建一个。4.3 验证 Redis 连接写一个最简单的接口验证连接状态。这里顺便体验一下 ASP.NET Core 极简 API 的写法。在 Program.cs 的 app.Run() 之前添加app.MapGet(/redis/health, async (IConnectionMultiplexer redis) { var db redis.GetDatabase(); await db.StringSetAsync(health:check, ok, TimeSpan.FromSeconds(30)); var value await db.StringGetAsync(health:check); return Results.Ok(new { key health:check, value value.ToString() }); });启动后访问 /redis/health返回类似结果说明 Redis 连接和读写都正常{ key: health:check, value: ok }这里要注意GetDatabase() 返回的 IDatabase 是轻量对象每次调用获取即可不需要缓存。5. 实现 Redis 分布式锁工具类5.1 锁的核心要求一个可用的分布式锁至少要满足四个条件互斥性同一时刻只有一个客户端持有锁。原子性加锁的“判断 写入”和释放锁的“校验 删除”都必须全套原子操作。防死锁锁必须有过期时间客户端崩溃后锁能自动释放。身份校验释放锁时只能释放自己持有的锁不能误删别人的锁。下面实现的 RedisLock 类会同时覆盖这四个条件。5.2 RedisLock 完整代码在项目中新建文件Infrastructure/Locks/RedisLock.csusing StackExchange.Redis; namespace WebApiDemo.Infrastructure.Locks; public sealed class RedisLock : IAsyncDisposable { private readonly IDatabase _db; private readonly string _key; private readonly string _token; private readonly TimeSpan _expiry; private bool _locked; private RedisLock(IDatabase db, string key, string token, TimeSpan expiry) { _db db; _key key; _token token; _expiry expiry; } /// summary /// 尝试获取分布式锁获取失败返回 null /// /summary public static async TaskRedisLock? AcquireAsync( IDatabase db, string key, TimeSpan expiry, CancellationToken cancellationToken default) { var token Guid.NewGuid().ToString(N); bool acquired await db.StringSetAsync(key, token, expiry, When.NotExists); if (!acquired) { return null; } return new RedisLock(db, key, token, expiry) { _locked true }; } /// summary /// 释放锁使用 Lua 脚本保证“校验 token 删除 key”原子执行 /// /summary public async Task ReleaseAsync() { if (!_locked) return; const string script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; await _db.ScriptEvaluateAsync( script, new RedisKey[] { _key }, new RedisValue[] { _token }); _locked false; } public async ValueTask DisposeAsync() { await ReleaseAsync(); } }5.3 关键逻辑说明第一加锁使用StringSetAsync(key, token, expiry, When.NotExists)。When.NotExists对应 Redis 的 NX 参数只有 key 不存在时才会写入成功。整个“判断是否存在 写入”由 Redis 保证原子执行。第二token 使用 GUID。每个请求生成的 token 不同释放锁时用它来确认身份。如果锁已经过期被其他请求拿到并重新写入新 token旧请求执行释放时就会因为 token 不一致而不删除别人的锁。第三释放锁使用 Lua 脚本而不是直接 KeyDelete。原因在基础原理一节已经讲过必须保证“获取值 - 比较 - 删除”这三步是原子的否则存在误删风险。第四RedisLock 实现 IAsyncDisposable 接口。这样业务代码可以配合await using使用离开作用域自动释放锁。即使业务中途抛出异常DisposeAsync 也会被执行减少锁泄漏的可能。但要注意这只是一种兜底真正的锁依然依赖 Redis 的过期时间兜底。5.4 关于锁续期看门狗的设计取舍在业务里使用上面的锁有一个不能回避的问题如果业务执行时间超过了锁的过期时间锁会提前失效。此时另一个请求就能获取到锁导致同一份资源被并发处理。RedissonJava 生态提供了 WatchDog 机制加锁成功后自动续期默认每 10 秒检查一次如果业务未结束就延长锁的过期时间。.NET 端没有这么成熟的官方库所以需要自己取舍。实际项目中通常有两种选择一是把锁过期时间设置得足够长覆盖正常业务的最长耗时。比如业务平均耗时 200ms最差耗时 2 秒锁过期时间设 10 秒。这个方案简单但极端情况下如果业务卡死锁会一直占着直到 10 秒后才释放。二是实现自己的续期机制。加锁成功后启动一个后台定时任务每隔一段时间检查锁的 token 是否还是自己的如果是就重新设置过期时间业务结束时取消续期任务并释放锁。本文示例为了聚焦分布式锁的核心实现采用第一种方案后续最佳实践会给出更完整的建议。真正的生产环境需要结合业务耗时和控制面监控来动态设置。6. 完整业务示例库存扣减与防重复下单6.1 业务场景设计为了同时演示“解决超卖”和“解决重复请求”两个问题这里设计两个接口库存扣减接口POST /api/product/{productId}/deduct扣减指定商品的库存。创建订单接口POST /api/product/order同一个订单号多次提交时只处理一次。库存数据先放在 Redis 里用 keystock:{productId}表示。分布式锁的 key 用lock:stock:{productId}这样同一商品的扣减请求互斥不同商品的扣减互不影响锁粒度更小。为什么库存放 Redis 还要加锁因为实际业务流程不是简单一条扣减命令而是“读取当前库存 - 校验数量合法性 - 模拟外部风控耗时 - 扣减库存 - 记录操作日志”这样的组合流程。为了让这个工作单元内不出现并发干扰就用分布式锁兜住。如果生产环境的逻辑可以精简为一条原子命令请优先选择原子命令这一点非常重要。6.2 库存扣减服务在项目中新建文件Services/StockService.csusing StackExchange.Redis; using WebApiDemo.Infrastructure.Locks; namespace WebApiDemo.Services; public sealed class StockService { private readonly IConnectionMultiplexer _redis; public StockService(IConnectionMultiplexer redis) { _redis redis; } public async Task(bool Ok, string Message) DeductAsync(string productId, int quantity) { if (quantity 0) { return (false, 扣减数量必须大于 0); } var db _redis.GetDatabase(); var lockKey $lock:stock:{productId}; var lockHandle await RedisLock.AcquireAsync(db, lockKey, TimeSpan.FromSeconds(10)); if (lockHandle null) { return (false, 当前操作人数过多请稍后重试); } await using (lockHandle) { var stockKey $stock:{productId}; var stockText await db.StringGetAsync(stockKey); if (!stockText.HasValue) { return (false, 商品不存在或未初始化库存); } if (!int.TryParse(stockText, out var currentStock)) { return (false, 库存数据异常); } if (currentStock quantity) { return (false, $库存不足当前仅剩 {currentStock} 件); } // 模拟风控校验、用户积分结算、日志记录等耗时操作 // 这个操作必须在有锁保护的前提下进行否则多个请求会基于同一份旧库存做判断 await Task.Delay(TimeSpan.FromMilliseconds(50)); var newStock currentStock - quantity; await db.StringSetAsync(stockKey, newStock); return (true, $扣减成功剩余库存 {newStock}); } } }这段代码的逻辑顺序是加锁 - 检查锁是否成功 - 读库存 - 校验库存 - 模拟耗时操作 - 扣减 - 释放锁。值得注意的是await using (lockHandle)这个写法。lockHandle 为 null 时不能调用 await using所以先判断是否为 null进入业务块后再用 await using 保证释放。如果业务代码抛出异常lockHandle 的 DisposeAsync 也会被调用锁会被释放。6.3 防重复下单服务在项目中新建文件Services/OrderService.csusing StackExchange.Redis; using WebApiDemo.Infrastructure.Locks; namespace WebApiDemo.Services; public sealed class OrderService { private readonly IConnectionMultiplexer _redis; public OrderService(IConnectionMultiplexer redis) { _redis redis; } public async Task(bool Ok, string Message) CreateOrderAsync(string orderId) { if (string.IsNullOrWhiteSpace(orderId)) { return (false, 订单号不能为空); } var db _redis.GetDatabase(); var lockKey $lock:order:{orderId}; var lockHandle await RedisLock.AcquireAsync(db, lockKey, TimeSpan.FromSeconds(10)); if (lockHandle null) { return (false, 订单正在处理中请勿重复提交); } await using (lockHandle) { var handledKey $order:handled:{orderId}; // 关键校验同一个订单号如果已经处理过直接拒绝 if (await db.KeyExistsAsync(handledKey)) { return (false, 重复订单请求已被拒绝); } // 模拟真实下单流程写入订单表、扣减库存、发送消息等 await Task.Delay(TimeSpan.FromMilliseconds(100)); // 标记该订单号已处理过期时间根据业务需求设置 await db.StringSetAsync(handledKey, DateTimeOffset.UtcNow.ToString(O), TimeSpan.FromHours(2)); return (true, 下单成功); } } }这里有一个组合用法分布式锁解决“同一时刻只有一个请求处理这个订单号”而订单处理标记解决“锁过期后重复请求也能被识别”。为什么锁过期后标记依然有效因为真正标识“这个订单处理过”的是order:handled:{orderId}这个 key而不是锁本身。锁只是短时间的互斥工具标记才是幂等判断的依据。这种“锁 状态标记”的组合是防重复请求的通用设计。6.4 WebAPI 控制器在项目中新建文件Controllers/ProductController.csusing Microsoft.AspNetCore.Mvc; using WebApiDemo.Services; namespace WebApiDemo.Controllers; [ApiController] [Route(api/[controller])] public class ProductController : ControllerBase { private readonly StockService _stockService; private readonly OrderService _orderService; public ProductController(StockService stockService, OrderService orderService) { _stockService stockService; _orderService orderService; } // POST /api/product/A1001/deduct [HttpPost({productId}/deduct)] public async TaskIActionResult Deduct(string productId, [FromBody] DeductRequest request) { var result await _stockService.DeductAsync(productId, request.Quantity); return result.Ok ? Ok(result) : Conflict(result); } // POST /api/product/order [HttpPost(order)] public async TaskIActionResult CreateOrder([FromBody] OrderRequest request) { var result await _orderService.CreateOrderAsync(request.OrderId); return result.Ok ? Ok(result) : Conflict(result); } public sealed class DeductRequest { public int Quantity { get; set; } } public sealed class OrderRequest { public string OrderId { get; set; } string.Empty; } }返回 409 Conflict 是为了表达“资源冲突”的语义不是参数格式问题而是当前请求与已有状态冲突。这样客户端可以根据状态码区分是重试还是修改参数。6.5 完整 Program.cs最后把服务注册到 Program.cs 中using StackExchange.Redis; using WebApiDemo.Services; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // 注册 Redis 连接 builder.Services.AddSingletonIConnectionMultiplexer(sp { var config builder.Configuration.GetConnectionString(Redis); return ConnectionMultiplexer.Connect(config!); }); // 注册业务服务 builder.Services.AddSingletonStockService(); builder.Services.AddSingletonOrderService(); var app builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseAuthorization(); app.MapControllers(); app.Run();StockService 和 OrderService 内部没有保存请求级状态使用 AddSingleton 是安全的。如果将来它们依赖 EF Core 的 DbContext则需要根据实际情况调整生命周期。7. 运行与效果验证7.1 启动服务先启动 Redis再运行 WebAPIdotnet run启动成功后会看到类似输出Now listening on: http://localhost:5267端口以实际输出为准。打开 Swagger 页面确认接口正常显示http://localhost:5267/swagger先用一个请求初始化库存。可以打开 Swagger 或使用 curlcurl -X POST http://localhost:5267/api/product/A1001/deduct \ -H Content-Type: application/json \ -d {\quantity\: 50}如果 Redis 中还没有库存数据会返回“商品不存在或未初始化库存”。先手动写入库存redis-cli SET stock:A1001 100再次调用扣减接口应该返回扣减成功。7.2 用并发任务模拟压测这里的核心目标是库存初始 100 件同时发起 200 个扣减 1 件的请求最终成功的请求数必须小于等于 100且 Redis 中剩余库存不低于 0。为了模拟真实的高并发场景我写一个简单的 C# 控制台压测脚本。新建一个控制台项目或者直接在临时目录用 dotnet run 执行using System.Diagnostics; using System.Net.Http.Json; var client new HttpClient { BaseAddress new Uri(http://localhost:5267) }; var tasks new ListTaskHttpResponseMessage(); var stopwatch Stopwatch.StartNew(); for (int i 0; i 200; i) { var request new DeductRequest { Quantity 1 }; tasks.Add(client.PostAsJsonAsync(/api/product/A1001/deduct, request)); } await Task.WhenAll(tasks); stopwatch.Stop(); var okCount tasks.Count(t t.Result.StatusCode System.Net.HttpStatusCode.OK); var conflictCount tasks.Count(t t.Result.StatusCode System.Net.HttpStatusCode.Conflict); var finalStock await client.GetStringAsync(http://localhost:5267/redis/health); Console.WriteLine($总请求数: {tasks.Count}); Console.WriteLine($成功数: {okCount}); Console.WriteLine($冲突数: {conflictCount}); Console.WriteLine($总耗时: {stopwatch.ElapsedMilliseconds} ms); public sealed class DeductRequest { public int Quantity { get; set; } }注意这个示例里的 GET /redis/health 只是验证连接不是查库存。要查最终库存可以直接在终端执行redis-cli GET stock:A1001更好的做法是在 Program.cs 里加一个临时查询接口或者直接通过 Redis Desktop Manager 查看。7.3 预期结果如果分布式锁生效200 个并发请求中最多只有 100 个能成功其余返回“库存不足”。最终 Redis 中 stock:A1001 的值为 0。如果锁实现有问题比如没有加锁或者释放逻辑不正确会出现两种典型现象现象一成功数超过 100。说明存在多个请求同时读到相同库存并都通过校验这是超卖的直接证据。现象二成功数不超过 100但最终库存是负数。说明扣减逻辑没有做最终校验比如直接执行 stock stock - 1 而不判断是否为 0。所以压测时不仅要关注成功数还要检查 Redis 里的最终库存值。7.4 如果结果不对先看哪里压测出问题不要慌按下面顺序排查第一步确认 Redis 中锁的 key 是否真的存在过。压测过程中快速执行redis-cli KEYS lock:*如果看到 lock:stock:A1001说明锁确实写入过 Redis。第二步确认锁的 key 在请求结束后是否被删除。压测完成后执行redis-cli KEYS lock:*正常情况下应该为空。如果锁 key 一直存在说明释放逻辑没执行优先检查是不是业务异常导致锁没有释放。第三步确认并发请求是不是真的打到了多个服务实例上。如果你只启动了一个 dotnet run 进程严格来说这不是集群压测只是并发测试。要模拟集群可以启动两个不同端口的实例再用负载均衡或直接往两个端口分别发请求。8. 常见问题与排查方法分布式锁在生产环境踩坑不少下面整理一份高频排查表。问题现象可能原因排查方式解决方案请求一直获取不到锁上次业务异常导致锁未释放Redis 中查看 lock:* 的 TTL设置合理过期时间代码中确认 await using 释放逻辑业务还没执行完锁就过期了锁过期时间设置太短对比业务耗时与锁过期时间延长过期时间或实现续期看门狗误删了别人持有的锁释放锁时没有校验 token查看 Redis 日志和锁 value改用 Lua 脚本校验 token 再删除主从 Redis 切换后锁丢失主节点写入锁后宕机从节点尚未同步查看 Redis 主从切换记录使用 Redis 哨兵模式的高可用方案对强一致场景考虑 RedLock 或 etcdWindows 下 Redis 启动失败端口占用、缺少运行库、路径错误查看启动日志检查 6379 端口释放端口安装 VC 运行库或改用 DockerRedis 连接初始化时报错连接字符串错误或 Redis 未启动调用 redis-cli ping检查地址端口密码确认 Redis 进程状态压测成功数超过预期锁没生效或锁粒度过大查看是否对所有相关请求都加了同一把锁检查 key 设计确保互斥请求使用相同锁 key这里最需要注意的是“误删别人的锁”和“主从切换锁丢失”两个问题它们是分布式锁实现质量的试金石。能在技术方案评审时主动讲清楚这两个风险说明你对分布式锁的理解已经超过大多数只会调用 API 的开发者。9. 最佳实践与工程建议9.1 锁粒度与 key 设计锁粒度直接影响系统并发能力。不推荐的做法是把所有商品的扣减请求都用同一把锁比如lock:stock:global。这样会导致不同商品之间互相阻塞明明卖 A 商品和卖 B 商品互不影响却要排队执行。推荐的做法是按业务资源拆锁 key。同一商品用lock:stock:{productId}同一订单用lock:order:{orderId}。锁 key 的粒度越小并发能力越高。但这个粒度要用一句话来总结锁的最小粒度是“需要保护的业务资源边界”。不要为了追求并发而拆得过细也不要偷懒用全局锁。9.2 锁超时时间设置锁过期时间是一个很容易拍脑袋的参数。设置太短业务没跑完锁就失效设置太长一旦持有锁的实例异常其他请求要等很久才能恢复。经验值是锁过期时间 业务最坏耗时的 3 到 5 倍。比如业务正常耗时 100ms最坏情况 500ms锁过期时间可以设置为 2 到 3 秒。同时配合监控观察“锁获取等待时间”和“锁持有时间”两个指标根据真实数据调整。如果业务耗时不可控比如要调用第三方接口、执行大批量计算就只能考虑续期机制。注意任何续期方案都不能替代过期时间本身过期时间是最后的兜底防线。9.3 数据库库存与 Redis 数据一致性如果最终的库存数据以数据库为准Redis 只是缓存那就要小心两者的一致性问题。普通的做法是扣减时先更新数据库再删除 Redis 缓存。删除缓存失败时用消息队列或定时任务补偿。不要先更新 Redis 再更新数据库因为 Redis 一旦写入成功数据库更新失败就会出现缓存和数据库对不上的情况。在本文的演示中库存直接放在 Redis所以不需要处理这层一致性。但真实项目中库存往往在数据库Redis 只是高性能层。把锁和数据库事务放在一起时要特别注意锁的持有时间不能覆盖整个数据库事务的提交过程否则事务并发度会非常低。9.4 生产环境风险提示上生产环境之前必须确认几件事。第一Redis 的部署模式。单机 Redis 有单点风险一旦 Redis 宕机所有依赖分布式锁的请求都会失败。至少使用主从 哨兵的高可用模式。这里需要评估Redis 服务异常时业务是快速失败还是降级放行如果降级放行可能直接引入并发问题。第二锁的可观测性。为每个锁增加操作日志包括加锁时间、持锁时长、释放结果、获取锁失败次数。一套没有监控的分布式锁系统出问题时基本靠猜。第三任何生产环境变更都要遵守先测试、再灰度、可回滚的原则。不要在未验证的自建锁组件上直接跑核心下单流程。如果对自研方案没有信心优先使用经过大规模验证的方案比如 RedLock 或引入 etcd 的分布式锁不要重复造轮子。9.5 AI 辅助开发的评审清单用 AI 辅助生成分布式锁代码时建议逐条检查以下问题加锁是否使用了 SET NX EX 或等价原子操作而不是分开的“判断 写入”释放锁时是否校验了 token而不是直接删除 key锁是否有过期时间过期时间是否覆盖业务最坏耗时锁是否实现了 IDisposable 或 IAsyncDisposable保证异常路径也能释放代码里是否只有一个入口能释放锁有没有可能被外部调用方误释放AI 最擅长的是生成“主路径”代码也就是加锁、业务、释放这个标准流程。边界条件——如果业务抛异常怎么办、如果 Redis 超时怎么办、如果锁已经过期怎么办——这些才是并发代码的真正难点也是人工评审必须在场的环节。10. 总结与下一步实践这篇文章的核心目标是让你从零跑通一个“ .NET 10 WebAPI Redis 分布式锁”的完整闭环。代码不多但包含了三层关键内容第一层是分布式锁本身。用 StackExchange.Redis 实现原子加锁用 Lua 脚本实现原子释放并解决了锁过期、误删、异常释放等边界问题。第二层是业务接入。库存扣减用锁保护“读库存 校验 扣减”的组合操作防止超卖防重复下单用锁配合状态标记实现幂等防止同一个订单号被处理多次。第三层是工程落地。从环境准备、压测验证到生产环境风险和监控设计这些都是实际项目中绕过不掉的环节。跑通最小闭环后建议按照下面的顺序继续深入把库存数据从 Redis 迁回数据库研究 SQL 原子更新UPDATE product SET stock stock - ? WHERE id ? AND stock ?和分布式锁各自适用的场景。研究 RedLock 算法及其争议理解“Redis 分布式锁”在极端情况下的强一致性边界。如果项目已经在用 etcd 或 ZooKeeper了解它们实现的分布式锁与 Redis 版的差异判断未来是否切换。给自己写的锁组件加一套监控指标比如获取锁失败率、锁持有时间分布用数据驱动参数调优。分布式锁不是银弹。很多并发问题用原子命令、幂等设计或数据库约束就能解决分布式锁只应该用在真正需要跨进程互斥的场景。能想清楚“这里到底要不要锁”比会写一万行锁代码更重要。
返回列表