十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Redis 分布式锁在 .NET 10 高并发接口中的实战解析

Redis 分布式锁在 .NET 10 高并发接口中的实战解析 集群环境下的高并发接口最常见的风险不是一台服务器扛不住而是多实例之间互相拆台。库存扣减前先检查库存检查完还没更新另一个实例已经拿走最后一件商品前端用户手滑点了两次提交两个请求同时落在不同实例上订单被重复创建。这些问题在单机时代可以用lock、synchronized或数据库事务压住但部署成集群后进程内的锁互相看不到就必须引入分布式锁。Redis 因为性能高、部署简单、命令表达能力足够是后端团队实现分布式锁的高频选择。本文以 .NET 10 WebAPI 接口服务为例从 Redis 环境准备开始逐步实现一个带过期时间、带持有者校验、支持重试的 Redis 分布式锁并把它应用到库存超卖和重复请求两个场景中。文章不只是一个演示还会解释每一步背后的并发原理、常见坑位和生产落地建议。1. 先想清楚集群下为什么单机锁挡不住超卖1.1 典型超卖代码的问题在哪看一段最常见的库存扣减伪代码public async Task DeductAsync(long productId, int count) { var stock await _stockRepository.GetStockAsync(productId); // 读库存 if (stock count) { throw new BusinessException(库存不足); } await Task.Delay(10); // 模拟业务处理生成订单、写流水等 await _stockRepository.DecreaseStockAsync(productId, count); // 扣减 }问题出在“读库存、判断库存、扣减库存”这三步不是原子的。两个请求同时读到库存为 100都通过了stock count的判断然后各自执行扣减最终数据库可能只剩 98但订单却生成了两份。单机部署时把这段代码放进lock或synchronized可以让同一进程内的线程串行执行一旦服务部署成两个实例A 实例上的锁对 B 实例没有任何约束力两个进程同时执行同一个业务方法超卖就出现了。从 Java 后端切到 .NET 10 的同学尤其要注意C# 的lock和 Java 的synchronized本质上都是进程内锁它们解决的是线程互斥不是进程互斥更不是跨主机互斥。1.2 分布式锁的本质和四个条件分布式锁的通俗说法是让所有实例都去同一个外部协调器抢一把锁谁抢到谁进入临界区执行完主动释放持有者崩溃时靠过期时间自动释放。它的技术定义可以理解为存储在外部协调器中的临时互斥标记客户端通过原子指令竞争这个标记的所有权。一个可靠的分布式锁至少要满足四个条件互斥性同一时刻只有一个客户端能持有锁。防死锁持有者崩溃或网络分区后锁能自动过期释放。安全释放只能释放自己持有的锁不能误删其他请求重新获取的锁。高可用锁服务本身不能成为明显单点极端故障时要有应对策略。很多团队在 Redis 分布式锁上翻车不是因为加锁命令没学会而是后面三个条件没有逐条验证。1.3 为什么 Redis 是常见选择实现分布式锁的常见外部协调器有 Redis、数据库和 ZooKeeper各有侧重。方案优点主要限制Redis 分布式锁性能高SET NX EX 一条原子命令完成生态工具多极端故障时可能丢锁需要结合过期时间、续期、主从策略兜底数据库悲观锁与业务事务一致性好适合强一致场景吞吐低长事务会放大锁等待数据库连接占用久数据库唯一键或乐观锁适合幂等和防超卖兜底实现简单只能解决单表或单操作冲突无法覆盖跨表多步骤临界区ZooKeeper 临时顺序节点强一致基于会话感知客户端故障部署维护成本高性能低于 Redis在 .NET 生态里并没有像 Java Redisson 那样功能很完整的分布式锁组件所以自己封装一个很小的锁服务是常见做法。这也是本文把实现过程完整展开的原因只有理解了锁的原子性、过期时间、持有者校验和续期才能真正驾驭它而不是把一个看起来能跑的代码复制到生产环境。2. 环境准备.NET 10 和 Redis 缺一不可2.1 确认 .NET 10 开发环境本文按 .NET 10 SDK 编写。动手前先确认本机 SDK 版本因为 .NET 版本迭代较快实际下载到的正式版或预览版会持续变化。dotnet --version dotnet --list-sdks环境要求可以按下表确认组件要求.NET 10 SDK从 dotnet.microsoft.com 下载当前官方正式版或最新预览版IDEVisual Studio 2022需勾选“ASP.NET 和 Web 开发”工作负载Redis7.x 或更高版本本机、Docker 或远程服务器均可数据库演示库存可使用 SQLite 或 SQL ServerORM 可用 EF Core 或 Dapper2.2 Redis 安装Windows、Linux、Docker 三种方式首先要明确Redis 官方没有发布 Windows 原生版本。Windows 下开发通常有三种选择使用 WSL2 运行 Linux 发行版并安装 Redis。使用第三方 Windows 移植发布包。使用 Docker Desktop 运行 Redis 容器。Docker 方式最省事一条命令即可启动docker run -d --name redis-local -p 6379:6379 redis:7如果使用 Windows 移植包运行命令通常是redis-server.exe redis.windows.conf redis-cli.exe pingredis-cli ping返回PONG表示 Redis 可用。Linux 下可以使用包管理器安装也可以源码编译对于版本受限的场景源码编译更可控但需要额外安装编译工具链。启动 Redis 后检查版本和基本状态redis-cli INFO server | grep redis_version2.3 Redis 基本配置和可视化客户端本地学习阶段Redis 配置文件可以保持最简单的状态但有两个点要提前注意端口不要和已有服务冲突生产环境不要裸奔。bind 127.0.0.1 protected-mode yes port 6379 # 本地学习可以先不设密码生产环境必须设置 # requirepass your-strong-passwordbind 127.0.0.1表示只允许本机访问。如果应用服务器和 Redis 不在同一台机器需要把 bind 改为应用服务器可访问的网卡地址或者放在同一个内网安全组内不要直接暴露公网。可视化客户端建议准备一个例如 Another Redis Desktop Manager、Redis Desktop Manager 等。它们主要用来快速查看 Key、Value、TTL 和数据类型排查分布式锁残留时非常有用。3. 在 .NET 10 WebAPI 中接入 Redis 客户端3.1 创建 WebAPI 项目和项目结构使用 CLI 创建项目dotnet new webapi -n OrderService cd OrderService模板默认生成最小 API 风格。如果习惯 Controller 风格在 Visual Studio 2022 的模板选项里勾选“使用控制器”或者手动创建 Controllers 目录。为了后续演示清晰建议按下面的结构组织代码OrderService/ ├── Controllers/ │ ├── StockController.cs │ └── OrderController.cs ├── Services/ │ ├── IDistributedLockService.cs │ ├── RedisDistributedLockService.cs │ ├── StockService.cs │ └── IdempotentOrderService.cs ├── Program.cs └── appsettings.json添加 Redis 客户端包dotnet add package StackExchange.RedisStackExchange.Redis 是 .NET 生态中使用最广的 Redis 客户端API 贴近 Redis 命令资料也最多。其他如 FreeRedis、NewLife.Redis 也可以实现同样功能本文以 StackExchange.Redis 为例。3.2 配置连接字符串并注册 Redis 连接在appsettings.json中配置连接字符串{ ConnectionStrings: { Redis: localhost:6379,password,defaultDatabase0,abortConnectfalse } }每个参数的含义如下参数作用建议abortConnectfalse启动时 Redis 连接失败不阻止应用启动学习阶段方便生产环境要配合健康检查不能长期掩盖连接问题defaultDatabaseRedis 逻辑库编号默认 0 即可不要通过多库强行隔离不同业务passwordRedis 密码生产环境必填connectTimeout连接超时时间默认 5 秒按网络情况调整注册 Redis 连接对象builder.Services.AddSingletonIConnectionMultiplexer(sp { var config builder.Configuration.GetConnectionString(Redis); return ConnectionMultiplexer.Connect(config); });ConnectionMultiplexer是线程安全且复用连接的对象整个进程只应创建一个。这里有个常见的 AI 生成代码坑把ConnectionMultiplexer写在每次请求的处理方法里new一个或者把密码硬编码在代码中。这两点都要人工检查。3.3 封装最常用的 Redis 操作写一个最小可用的 RedisService供后续锁服务和业务服务调用public class RedisService { private readonly IConnectionMultiplexer _redis; public RedisService(IConnectionMultiplexer redis) { _redis redis; } private IDatabase Db _redis.GetDatabase(); public async Taskstring? StringGetAsync(string key) await Db.StringGetAsync(key); public async Taskbool StringSetAsync(string key, string value, TimeSpan? expiry null) await Db.StringSetAsync(key, value, expiry); public async Taskbool StringSetIfNotExistsAsync(string key, string value, TimeSpan expiry) await Db.StringSetAsync(key, value, expiry, When.NotExists); }这里的核心是使用 Redis String 类型。Redis String 不只可以存文本也可以存数字、JSON、位图等。分布式锁的 Key 和 Value 都用 String其中 Value 必须保存请求的唯一标识后面会详细解释。4. 分布式锁的完整实现从两行代码到可上线的锁服务4.1 第一版SET NX EX 为什么是加锁的最小完整命令很多文章会告诉你加锁要用SET key value NX EX seconds但不会解释为什么不能用“先 Exists 再 Set”。看下面这个错误写法if 不存在: set key value两个请求可能同时通过Exists判断然后都执行Set锁就失效了。Redis 的SET NX EX是一条原子命令NX 表示只有 Key 不存在时才写入EX 或 PX 表示设置过期时间单位分别是秒和毫秒。原子性意味着判断和写入之间没有其他命令可以插入。在 StackExchange.Redis 中加锁代码非常短public async Taskbool TryLockAsync(string lockKey, string requestId, TimeSpan expiry) { return await _db.StringSetAsync(lockKey, requestId, expiry, When.NotExists); }这个方法对应 Redis 命令SET lockKey requestId PX 过期毫秒 NX。When.NotExists就是 NX第三个参数 TimeSpan 就是过期时间。三个核心参数的含义参数含义lockKey锁的标识按业务维度设计例如 order:stock:lock:1001requestId当前请求唯一标识用于解锁时校验持有者expiry锁自动过期时间防御持有者崩溃后锁永久不释放如果没有过期时间持有者进程崩溃后锁永远存在其他请求全部拿不到锁这就是死锁有了过期时间崩溃后最多等待一个过期周期。所以过期时间不是可选项而是分布式锁的保底机制。4.2 第二版解锁必须用 Lua不能直接删除 Key第一版加锁看起来已经完整但解锁如果写成下面这样会在高并发下埋雷await _db.KeyDeleteAsync(lockKey);为什么不能直接删考虑这个时间线请求 A 获取锁Value A。A 业务执行时间超过锁过期时间锁在 Redis 中自动消失。请求 B 获取同一把锁Value B。A 业务执行完执行KeyDelete(lockKey)直接把 B 刚获取的锁删掉了。请求 C 也拿到锁与 B 同时进入临界区互斥失效。正确做法是先判断锁的 Value 是否还是自己的 requestId是则删除否则不处理。但“判断”和“删除”两个操作之间仍然有并发窗口判断结果是自己的删除之前锁刚好过期并被其他请求获取此时删除就会误删新锁。所以“比较 Value”和“删除 Key”必须放在 Redis 的 Lua 脚本中原子执行。释放锁的 Lua 脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endC# 调用代码private static readonly string UnlockScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public async Taskbool ReleaseLockAsync(string lockKey, string requestId) { var result await _db.ScriptEvaluateAsync( UnlockScript, new RedisKey[] { lockKey }, new RedisValue[] { requestId }); return (long)result 1; }返回 1 表示删除成功返回 0 表示 Value 不匹配说明锁已经不属于当前请求不能删除。注意释放分布式锁时不能直接调用 KeyDelete。必须判断锁的 Value 是否仍属于当前请求并且判断和删除必须放在同一个 Lua 脚本里执行。对应地加锁时传入的 requestId 必须是全局唯一值一般使用Guid.NewGuid().ToString(N)或雪花 ID。如果两个请求使用同一个 requestIdA 持锁期间锁过期B 用相同 Value 获取新锁A 释放锁时get结果与自己相等就会误删 B 的锁。requestId 的作用就是区分锁的持有者。4.3 第三版拿不到锁时按策略重试前面两版都是“拿不到锁就立即失败”。实际接口中锁可能被一个很快的事务持有比如耗时只有几十毫秒此时直接让用户失败体验很差。常见做法是自旋等待一小段时间。public async Taskbool AcquireLockWithRetryAsync( string lockKey, string requestId, TimeSpan expiry, int maxRetry 3, TimeSpan? retryDelay null) { retryDelay ?? TimeSpan.FromMilliseconds(100); for (var i 0; i maxRetry; i) { if (await TryLockAsync(lockKey, requestId, expiry)) { return true; } await Task.Delay(retryDelay.Value); } return false; }更严格的方式是用 Stopwatch 控制总等待时间避免在高并发时大量线程同时循环争抢var watch Stopwatch.StartNew(); while (watch.Elapsed maxWait) { if (await TryLockAsync(lockKey, requestId, expiry)) { return true; } await Task.Delay(50); } return false;学习环境用简单循环即可理解思路。生产环境如果要严格控制 Redis 压力还可以在本机用 SemaphoreSlim 限制同时等待锁的线程数量避免所有请求都打到 Redis 上。4.4 锁过期时间和续期问题第二版解决了误删但新的问题是锁过期了业务还没执行完怎么办假设锁过期时间设 10 秒业务最慢路径需要 15 秒。第 10 秒时锁被 Redis 自动删除另一个请求获取锁进入临界区两个请求同时执行库存扣减逻辑分布式锁形同虚设。解决思路有几种把过期时间设大一些保证大于业务最大耗时。最简单但过期时间过大时持有者崩溃后其他请求等待时间会变长。手动续期后台任务每隔expiry / 3的时间刷新锁的过期时间业务执行完取消续期并释放锁。这类似 Redisson 的 WatchDog 思路。使用第三方 RedLock 实现选择库之前要确认项目的维护情况、并发模型和需求匹配度。续期本身也要用 Lua 脚本确保只有锁仍归自己持有时才刷新过期时间if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) else return 0 end简化版的续期循环思路var renewalCts new CancellationTokenSource(); _ RenewLoopAsync(lockKey, requestId, expiry, renewalCts.Token); try { // 执行业务逻辑 } finally { renewalCts.Cancel(); await ReleaseLockAsync(lockKey, requestId); } async Task RenewLoopAsync(string key, string requestId, TimeSpan expiry, CancellationToken token) { while (!token.IsCancellationRequested) { await Task.Delay(expiry / 3, token); await RenewAsync(key, requestId, expiry); } }生产环境还要处理续期 Task 的异常避免后台续期线程崩溃后锁变成裸过期。分布式锁的三版演进可以用下表总结版本加锁解锁解决的问题遗留问题第一版SET NX EXKeyDelete互斥、防死锁可能误删他人锁第二版SET NX EXLua 比较后删除误删他人锁锁过期后业务未完成第三版SET NX EX 重试Lua 比较后删除拿不到锁时自动等待仍需合理过期时间或续期5. 实战一库存超卖场景的分布式锁接入5.1 库存业务为什么不能只依赖一条 UPDATE有一种观点是库存扣减只要写成UPDATE product_stock SET stock stock - count WHERE product_id productId AND stock count数据库自带行锁不会超卖为什么还需要分布式锁这句话对“扣减库存”这个单点操作成立但对完整的下单流程不成立。真实的库存扣减很少只有一条 UPDATE而是“查询库存、检查库存、创建订单快照、写流水、扣减库存”多个步骤。多个请求可能都通过了库存检查都创建了订单直到扣减时才有一个失败但订单数据已经被写进去了业务上已经产生了脏数据。分布式锁在这里的作用是让“读库存、业务判断、业务登记、扣减库存”作为一个临界区串行执行。数据库的WHERE stock count是最后一道防线两者不是替代关系而是配合关系。另一个常见误区是“Redis 里用 DECR 原子扣减就不需要锁”。DECR只保证 Redis 中的计数不超卖无法保证库存扣减前那些业务预占、订单登记、消息通知的一致性。Redis 的原子命令不能包住业务状态流转锁才能包住临界区。5.2 定义锁服务接口把第 4 章实现封装成接口方便库存服务、订单服务复用public interface IDistributedLockService { Taskbool TryLockAsync(string lockKey, string requestId, TimeSpan expiry); Taskbool AcquireLockWithRetryAsync(string lockKey, string requestId, TimeSpan expiry, int maxRetry 3, TimeSpan? retryDelay null); Taskbool ReleaseLockAsync(string lockKey, string requestId); }实现类RedisDistributedLockService中把第 4 章的TryLockAsync、AcquireLockWithRetryAsync、ReleaseLockAsync和 Lua 脚本放进去即可。5.3 库存扣减的完整代码Controller 层尽量保持薄只做参数接收和结果返回[ApiController] [Route(api/[controller])] public class StockController : ControllerBase { private readonly StockService _stockService; public StockController(StockService stockService) { _stockService stockService; } [HttpPost({productId}/deduct)] public async TaskIActionResult Deduct(long productId, [FromQuery] int count) { var requestId Guid.NewGuid().ToString(N); var result await _stockService.DeductAsync(productId, count, requestId); return Ok(result); } }Service 层把锁、库存、订单服务组合起来public class StockService { private readonly IDistributedLockService _lockService; private readonly IStockRepository _stockRepository; private readonly IOrderRepository _orderRepository; public StockService( IDistributedLockService lockService, IStockRepository stockRepository, IOrderRepository orderRepository) { _lockService lockService; _stockRepository stockRepository; _orderRepository orderRepository; } public async TaskDeductResult DeductAsync(long productId, int count, string requestId) { var lockKey $order:stock:lock:{productId}; if (!await _lockService.TryLockAsync(lockKey, requestId, TimeSpan.FromSeconds(10))) { return DeductResult.Failed(系统繁忙请稍后重试); } try { var stock await _stockRepository.GetStockAsync(productId); if (stock count) { return DeductResult.Failed(库存不足); } // 模拟业务处理创建订单快照、写流水、通知下游等 await _orderRepository.CreateOrderAsync(productId, count, requestId); var affected await _stockRepository.DecreaseStockAsync(productId, count); if (affected 0) { return DeductResult.Failed(库存不足请重试); } return DeductResult.Success(); } finally { await _lockService.ReleaseLockAsync(lockKey, requestId); } } }几个关键设计点锁 Key 按 productId 拆分不同商品使用不同锁避免所有商品共用一把锁导致吞吐下降。requestId 同时作为锁持有者标识可以顺带写入订单表作为请求来源标识方便排查。释放锁放在 finally 中即使业务抛异常锁也会主动释放。过期时间先给 10 秒生产环境要结合业务最慢路径重新评估。实际项目中CreateOrderAsync和DecreaseStockAsync应包在同一个数据库事务中这里为了突出锁的逻辑做了简化。5.4 模拟并发验证超卖是否消失本地验证可以用并发 Task 模拟一批请求var tasks Enumerable.Range(0, 1000) .Select(i _stockService.DeductAsync(1, 1, Guid.NewGuid().ToString(N))); var results await Task.WhenAll(tasks); var successCount results.Count(r r.Success); var failCount results.Count(r !r.Success);预期结果是初始库存 1001000 个并发请求最终successCount 100failCount 900。如果去掉分布式锁会观察到successCount 100或者扣减后库存变成负数。这里要明确一点Task.WhenAll只模拟了单进程内的并发只能验证接口逻辑是否合理不能验证跨实例的互斥效果。要真正验证 Redis 分布式锁在集群下有效需要启动至少两个 WebAPI 实例通过负载均衡分发请求并连接同一个 Redis。再使用 JMeter、k6 或 wrk 发起并发压测。6. 实战二用分布式锁拦截重复请求6.1 重复请求的典型来源重复请求不一定是高并发导致的更常见的是时间上先后到达但业务结果不能重复。典型来源包括前端双击提交按钮、移动端弱网环境自动重试、网关或 RPC 框架超时重试、消息队列重复消费。这类问题和库存超卖不同。超卖关心的是“多个实例同时修改同一个库存”重复请求关心的是“同一个请求被处理了多次产生多条订单或多次扣款”。6.2 锁加幂等表比单独 Set NX 更可靠一个直觉方案是请求进来时直接SET requestNo 1 NX EX 60设置成功就执行业务设置失败就返回重复提交。这个方案能挡住并发但有一个明显漏洞如果锁过期时间比业务执行时间短第二个请求会在锁过期后进入并执行业务幂等失效。即使把过期时间设得很长Redis 里也无法区分“处理中”和“已完成”两种状态。更可靠的做法是锁负责并发排队幂等表负责状态记录。同一个 requestNo 的请求先在锁外排队拿到锁后进临界区查询幂等表如果已经处理过直接返回第一次处理的结果如果没有处理过执行正常业务并在同一个事务里写入幂等记录。幂等表的唯一索引作为最终兜底。6.3 幂等表设计以创建订单为例CREATE TABLE idempotent_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_no VARCHAR(64) NOT NULL, business_type VARCHAR(32) NOT NULL, response_body TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request_no_biz (request_no, business_type) );request_no是客户端生成的请求唯一标识business_type区分不同的业务类型两者组成唯一索引。即使极端情况下两个请求同时进入也只有一个能插入成功另一个会收到唯一键冲突业务层捕获后返回已处理结果。6.4 防重接口代码[HttpPost(create)] public async TaskIActionResult CreateOrder(OrderCreateRequest request) { var requestNo request.RequestNo; if (string.IsNullOrWhiteSpace(requestNo)) { return BadRequest(new { code 400, message RequestNo 不能为空 }); } var lockKey $biz:order:create:{requestNo}; var requestId Guid.NewGuid().ToString(N); if (!await _lockService.TryLockAsync(lockKey, requestId, TimeSpan.FromSeconds(5))) { return Conflict(new { code 409, message 请求正在处理中请勿重复提交 }); } try { var exist await _idempotentRepository.GetByRequestNoAsync(requestNo); if (exist ! null) { return Ok(exist.ResponseBody); } var result await _orderService.CreateAsync(request); await _idempotentRepository.SaveAsync(requestNo, JsonSerializer.Serialize(result)); return Ok(result); } finally { await _lockService.ReleaseLockAsync(lockKey, requestId); } }这里有几个容易误解的地方requestNo必须由客户端生成并透传服务端自动生成的请求号无法用于幂等因为每次生成的结果都不同。锁的过期时间 5 秒只是示例要大于创建订单的业务耗时。如果业务可能超过 5 秒仍需要配合续期。SaveAsync写入幂等记录时如果遇到唯一键冲突要按“已经处理过”处理而不是直接抛异常。幂等记录和订单写入尽量放在同一个事务中避免订单成功但幂等记录没写入导致下次重试再次创建订单。7. 分布式锁最容易踩的坑与排查链路7.1 坑1释放锁时误删他人的锁现象偶发性地出现多个请求同时进入临界区库存超卖或订单重复。原因解锁直接KeyDelete删掉了其他请求重新获取的锁。排查在日志中输出释放锁的结果。如果出现“释放锁跳过”说明 Value 校验没有通过锁不是当前请求持有的。解决使用 Lua 脚本先get比较 Value再del删除两个动作必须原子。7.2 坑2锁过期时间设置不合理现象业务正常执行但锁提前过期后续请求进入临界区或者锁过期时间过长持有者崩溃后其他请求长时间等待。原因没有结合业务最慢路径评估过期时间也没有对长任务做续期。解决先统计业务在临界区内的最大耗时设置过期时间时留出缓冲长任务额外做续期不要把 Redis 调用、外部 HTTP 调用等长耗时操作放在锁内。7.3 坑3锁粒度设计错误现象接口吞吐很低所有请求排成一串或者锁内再嵌套加锁出现死锁。原因锁 Key 设计太粗所有业务共用一把全局锁或锁 Key 太细且加锁顺序不一致。解决按业务实体拆分锁 Key例如按 productId、userId、requestNo 拆分。锁内避免继续申请其他锁如果确实需要多把锁要保证所有路径加锁顺序一致。7.4 坑4Redis 主从切换导致锁丢失现象Redis 主库宕机从库升级为主库后原来写入到主库的锁 Key 不存在多个请求同时进入临界区。原因锁写在主库但主从复制是异步的Key 还没同步到从库时主库宕机从库成为新主库后没有这把锁。解决Redis 哨兵解决了主从自动切换的高可用问题但没有解决异步复制带来的锁丢失。RedLock 通过多节点 quorum 提高容错但会带来性能和一致性成本也不是绝对安全。对库存、扣款这类一致性要求极高的场景可以在数据库侧再做一次基于唯一键或行锁的兜底。7.5 坑5缓存和锁混用清理缓存时误删锁现象某个缓存清理任务执行后分布式锁全部失效接口并发异常。原因锁 Key 和缓存 Key 的命名空间没有隔离或者有人执行了FLUSHDB、KEYS *删除操作。解决锁 Key 统一使用lock:前缀缓存 Key 使用cache:前缀生产环境禁止执行FLUSHDB、FLUSHALL和KEYS命令。7.6 从现象到根因的排查链路排查分布式锁问题可以按下面的顺序走确认现象是库存超卖、订单重复还是接口大面积失败。看日志检查加锁、解锁日志是否输出了 requestId、lockKey、成功状态统计加锁失败率。查 Redis用命令查看锁 Key 的 TTL 和 Value。redis-cli -a 你的密码 --no-auth-warning TTL order:stock:lock:1001 redis-cli -a 你的密码 --no-auth-warning GET order:stock:lock:1001TTL 为 -1 表示锁没有过期时间需要检查加锁代码是否漏掉了 expiryTTL 为 -2 表示 Key 不存在。GET 返回的 Value 与当前请求 requestId 不一致时可能是误删或锁已过期被重新获取。看主从执行redis-cli INFO replication检查主从状态确认事故时间是否和主从切换时间吻合。看业务耗时通过日志或链路追踪统计临界区内耗时判断是否超过锁过期时间。看数据库检查订单表、幂等表中是否存在相同 requestId 的重复数据。常见问题的汇总表现象常见原因检查方式处理建议多个请求同时进入临界区锁过期时间比业务耗时短对比业务耗时和锁 TTL调大过期时间或增加续期偶发误删锁后并发进入解锁未校验 Value看日志是否出现释放锁跳过改用 Lua 脚本解锁库存不为负但订单重复只防了扣减没防创建查订单表 requestId 重复数锁内加幂等表或唯一键锁 Key 还在但拿不到锁锁过期时间过长查 TTL 和请求等待时间缩短过期时间增加续期主从切换后锁丢失主从异步复制丢锁核对切换时间和锁 Key 状态数据库兜底或按一致性要求评估 RedLock8. 生产落地参数、监控、检查清单和扩展方向8.1 Key 设计与命名规范分布式锁的 Key 建议按业务:领域:lock:业务ID格式设计例如order:stock:lock:1001pay:refund:lock:order_20250101biz:order:create:req_123456Value 统一使用请求唯一 ID建议是Guid.NewGuid().ToString(N)或雪花 ID。锁 Key 和缓存 Key 必须分开命名空间锁统一加lock:前缀避免缓存清理任务误删锁。8.2 过期时间与重试策略选择参数学习环境建议生产环境建议锁过期时间5-10 秒预估最大业务耗时乘 2并配合续期重试次数3 次3-5 次或控制总等待时间不超过 200-500 毫秒重试间隔100 毫秒短任务 10-50 毫秒并考虑指数退避锁粒度按商品、按请求号按业务最小维度避免嵌套加锁不要全局只使用一把锁。锁 Key 越粗并发吞吐越低锁 Key 越细管理成本和死锁风险越高需要在业务维度上做取舍。8.3 Redis 持久化和高可用准备生产环境的 Redis 至少要考虑持久化和高可用两个层面。持久化方面开启 AOF 或 RDB 可以解决 Redis 重启后的数据恢复问题但要注意持久化不能解决主从切换时的异步复制丢锁它和锁丢失是两个不同的问题。高可用方面可以使用主从加哨兵或者根据规模选择 Redis Cluster。哨兵负责故障自动切换Cluster 负责数据分片和高可用。无论哪种方式锁的高可用都要结合业务一致性和性能要求综合考虑不要把 Redis 分布式锁当作所有场景的万能方案。网络和安全方面Redis 和应用服务器之间不要走公网建议放在同一个内网安全组中并设置强密码。连接字符串中的密码不要硬编码在代码或appsettings.json中要使用环境变量或密钥管理服务。8.4 发布前检查清单[ ] Redis 是否设置了访问密码并且只允许应用服务器访问[ ] 连接字符串是否来自环境变量或密钥管理没有提交到源码仓库[ ] 加锁是否设置了过期时间解锁是否放在 finally 中[ ] 解锁是否使用 Lua 脚本并校验 requestId[ ] 锁过期时间是否大于业务最慢路径的耗时[ ] 是否有加锁成功、加锁失败、释放成功、释放跳过的日志[ ] 是否对库存扣减、订单创建做了并发压测[ ] 是否对幂等表加了唯一索引[ ] 是否考虑 Redis 主从切换场景下的兜底策略[ ] 是否避免在锁内执行长耗时外部调用8.5 下一步扩展方向如果这套锁服务要在团队内长期使用可以把锁封装成内部 NuGet 包统一锁的 Key 规范、过期时间策略和日志格式。也可以用 OpenTelemetry 对锁等待时间、获取成功率做埋点把分布式锁纳入监控体系。对于更长链路、更复杂的业务流程锁不应该包住整个流程而应该尽量缩小临界区。更合理的方式是使用状态机、事件驱动和幂等表来保证最终一致锁只保护短时间内真正需要互斥的状态流转。对瞬时峰值流量分布式锁也不是唯一的解决方案限流、削峰、消息队列都可以配合使用。如果前端使用 Vue 消费 WebAPI 的文件下载接口还会涉及 blob 接收二进制流、设置Content-Disposition文件名等问题这属于另一个工程主题但和分布式锁一样都属于 WebAPI 发布后常见的联调细节。建议在项目初期就把这类跨端联调规则写进接口文档。9. 结合 AI 辅助开发哪些代码可以交给 AI哪些必须人工把关9.1 AI 适合生成什么在 .NET 10 WebAPI 项目中AI 比较适合生成 Controller 骨架、DTO、项目初始化、健康检查、Swagger 配置、常规 CRUD 等重复度高的代码。分布式锁、事务、幂等、并发控制这类正确性敏感的代码AI 生成的结果只能作为初稿关键路径必须人工理解并审查。9.2 给 AI 的提示词要包含约束同样的需求如果只写“帮我写个分布式锁”AI 很可能给出KeyDelete直接解锁或者在每个请求里 new 一个连接对象。正确的做法是在提示词中明确约束条件。下面是一个可以参考的提示词请用 .NET 10 生成一个 WebAPI 库存扣减接口。 要求 1. 使用 Controller 风格返回统一响应格式。 2. 使用 Redis 分布式锁锁 Key 按 productId 拆分。 3. 加锁使用 SET NX EXValue 使用每次请求生成的 requestId。 4. 锁内先查询库存再创建订单流水最后扣减库存。 5. 解锁必须使用 Lua 脚本先比较 Value 再删除。 6. 释放锁必须放在 finally 中。 7. 库存不足时返回业务错误码。AI 生成后重点审查加锁是否带过期时间、解锁是否用了 Lua、锁是否在 finally 中释放而不是急着把代码合并到主线。9.3 AI 生成代码审查清单审查点常见问题通过标准Redis 连接每次请求创建 ConnectionMultiplexer单例注册复用连接加锁没有设置过期时间必须带 EX/PX 过期时间解锁直接删除 KeyLua 比较 Value 后删除异常处理锁释放不在 finally 中finally 中释放锁配置管理密码硬编码使用环境变量或配置中心日志加锁失败无日志有 requestId、key、成功失败状态幂等只靠锁不写幂等表锁加唯一键双保险分布式锁的核心其实很小一个原子加锁、一个安全释放、一个合理的过期时间。把它封装成一个稳定的锁服务后再往上叠加库存防超卖、重复请求拦截整套 WebAPI 在高并发场景下会清晰很多。对后端工程师来说在 .NET 10 项目里完整走一遍 Redis 分布式锁的接入和排错比零散地看 Redis 命令要有价值得多。
返回列表