.NET 10 生产级 Redis 分布式锁实战:防误删、可重入与看门狗续期 如果你是一名后端开发大概率遇到过这种线上事故用户在提交订单时手快连点了两下服务端在极短时间里创建了两条一模一样的订单或者在扣减库存时明明代码里用了lock或synchronized商品还是超卖了。于是你听说了 Redis 分布式锁说只要用一条SET NX EX就能解决跨实例互斥。但真把这种锁写进生产环境又会踩到锁过期导致重复执行、误删别人的锁、锁不能重入、业务没跑完锁却先没了这一连串坑。这篇文章我会基于 .NET 10 WebAPI 项目从零搭一个可用于生产环境的 Redis 分布式锁组件。它不是只讲SET NX EX的入门笔记而是会覆盖四个关键能力锁超时自动释放、防误删锁、可重入锁、看门狗自动续期。最后还会用一个订单防重复提交的接口把整套代码串起来运行验证。读完之后你既能写出一套带看门狗续期的RedisLockService也能从容回答面试里关于分布式锁的常见追问。顺便说一句现在 AI 辅助编码已经很普及但并发锁这类对边界条件要求极高的代码我建议你还是亲手敲一遍并推演每一步。分布式锁的难点从来不在“加锁”这一行命令而在于异常场景下锁还能不能保持安全。1. 为什么生产环境不能只会 SET NX EX先看一个经典场景订单系统部署了三个实例用户请求通过负载均衡随机打到其中一台。单体应用里用进程内锁没问题因为所有请求都在同一个进程内竞争但到了多实例部署请求可能打到不同的机器A 实例中的lock管不住 B 实例的并发请求。这就是分布式锁存在的根本原因把“互斥”从单进程提升到跨实例层面。如果此时你只是简单地在 Redis 里执行这条命令SET order:create:1001 1 NX EX 30它确实能实现“同一时刻只有一个请求能设置成功”的效果但生产环境很快会暴露三个问题。第一锁没有归属。所有线程写入的值都是1当锁因为业务执行超过 30 秒而自动过期后另一个进程拿到了锁。此时第一个请求终于执行完它执行DEL key把第二个请求的锁删掉了。紧接着第三个请求又拿到锁循环往复锁完全失效。第二锁不能重入。如果你的方法 A 持有锁方法 A 内部又调用方法 B而方法 B 也尝试获取同一把锁它会被自己挡住。业务上就会出现“自己把自己锁死”的现象。第三锁可能过早释放。业务执行时间一旦超过锁过期时间即使第一个线程还在处理Redis 也会把 key 删掉让另一个线程进入临界区。放大过期时间并不能根治只会在业务结束时更晚释放而且遇到慢 SQL 或 GC 停顿锁该过期还是会过期。所以生产级 Redis 分布式锁绝不是一条命令的事它的完整能力应该是加锁时带过期时间防止进程崩溃造成死锁释放时校验持有者身份防止误删用计数器支持重入用看门狗自动续期保证业务没有执行完时锁不会提前消失。把这四件事做完才是能上生产的方案。2. Redis 分布式锁核心概念与正确姿势在进入代码之前先把几个关键概念梳理清楚。很多人面试被问倒不是不知道 Redis 命令而是没有理解每个设计背后的动机。2.1 NX 和 EX 缺一不可Redis 提供了一条最常用的加锁命令SET lockKey lockValue NX EX 30其中NX表示只有当 key 不存在时才能设置成功这保证了互斥EX 30表示 key 在 30 秒后自动过期防止持有锁的进程崩溃后锁永久占住。整个操作是原子的不会出现“先 SETNX 再设置过期时间”中间宕机导致锁没有过期时间的漏洞。如果把这条命令拆成两步比如先SETNX成功再单独执行EXPIRE一旦第二步执行前进程崩溃锁就会永远存在。后面所有的请求都会被卡死。因此加锁和设置过期时间必须一次完成。2.2 释放锁为什么必须用 Lua 脚本释放锁不能直接写DEL key因为你不知道 key 是不是自己的。正确做法是在设置锁时写入一个只有当前请求知道的随机标识token释放时先比较 Redis 里的 value 是否等于自己的 token如果相等才删除如果不相等说明锁已经被别人拿到绝不能删。比较和删除是两个步骤必须保证原子性。Redis 事务做不到真正的判断后执行但 Lua 脚本可以。Redis 从 2.6 版本开始就支持在服务端原子执行 Lua 脚本这也是如今释放分布式锁的标准姿势。2.3 可重入锁为什么用 HashString 类型只能存一个值无法表达“同一个持有者加锁 N 次”的信息。Hash 结构天然适合做重入计数field 存放持有者的 tokenvalue 存放加锁次数。加锁时如果 field 存在就HINCRBY加一释放时减一减到 0 才真正删除 key。2.4 看门狗是续期机制不是硬件看门狗这个词最早被大家熟知是因为 Java 的 Redisson 框架。Redisson 获取锁之后会启动一个后台任务每隔一段时间自动给锁续期默认锁超时时间 30 秒续期任务每 10 秒执行一次。如果业务执行了 50 秒锁的过期时间会不断被刷新到 30 秒以后从而避免锁提前失效。.NET 生态没有官方 Redisson所以我们需要在自定义锁服务里实现一个类似的看门狗逻辑。实现原理并不复杂一个后台Task定时对 key 执行PEXPIRE刷新过期时间业务释放锁时取消这个任务。能力本地锁不完善的 Redis 锁生产级 Redis 锁作用范围单进程多进程多进程超时保护一般不需要需要设置需要设置释放身份校验自动无token Lua可重入原生支持不支持Hash 计数长任务保护自动无看门狗续期3. 环境准备.NET 10 WebAPI Redis本文的代码基于 .NET 10 WebAPI 模板但下面使用的都是 .NET 6 以后就稳定的 API所以你用 .NET 8 或 .NET 9 一样能跑通。Redis 客户端使用目前 .NET 生态最主流的StackExchange.Redis。3.1 安装 Redis本地开发最简单的方式是用 Docker 启动一个 Redis 7.x 容器docker run --name redis7 -p 6379:6379 -d redis:7-alpine如果你在 Windows 上不想用 Docker也可以使用支持 Redis 协议的内存数据库或通过 WSL 安装 Redis 服务端。生产环境则建议部署 Redis 主从 哨兵或者正式集群避免单点故障。启动后先验证一下docker exec -it redis7 redis-cli ping返回PONG就说明 Redis 服务正常。3.2 创建 WebAPI 项目打开终端执行dotnet new webapi -n OrderApi cd OrderApi然后添加 Redis 客户端包dotnet add package StackExchange.Redis如果使用的是 Visual Studio 2022也可以通过“管理 NuGet 程序包”搜索并安装StackExchange.Redis。3.3 配置连接字符串编辑项目根目录下的appsettings.json{ ConnectionStrings: { Redis: localhost:6379,defaultDatabase0,abortConnectfalse }, Logging: { LogLevel: { Default: Information, Microsoft.AspNetCore: Warning } } }abortConnectfalse的含义是应用启动时即使 Redis 暂时不可用也不要立即抛异常让连接池在后台继续重试。这个配置在本地开发时很有用但生产环境还是要确保 Redis 高可用。4. 第一版 Redis 分布式锁能跑但一上生产就会出事先来看一个最简单、也最危险的第一版实现。很多入门文章只会教你这一段public sealed class RedisLockV1 { private readonly IDatabase _db; public RedisLockV1(IConnectionMultiplexer connection) { _db connection.GetDatabase(); } public async Taskbool TryLockAsync(string key, TimeSpan expiry) { return await _db.StringSetAsync(key, 1, expiry, When.NotExists); } public async Task UnlockAsync(string key) { await _db.KeyDeleteAsync(key); } }这段代码解决了两件事跨实例互斥和死锁兜底。但它也有三个致命隐患我依次解释。第一UnlockAsync是直接删 key完全不校验持有者。假设请求 A 拿到锁后执行了 35 秒而锁的过期时间是 30 秒那么在第 31 秒时锁已经自动消失请求 B 成功拿到锁。第 35 秒请求 A 执行完调用KeyDeleteAsync把请求 B 的锁删掉。请求 C 又趁虚而入整个临界区形同虚设。第二锁的值是固定的1没有任何请求上下文信息。即使是业务正常的情况你也无法通过 Redis 里的 value 判断这个锁是谁拿的、什么时候拿的排查问题时两眼一抹黑。第三不支持可重入。如果同一个请求的代码嵌套调用第二次获取同样 key 的锁会返回失败业务直接抛异常或走错误分支。这个版本做演示可以上生产绝对不行。接下来我们一步步把它升级成安全版本。4.1 事故推演误删锁是怎么发生的用一个时间线来说明问题时间请求 A请求 BRedis 中的锁t0获取锁成功keyAt30锁过期key 不存在t31业务未完成获取锁成功keyBt35释放锁误删 Bkey 不存在t36业务还在执行锁已经丢失第 36 秒时请求 C 可以再次获得锁但请求 B 的业务可能还没有执行完库存超卖就是这样发生的。要避免这个问题必须在释放时确认“这把锁是我自己的”。5. 升级一锁超时自动释放与防误删锁首先要做的改动是给锁加入身份标识 token。token 应该是一个足够随机、在当前调用链路上唯一的字符串比如 GUID 或请求的 TraceId。加锁时把这个 token 作为 value 写入 Redis释放时先取出 Redis 中的 value 与自己的 token 比较相同才删除。释放逻辑必须用 Lua 脚本保证原子性const string unlockScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end ; var result await _db.ScriptEvaluateAsync( unlockScript, new RedisKey[] { lockKey }, new RedisValue[] { token });这段脚本做的事情可以分为三步第一步读取 Redis 中锁的值第二步判断是否等于当前请求的 token第三步相等则删除不相等则返回 0。三步在 Redis 服务端原子执行过程中不会有其他命令插入因此不会出现“判断时是自己的锁删除时已经是别人的锁”这种竞态。这一阶段依然使用 String 类型加锁命令为SET orderLock token NX EX 30EX 30保证锁超时自动释放即使请求进程在执行中途崩溃Redis 也会在 30 秒后主动清理 key不会死锁。NX保证同一时刻只有一个请求能写入成功。到这里第一版的两个大坑被填掉了死锁靠过期时间解决误删靠 token Lua 解决。但锁还不支持重入长任务也依然可能在执行中锁被自动过期。继续往下升级。6. 升级二可重入锁可重入锁解决的是同一个执行单元内重复获取锁的问题。举个例子你有一个CreateOrderAsync方法加了分布式锁它内部调用了DeductStockAsync而后者也声明了同一把锁。如果锁不可重入第二次获取会失败代码里可能出现“自己阻塞自己”的诡异问题。另外在 .NET 异步编程中同一个逻辑单元很可能在不同的线程上流动所以锁的重入维度不应该绑定到 Thread ID更稳妥的做法是绑定到一个业务请求唯一标识比如HttpContext.TraceIdentifier。这样同一个请求内部无论经过多少次异步切换都能识别为同一个持有者。实现上把 Redis 的底层结构从 String 换成 Hashfield 存放持有者 tokenvalue 存放重入计数。加锁时执行下面的 Lua 脚本const string lockScript if redis.call(exists, KEYS[1]) 0 then redis.call(hset, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 elseif redis.call(hexists, KEYS[1], ARGV[1]) 1 then redis.call(hincrby, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 else return 0 end ;这段脚本的逻辑是如果 key 不存在说明目前没有锁则创建一个 Hashfield 为 tokenvalue 初始化为 1并设置过期时间如果 key 存在且 token 对应的 field 存在说明是同一个持有者重入直接把计数加一同时刷新过期时间如果 key 存在但 field 不是当前 token说明锁被其他请求持有返回 0 表示获取失败。释放锁的脚本对应为const string releaseScript if redis.call(hexists, KEYS[1], ARGV[1]) 0 then return -1 end local count redis.call(hincrby, KEYS[1], ARGV[1], -1) if count 0 then redis.call(del, KEYS[1]) return 0 else redis.call(pexpire, KEYS[1], ARGV[2]) return count end ;释放时先确认自己的 field 是否存在。如果不存在说明锁已经不属于自己此时不能删除返回 -1。如果存在将计数减一计数归零才真正删除整个 key否则只刷新过期时间并返回剩余计数。到这里可重入能力已经实现。但还有一个关键问题没有解决如果业务执行时间超过了 key 的过期时间锁还是会提前消失。接下来实现看门狗。7. 升级三看门狗自动续期看门狗要解决的问题很具体你预估业务执行时间不超过 5 秒把锁过期时间设成 30 秒结果某天遇到慢 SQL一个分布式任务跑了 35 秒。第 30 秒时锁被 Redis 自动删除另一个实例趁机进入临界区重复执行了任务。这种问题不是靠“把过期时间调大”能根治的因为你永远无法准确预估最坏情况。看门狗的思路是获取锁成功之后启动一个后台任务每隔一段时间对锁执行一次续期。比如锁过期时间是 30 秒看门狗每 10 秒续期一次把 key 的过期时间重新刷新到 30 秒后。只要业务线程仍然存活锁就不会提前过期。续期脚本也要校验持有者身份防止业务已经执行完、锁被释放后看门狗又把另一个请求的锁给续期了const string renewScript if redis.call(hexists, KEYS[1], ARGV[1]) 1 then return redis.call(pexpire, KEYS[1], ARGV[2]) else return 0 end ;后台任务的代码大致是这样private async Task KeepAliveAsync( string key, string token, long expiryMs, CancellationToken cancellationToken) { var interval TimeSpan.FromMilliseconds(expiryMs / 3); while (!cancellationToken.IsCancellationRequested) { try { await Task.Delay(interval, cancellationToken); if (cancellationToken.IsCancellationRequested) { break; } await _db.ScriptEvaluateAsync( renewScript, new RedisKey[] { key }, new RedisValue[] { token, expiryMs.ToString() }); } catch (OperationCanceledException) { break; } catch (Exception ex) { // Redis 网络抖动时不要直接退出短暂等待后继续重试 _logger.LogWarning(ex, Redis 锁续期失败稍后重试key{Key}, key); try { await Task.Delay(500, cancellationToken); } catch (OperationCanceledException) { break; } } } }这里有两个细节需要注意。第一个细节是间隔时间。常见做法是过期时间的三分之一这样即使某一次续期失败下一次续期时锁也还有剩余时间不会立刻过期。第二个细节是你必须记录返回的CancellationTokenSource和后台任务在业务释放锁时先取消看门狗再释放锁。如果忘记取消后台任务会继续空转虽然续期脚本会因为HEXISTS判断为 0 而停止刷新但白白占用一个 Task。看门狗并不能解决所有极端场景。如果应用进程出现长时间 GC 停顿或者被调试器挂起看门狗本身也可能无法运行锁照样会过期。任何分布式锁都无法避免这种“进程假死”问题这是分布式系统最好的权衡之一。我们要做的是把这种风险概率降到足够低。8. 生产级完整实现订单防重复提交实战现在把前几章的升级整合成一个完整的可运行项目。示例业务是订单防重复提交同一个用户在同一时刻只能有一个创建订单请求在执行。8.1 完整 RedisLockService新建文件Infrastructure/RedisLockService.csusing Microsoft.Extensions.Logging; using StackExchange.Redis; namespace OrderApi.Infrastructure; public sealed class RedisLockService { private readonly IDatabase _db; private readonly ILoggerRedisLockService _logger; public RedisLockService(IConnectionMultiplexer connection, ILoggerRedisLockService logger) { _db connection.GetDatabase(); _logger logger; } public async TaskRedisLockHandle AcquireAsync( string key, string token, TimeSpan expiry, TimeSpan waitTime, CancellationToken cancellationToken default) { var expiryMs (long)expiry.TotalMilliseconds; var startTime DateTime.UtcNow; var acquired await TryAcquireAsync(key, token, expiryMs); while (!acquired DateTime.UtcNow - startTime waitTime) { await Task.Delay(50, cancellationToken); acquired await TryAcquireAsync(key, token, expiryMs); } if (!acquired) { return RedisLockHandle.Failed; } _logger.LogInformation(获取锁成功key{Key}, key); var cts new CancellationTokenSource(); var keepAliveTask KeepAliveAsync(key, token, expiryMs, cts.Token); return new RedisLockHandle(this, key, token, expiryMs, cts, keepAliveTask); } private async Taskbool TryAcquireAsync(string key, string token, long expiryMs) { const string lockScript if redis.call(exists, KEYS[1]) 0 then redis.call(hset, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 elseif redis.call(hexists, KEYS[1], ARGV[1]) 1 then redis.call(hincrby, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 else return 0 end ; var result await _db.ScriptEvaluateAsync( lockScript, new RedisKey[] { key }, new RedisValue[] { token, expiryMs.ToString() }); return (long)result 1; } public async Tasklong ReleaseAsync(string key, string token, long expiryMs) { const string releaseScript if redis.call(hexists, KEYS[1], ARGV[1]) 0 then return -1 end local count redis.call(hincrby, KEYS[1], ARGV[1], -1) if count 0 then redis.call(del, KEYS[1]) return 0 else redis.call(pexpire, KEYS[1], ARGV[2]) return count end ; var result await _db.ScriptEvaluateAsync( releaseScript, new RedisKey[] { key }, new RedisValue[] { token, expiryMs.ToString() }); var code (long)result; if (code 0) { _logger.LogInformation(释放锁成功key{Key}, key); } return code; } private async Task KeepAliveAsync( string key, string token, long expiryMs, CancellationToken cancellationToken) { var interval TimeSpan.FromMilliseconds(expiryMs / 3); while (!cancellationToken.IsCancellationRequested) { try { await Task.Delay(interval, cancellationToken); if (cancellationToken.IsCancellationRequested) { break; } const string renewScript if redis.call(hexists, KEYS[1], ARGV[1]) 1 then return redis.call(pexpire, KEYS[1], ARGV[2]) else return 0 end ; await _db.ScriptEvaluateAsync( renewScript, new RedisKey[] { key }, new RedisValue[] { token, expiryMs.ToString() }); } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogWarning(ex, Redis 锁续期失败稍后重试key{Key}, key); try { await Task.Delay(500, cancellationToken); } catch (OperationCanceledException) { break; } } } } }8.2 RedisLockHandle锁句柄与自动释放新建文件Infrastructure/RedisLockHandle.cs。它实现了IAsyncDisposable配合await using使用可以在代码块结束时自动取消看门狗并释放锁namespace OrderApi.Infrastructure; public sealed class RedisLockHandle : IAsyncDisposable { public static RedisLockHandle Failed { get; } new(null, null, null, 0, null, null); private readonly RedisLockService? _service; private readonly string? _key; private readonly string? _token; private readonly long _expiryMs; private readonly CancellationTokenSource? _cts; private readonly Task? _keepAliveTask; private int _disposed; private RedisLockHandle( RedisLockService? service, string? key, string? token, long expiryMs, CancellationTokenSource? cts, Task? keepAliveTask) { _service service; _key key; _token token; _expiryMs expiryMs; _cts cts; _keepAliveTask keepAliveTask; IsAcquired service is not null; } public bool IsAcquired { get; } public static RedisLockHandle Create( RedisLockService service, string key, string token, long expiryMs, CancellationTokenSource cts, Task keepAliveTask) { return new RedisLockHandle(service, key, token, expiryMs, cts, keepAliveTask); } public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) 1) { return; } _cts?.Cancel(); try { if (_keepAliveTask is not null) { await _keepAliveTask; } } catch { } if (_service is not null _key is not null _token is not null) { await _service.ReleaseAsync(_key, _token, _expiryMs); } } }为了让AcquireAsync返回的RedisLockHandle能访问私有的构造函数也可以在同一个文件中写一个工厂方法。上面的代码里我增加了一个Create静态方法AcquireAsync中需要改成调用它return RedisLockHandle.Create(this, key, token, expiryMs, cts, keepAliveTask);这样RedisLockHandle.Failed的单例也能正常使用。8.3 注册服务和编写 Controller编辑Program.csusing OrderApi.Infrastructure; using StackExchange.Redis; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var redisConnectionString builder.Configuration.GetConnectionString(Redis) ?? throw new InvalidOperationException(Redis connection string not configured.); builder.Services.AddSingletonIConnectionMultiplexer(_ ConnectionMultiplexer.Connect(redisConnectionString)); builder.Services.AddScopedRedisLockService(); var app builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.MapControllers(); app.Run();新建Models/OrderRequest.csnamespace OrderApi.Models; public class OrderRequest { public long UserId { get; set; } public string OrderNo { get; set; } string.Empty; }新建Controllers/OrdersController.csusing Microsoft.AspNetCore.Mvc; using OrderApi.Infrastructure; using OrderApi.Models; namespace OrderApi.Controllers; [ApiController] [Route(api/[controller])] public class OrdersController : ControllerBase { private readonly RedisLockService _lockService; private readonly ILoggerOrdersController _logger; public OrdersController(RedisLockService lockService, ILoggerOrdersController logger) { _lockService lockService; _logger logger; } [HttpPost] public async TaskIActionResult Create(OrderRequest request) { var lockKey $order:create:{request.UserId}; var token HttpContext.TraceIdentifier; await using var handle await _lockService.AcquireAsync( lockKey, token, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(3)); if (!handle.IsAcquired) { return Conflict(new { message 操作太频繁请稍后再试 }); } // 真正的业务逻辑这里只做模拟 _logger.LogInformation(开始创建订单用户 {UserId}单号 {OrderNo}, request.UserId, request.OrderNo); await Task.Delay(200); return Ok(new { code 0, message 创建成功, orderNo request.OrderNo }); } }这里锁粒度按UserId划分同一个用户同时只能有一个创建订单请求在跑。token 使用HttpContext.TraceIdentifier同一个请求的不同嵌套调用会持有同一个 traceId因此天然支持可重入。如果两个不同的请求同时提交第二个请求会在 3 秒等待时间内反复重试拿不到锁直接返回 409。8.4 运行与验证启动应用dotnet run应用默认监听端口视项目配置而定通常是通过launchSettings.json配置。接下来用 curl 发起请求curl -X POST http://localhost:5232/api/orders \ -H Content-Type: application/json \ -d {\userId\:1001,\orderNo\:\P20250101001\}为了模拟并发重复提交可以在 bash 里循环发送 20 个请求for i in {1..20}; do curl -s -X POST http://localhost:5232/api/orders \ -H Content-Type: application/json \ -d {\userId\:1001,\orderNo\:\P20250101001\} done wait正常情况下并发请求中只有一个进入业务逻辑其余请求返回 409。在 Redis 中观察锁的状态docker exec -it redis7 redis-cli执行keys order:create:*如果在业务还在执行时查看能看到一个 Hash keyhgetall order:create:1001返回结果类似TRACEID值 1业务执行完后锁自动释放key 被删除。如果业务里故意把Task.Delay改成 35 秒你可以在 Redis 里观察到锁的 TTL 不断被看门狗刷新而不是在第 30 秒消失ttl order:create:1001正常情况下 TTL 会保持在 30 秒附近始终不会被减到 0。9. 常见问题排查与生产最佳实践把代码写完只是第一步。下面这些问题是实际项目中比较容易踩的坑建议对照排查。9.1 常见问题排查表问题现象可能原因排查方式解决方案一直拿不到锁接口阻塞Redis 连接不可用锁被其他请求持有且未释放redis-cli pingredis-cli keys *查看锁是否存在检查 Redis 连接确认等待时间是否合理业务没执行完锁就提前消失过期时间太短看门狗没有启动查看服务日志中是否有续期记录redis-cli ttl key观察 TTL设置更合理的过期时间确认看门狗没有被错误取消释放锁时误删了别人的锁没有使用 token 校验释放时直接DEL检查释放锁的代码是否用了 Lua 脚本用 token Lua 脚本释放Redis 中出现大量过期锁进程崩溃前没有释放加锁没有设置过期时间redis-cli ttl key查看是否有永久 key所有加锁操作必须携带过期时间可重入失效两次获取锁使用了不同 token检查 token 是否来自 TraceId 或同一个业务上下文统一使用请求级 traceId锁释放后看门狗仍在空转释放前没有取消 CancellationTokenSource查看后台任务数量在DisposeAsync中先Cancel再释放9.2 生产环境最佳实践第一锁的 key 命名要带上业务语义。推荐格式是业务域:操作:目标ID例如order:create:1001。这样在 Redis 里排查问题、统计锁的使用情况都非常方便。第二token 不要用固定字符串也不要只用简单 GUID。在 WebAPI 场景中首选HttpContext.TraceIdentifier这样的请求级唯一标识它能让同一个请求内部的多次锁获取自然共享一个 token。第三过期时间和看门狗间隔需要成组设置。常见做法是锁默认过期 30 秒看门狗每 10 秒续期一次。如果你在某个业务中显式把过期时间设为 60 秒看门狗也会自动按 20 秒间隔续期因为代码里已经根据expiryMs / 3计算了间隔。第四锁粒度要尽量小。能锁用户维度就不要锁全站能锁订单维度就不要锁用户全部订单。锁粒度越粗并发能力越差Redis 压力也越大。第五看门狗数量要控制。如果系统里并发的锁很多每个锁持有一个后台KeepAliveAsync任务可能造成线程和连接资源浪费。更极端的情况是成百上千个锁同时存在每个锁每 10 秒执行一次脚本对 Redis 会产生可观的 QPS。遇到这种场景可以考虑使用一个统一的Timer批量续期或者用延迟队列管理待续期锁但这是另一个工程话题。第六分布式锁不能替代幂等设计。锁只能在“同一时刻”挡住重复请求如果第一个请求执行完并释放锁之后第二个请求才进来锁就无能为力了。真实的订单防重应该同时使用数据库唯一索引或者订单表中的唯一业务号让数据库在最后一道关卡兜底。记住这句话分布式锁减少并发冲突唯一索引保证数据最终不重。第七如果 Redis 使用了主从 哨兵的高可用架构主节点发生故障切换时会短暂丢失锁数据极端情况下两个客户端可能同时持有锁。如果你的业务对一致性要求极高宁可降低一点性能也要使用 etcd 或 ZooKeeper 这种带分布式共识能力的组件来实现锁。如果业务可以接受极小概率的锁失效那 Redis 方案是性价比最高的选择。10. 把锁沉淀成团队公共组件最后聊一个工程层面的建议不要把上面这段代码在业务 Controller 里到处复制。推荐做法是把它放到团队的基础库项目中作为公共组件统一维护。Controller 里只需要依赖RedisLockService把锁 key、token、过期时间和等待时间传进去业务代码保持干净。这套实现的完整链路是AcquireAsync加锁成功 → 启动看门狗续期 → 业务执行 →DisposeAsync取消看门狗并安全释放锁。无论业务方法抛出异常还是正常返回await using都能保证资源释放。如果团队里每个人都在自己项目里重新写一套锁最后一定会有某个版本少一个 Lua 脚本或者忘掉看门狗线上问题就是这么来的。分布式锁的知识点非常适合用来做技术分享和面试复盘。当你把“锁超时自动释放、防误删锁、可重入锁、看门狗续期”这条主线完整讲清楚面试官会知道你不只是背过 Redis 命令而是真正理解过生产环境的边界条件。这就是这篇文章希望帮你建立的能力。