ARTICLE DETAIL

资讯详情

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

.NET 10与Redis生产级分布式锁:可重入、看门狗续期与防误删

.NET 10与Redis生产级分布式锁:可重入、看门狗续期与防误删 这次我们来看一个 .NET 后端和 Redis 结合生产级分布式锁的完整项目。很多人在项目里用SETNX加锁、DEL解锁觉得“分布式锁我做过”但一旦遇到业务耗时超过锁过期时间、同一个请求里需要重入、或者锁被别的线程误删这套简易方案就会出问题。这个项目的重点不是概念堆砌而是把锁超时自动释放、看门狗续期、可重入锁、防误删锁四件事一次性落地跑在 .NET 10 WebAPI Redis 的真实接口里。先说这个项目能帮你解决什么。如果你在写秒杀扣库存最怕超卖如果你在写订单提交接口最怕用户双击产生两条订单如果你在做多实例部署的定时任务最怕多个节点同时执行。这些场景的本质都是多个线程、多个进程、甚至多台服务器同时操作同一个资源需要通过一个所有节点都能访问到的组件来做互斥。Redis 分布式锁就是这套基础设施。整个项目的核心代码全部可以使用官方StackExchange.Redis客户端实现不依赖第三方分布式锁组件。这是最容易被忽视的优势——你不需要额外引入一个“锁框架”只要项目里本来就有 Redis这套锁服务就能直接嵌入现有 WebAPI 项目。下面我会从环境准备、Redis 安装、项目初始化、锁服务核心实现、WebAPI 接口、并发批量测试、问题排查一条线走完。1. 核心能力速览能力项说明技术栈.NET 10 WebAPI StackExchange.Redis Redis 7解决问题多线程、多实例并发下资源互斥防止超卖、重复提交、任务重复执行锁超时自动释放加锁时设置 TTL业务崩溃时 Redis 自动删除锁看门狗续期未显式指定过期时间时后台任务按固定间隔自动续期避免锁提前过期可重入锁同一业务链路中用同一 ownerId 多次加锁计数释放不会自己锁死自己防误删锁加锁时生成全局唯一 ownerId删除前用 Lua 脚本校验归属核心存储结构Redis Hashfield 为 ownerIdvalue 为重入计数启动方式常规dotnet run/ Visual Studio 启动 / Docker 部署均可是否支持 API是本文给出两个 WebAPI 演示接口是否支持批量任务是本文提供并发批量扣库存验证脚本Redis 门槛Docker 一条命令启动Windows 可用 Memurai 兼容适合场景秒杀库存、订单防重、分布式定时任务、幂等控制从能力表可以看到这已经不是一个“理论演示项目”而是能直接搬到自己业务里的基础设施。显存、显卡这些 AI 项目关注的参数在这里不涉及本文的重点是锁的可靠性、代码可维护性和并发正确性。2. 分布式锁使用场景与使用边界2.1 适合做什么最典型的场景是秒杀和库存扣减。库存扣减是典型的 read-modify-write 操作先从数据库或者缓存读取库存判断大于 0 后减一再写回。如果两个请求同时读到库存为 1各自减一后写回最终库存变成 0但实际卖出了两件。用分布式锁把“读库存、判断、减一、写回”这个临界区保护起来就能避免超卖。第二个场景是防重复提交。用户点击“提交订单”按钮前端通常会做按钮置灰但不可靠。网络重试、用户刷新、后端消息重复投递都可能让同一个订单请求到达后端多次。分布式锁配合业务幂等键可以让同一订单 ID 在同一时间段内只被处理一次。第三个场景是多实例定时任务互斥。微服务多副本部署后HostedService 或 Job 调度器会在每个实例上都触发一次。如果没有互斥机制一个清理任务会被执行 N 次。利用 Redis 分布式锁只有拿到锁的实例才真正执行任务未拿到锁的实例直接跳过本次调度。2.2 不适合做什么分布式锁不适合作为唯一的数据一致性手段。锁只是第一道防线数据库唯一索引、版本号乐观锁、业务状态机校验仍然要保留。锁是降低冲突概率数据库约束才是最终兜底。分布式锁也不适合跨网络分区、Redis 本身不可用的极端场景。如果 Redis 宕机理论上所有拿锁操作都会失败业务会被降级或拒绝。如果业务要求“Redis 挂了也能继续”你需要额外的本地降级策略而不是把全部希望押在分布式锁上。2.3 合规与安全边界使用分布式锁时锁的 key 通常会包含业务标识例如用户 ID、订单 ID。要避免在 key 中明文存放身份证号、手机号等敏感个人信息必要时应做哈希处理。日志中也不要把锁的完整信息和业务敏感数据一起输出。本文所有演示接口均基于本机测试环境不涉及真实生产数据。如果你要在生产系统部署必须经过代码评审、压测和灰度验证。涉及用户数据时还要遵守个人信息保护的相关要求。3. 环境准备与前置条件3.1 .NET 10 SDK本项目基于 .NET 10。.NET 10 是 Microsoft 发布的新一代 LTS 版本支持周期较长适合生产项目选型。如果本机还没有 .NET 10 SDK需要先安装。安装完成后打开终端验证dotnet --version能看到10.x.x就说明 SDK 就绪。如果你用的是 Visual Studio需要确保版本支持 .NET 10。命令行创建项目时可以指定-f net10.0VS 里新建项目时也可以在目标框架下拉框中选择 .NET 10.0。3.2 Redis 服务推荐优先使用 Docker 启动 Redis速度快、环境隔离、卸载方便docker run -d \ --name redis7 \ -p 6379:6379 \ -v redis-data:/data \ redis:7-alpine如果本机没有 DockerWindows 用户可以考虑 Memurai它兼容 Redis 协议可以作为 Windows 本机开发时的替代品。这里不展开安装细节你只需要保证测试机上有可用的 Redis 服务并且知道连接串即可。测试连接可以用命令行redis-cli -h 127.0.0.1 -p 6379 ping返回PONG表示 Redis 可用。3.3 Redis 可视化工具调试分布式锁时能直观看到 key 的 TTL 变化会很有帮助。可以准备一款 Redis 客户端工具例如 Redis Desktop Manager 或其他类似的 Redis 可视化客户端。主要用来看三点lock:前缀的 key 是否存在、它的 TTL 是否被看门狗刷新、Hash 里的 field 和 value 是否正常。3.4 项目初始化命令打开终端创建一个新的 WebAPI 项目dotnet new webapi -controllers -n DotNet10.RedisLock.Demo -f net10.0 cd DotNet10.RedisLock.Demo dotnet add package StackExchange.Redis如果模板默认生成的是最小 API 结构加-controllers参数就会生成 Controllers 目录方便演示传统 WebAPI。下面把项目改用launchSettings.json中固定的端口方便后续并发测试。4. 项目结构与 Redis 连接配置4.1 目录结构DotNet10.RedisLock.Demo/ ├── Controllers/ │ ├── StockController.cs │ └── OrderController.cs ├── Infra/ │ ├── RedisLockOptions.cs │ ├── IDistributedLockService.cs │ ├── DistributedLockService.cs │ └── RedisLockHandle.cs ├── appsettings.json └── Program.csInfra目录放锁服务基础设施Controllers放业务接口。这样结构清晰锁服务可以单独复制到其他项目复用。4.2 NuGet 说明核心客户端是StackExchange.Redis。它是 .NET 生态最主流的 Redis 客户端提供IDatabase.ScriptEvaluateAsync方法可以执行 Lua 脚本是实现原子性锁操作的基础。生产环境注意要使用稳定版本不要长期使用 RC 或 Preview 版本。4.3 appsettings.json 配置{ ConnectionStrings: { Redis: 127.0.0.1:6379,abortConnectfalse }, RedisLock: { DefaultLeaseTimeSeconds: 30, RenewalIntervalSeconds: 10, AcquireWaitTimeMilliseconds: 5000, RetryIntervalMilliseconds: 100 }, Logging: { LogLevel: { Default: Information, Microsoft.AspNetCore: Warning } }, AllowedHosts: * }这里几个配置参数作用非常关键DefaultLeaseTimeSeconds默认锁过期时间。业务异常崩溃时Redis 会在到期后自动删除锁避免死锁。RenewalIntervalSeconds看门狗续期间隔必须小于DefaultLeaseTimeSeconds一般设置为过期时间的 1/3。AcquireWaitTimeMilliseconds获取锁的最长等待时间。超过这个时间还没拿到锁放弃执行。RetryIntervalMilliseconds获取锁失败后的重试间隔。4.4 Program.cs 注册服务using DotNet10.RedisLock.Demo.Infra; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.ConfigureRedisLockOptions( builder.Configuration.GetSection(RedisLock)); builder.Services.AddSingletonIConnectionMultiplexer(sp ConnectionMultiplexer.Connect( builder.Configuration.GetConnectionString(Redis)!)); builder.Services.AddSingletonIDistributedLockService, DistributedLockService(); var app builder.Build(); app.UseAuthorization(); app.MapControllers(); app.Run();注意IConnectionMultiplexer是单例整个应用复用一个 Redis 连接对象IDistributedLockService也是单例。锁的实时状态存在 Redis 中服务本身是无状态的。5. 核心代码实现生产级分布式锁这一节是重点。我会把锁的四个核心能力用代码完整实现加锁、可重入、看门狗续期、安全释放。5.1 配置项与接口定义namespace DotNet10.RedisLock.Demo.Infra; public sealed class RedisLockOptions { public int DefaultLeaseTimeSeconds { get; set; } 30; public int RenewalIntervalSeconds { get; set; } 10; public int AcquireWaitTimeMilliseconds { get; set; } 5000; public int RetryIntervalMilliseconds { get; set; } 100; }接口定义namespace DotNet10.RedisLock.Demo.Infra; public interface IDistributedLockService { TaskRedisLockHandle? AcquireAsync( string key, TimeSpan? leaseTime null, TimeSpan? waitTime null, string? ownerId null); Task ExecuteWithLockAsync( string key, FuncTask action, TimeSpan? leaseTime null, TimeSpan? waitTime null, string? ownerId null); }AcquireAsync返回一个锁句柄RedisLockHandle业务代码可以通过await using在离开作用域时自动释放。ExecuteWithLockAsync是业务封装适合最常用的场景。5.2 RedisLockHandle锁句柄与看门狗RedisLockHandle是分布式锁的核心句柄类负责持有锁的元数据并在后台启动看门狗续期任务。namespace DotNet10.RedisLock.Demo.Infra; public sealed class RedisLockHandle : IAsyncDisposable { private readonly IDatabase _db; private readonly string _key; private readonly string _ownerId; private readonly TimeSpan _leaseTime; private readonly TimeSpan _renewalInterval; private readonly CancellationTokenSource _cts new(); private Task? _watchdogTask; private bool _released; private static readonly string RenewScript if redis.call(hexists, KEYS[1], ARGV[1]) 1 then return redis.call(pexpire, KEYS[1], ARGV[2]) end return 0 ; private static readonly string ReleaseScript if redis.call(hexists, KEYS[1], ARGV[1]) 0 then return 0 end local counter redis.call(hincrby, KEYS[1], ARGV[1], -1) if counter 0 then redis.call(del, KEYS[1]) return 1 end redis.call(pexpire, KEYS[1], ARGV[2]) return 1 ; internal RedisLockHandle( IDatabase db, string key, string ownerId, TimeSpan leaseTime, TimeSpan renewalInterval) { _db db; _key key; _ownerId ownerId; _leaseTime leaseTime; _renewalInterval renewalInterval; } public string Key _key; public string OwnerId _ownerId; internal void StartWatchdog() { _watchdogTask RenewalLoopAsync(_cts.Token); } public async Taskbool ReleaseAsync() { if (_released) { return true; } _released true; _cts.Cancel(); try { if (_watchdogTask is not null) { await _watchdogTask; } } catch (OperationCanceledException) { } var result (long)await _db.ScriptEvaluateAsync( ReleaseScript, new RedisKey[] { _key }, new RedisValue[] { _ownerId, (long)_leaseTime.TotalMilliseconds }); return result 0; } public async ValueTask DisposeAsync() { await ReleaseAsync(); } private async Task RenewalLoopAsync(CancellationToken token) { using var timer new PeriodicTimer(_renewalInterval); try { while (await timer.WaitForNextTickAsync(token)) { var result (long)await _db.ScriptEvaluateAsync( RenewScript, new RedisKey[] { _key }, new RedisValue[] { _ownerId, (long)_leaseTime.TotalMilliseconds }); if (result 0) { return; } } } catch (OperationCanceledException) { } } }这里的关键点第一锁的存储结构是 Hash 而不是普通的 String。Hash 的 key 是锁名称field 是ownerIdvalue 是重入计数。这样可以让一个ownerId对同一把锁重入多次同时还能保留 TTL 自动过期能力。第二续期脚本只续 ownerId 匹配的锁。如果锁已经被另一个线程获取说明当前线程的锁已经因为某种原因丢失了续期脚本返回 0看门狗自动停止续期。这比“无脑续期”安全得多。第三释放脚本处理了重入计数。每次调用ReleaseAsync只把计数器减 1只有计数归零时才真正删除 key。这样外层锁没释放时内层重入锁释放不会误删外层锁。5.3 DistributedLockService加锁逻辑与可重入接下来是加锁服务的实现namespace DotNet10.RedisLock.Demo.Infra; using Microsoft.Extensions.Options; public sealed class DistributedLockService : IDistributedLockService { private readonly IDatabase _db; private readonly RedisLockOptions _options; private static readonly 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 end if 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 end return 0 ; public DistributedLockService( IConnectionMultiplexer redis, IOptionsRedisLockOptions options) { _db redis.GetDatabase(); _options options.Value; } public async TaskRedisLockHandle? AcquireAsync( string key, TimeSpan? leaseTime null, TimeSpan? waitTime null, string? ownerId null) { var lockKey $lock:{key}; var lockOwner ownerId ?? ${Environment.MachineName}:{Guid.NewGuid():N}; var lockLease leaseTime ?? TimeSpan.FromSeconds(_options.DefaultLeaseTimeSeconds); var maxWait waitTime ?? TimeSpan.FromMilliseconds(_options.AcquireWaitTimeMilliseconds); var retryInterval TimeSpan.FromMilliseconds(_options.RetryIntervalMilliseconds); var deadline DateTime.UtcNow maxWait; while (true) { var acquired await TryAcquireAsync(lockKey, lockOwner, lockLease); if (acquired) { var handle new RedisLockHandle( _db, lockKey, lockOwner, lockLease, TimeSpan.FromSeconds(_options.RenewalIntervalSeconds)); if (leaseTime is null) { handle.StartWatchdog(); } return handle; } if (DateTime.UtcNow deadline) { return null; } await Task.Delay(retryInterval); } } private async Taskbool TryAcquireAsync( string lockKey, string ownerId, TimeSpan leaseTime) { var result (long)await _db.ScriptEvaluateAsync( LockScript, new RedisKey[] { lockKey }, new RedisValue[] { ownerId, (long)leaseTime.TotalMilliseconds }); return result 1; } public async Task ExecuteWithLockAsync( string key, FuncTask action, TimeSpan? leaseTime null, TimeSpan? waitTime null, string? ownerId null) { await using var handle await AcquireAsync(key, leaseTime, waitTime, ownerId); if (handle is null) { throw new TimeoutException($获取分布式锁超时:{key}); } await action(); } }AcquireAsync是阻塞式加锁当锁被其他线程持有时会按照RetryIntervalMilliseconds间隔重试直到超过waitTime。这种设计适合需要“拿不到锁就等待”的业务。如果业务要求“拿不到锁立刻失败”可以把waitTime设为TimeSpan.Zero或者由业务层对 null 结果做降级。5.4 可重入的调用方式可重入的场景很常见外层方法加锁调用了一个业务方法业务方法内部又对同一个资源加锁。如果锁不支持重入第二次加锁会失败造成死锁。这个方案的调用方式很简单var ownerId $order:{orderId}:{Guid.NewGuid():N}; await _lockService.ExecuteWithLockAsync(order:20260101, async () { // 外层业务 await _lockService.ExecuteWithLockAsync(order:20260101, async () { // 内层业务同一个 ownerId 才能重入 }, ownerId: ownerId); }, ownerId: ownerId);因为两次加锁使用了同一个ownerIdRedis 脚本识别到 Hash 中已有相同 field就会执行HINCRBY可重入成功。如果两次加锁不传同一个ownerId第二次会返回 0。所以在真实项目中如果你想在同一条业务链路里重入就必须保证同一个 ownerId 传递下去。我可以提供一个方案把 ownerId 放到AsyncLocal或者方法入参里向下传递或者更简单一点业务层封装一个“同一个请求上下文内自动生成一次 ownerId”的工厂。这里我们演示一个更工程化的方案在 Controller 中为一次业务请求生成 ownerId并传入所有可能加锁的方法。这样可以保证整条调用链的锁归属一致。5.5 为什么要用 Lua 脚本分布式锁最容易踩的坑是“非原子操作”。比如先判断 key 是否存在再 set两步操作之间插入了其他请求就会导致多人拿到同一把锁。Lua 脚本在 Redis 服务端是原子执行的整个脚本不会被其他命令插入从根本上避免了竞态条件。本项目的加锁、续期、释放都使用 Lua 脚本这也是它能做到防误删和可重入的基础。如果你在面试中被问到“Redis 分布式锁的原理”可以先回答 Lua 脚本保证原子性再回答 Hash 结构实现可重入最后回答看门狗实现续期这个回答层次是完整的。6. WebAPI 接口实现与并发验证现在把锁服务接入真实的 WebAPI 接口。我会写两个演示接口秒杀扣库存和防重复提交。6.1 秒杀扣库存using DotNet10.RedisLock.Demo.Infra; using Microsoft.AspNetCore.Mvc; namespace DotNet10.RedisLock.Demo.Controllers; [ApiController] [Route(api/stock)] public sealed class StockController : ControllerBase { private static int _stock 100; private readonly ILoggerStockController _logger; private readonly IDistributedLockService _lockService; public StockController( ILoggerStockController logger, IDistributedLockService lockService) { _logger logger; _lockService lockService; } [HttpPost(deduct)] public async TaskIActionResult Deduct([FromQuery] int productId 1001) { var ownerId $product:{productId}:{Guid.NewGuid():N}; await using var handle await _lockService.AcquireAsync( $stock:{productId}, leaseTime: TimeSpan.FromSeconds(10), ownerId: ownerId); if (handle is null) { return StatusCode(503, new { message 系统繁忙请稍后再试 }); } if (_stock 0) { return Ok(new { success false, message 库存已售罄 }); } _stock--; _logger.LogInformation(扣减库存成功, 剩余库存: {Stock}, _stock); return Ok(new { success true, remainingStock _stock }); } [HttpGet(status)] public IActionResult Status() { return Ok(new { stock _stock }); } }这个接口的锁是模拟的库存放在内存字段_stock并发场景下如果锁失效可以明显看到库存扣成负数。实际项目中库存应该放在数据库或 Redis 计数器中这里的重点是演示分布式锁的互斥效果。6.2 防重复提交接口using DotNet10.RedisLock.Demo.Infra; using Microsoft.AspNetCore.Mvc; namespace DotNet10.RedisLock.Demo.Controllers; [ApiController] [Route(api/order)] public sealed class OrderController : ControllerBase { private readonly ILoggerOrderController _logger; private readonly IDistributedLockService _lockService; public OrderController( ILoggerOrderController logger, IDistributedLockService lockService) { _logger logger; _lockService lockService; } [HttpPost(submit)] public async TaskIActionResult Submit([FromBody] SubmitOrderRequest request) { if (request.OrderId 0) { return BadRequest(new { message OrderId 不能为空 }); } var ownerId $order:{request.OrderId}:{Guid.NewGuid():N}; var mutexKey $order:submit:{request.OrderId}; await using var handle await _lockService.AcquireAsync( mutexKey, leaseTime: TimeSpan.FromSeconds(5), waitTime: TimeSpan.FromSeconds(3), ownerId: ownerId); if (handle is null) { return Conflict(new { message 订单正在处理中请勿重复提交 }); } // 模拟订单创建耗时 await Task.Delay(300); _logger.LogInformation(订单 {OrderId} 提交成功, request.OrderId); return Ok(new { success true, orderId request.OrderId }); } } public sealed record SubmitOrderRequest(long OrderId);这里用Conflict返回 409语义更准确客户端请求与服务器当前状态冲突通常就是重复提交。6.3 批量并发验证部署启动项目后可以用下面的脚本模拟高并发扣库存。先拿到当前库存然后同时发 200 个扣库存请求看最终库存是否为 0。# 先查看初始库存 curl http://localhost:5000/api/stock/status # 并发请求脚本200 个请求同时发 for i in $(seq 1 200); do curl -s -X POST http://localhost:5000/api/stock/deduct?productId1001 /dev/null done wait因为 WebAPI 默认并发处理能力有限实际并发度取决于线程池和 HttpClient 连接限制。这个脚本在 Linux/Mac 下可以跑Windows 下用 PowerShell 也可以实现类似效果。更可控的是用 .NET 本身写一个并发压测using System.Diagnostics; var httpClient new HttpClient(); var tasks new ListTaskHttpResponseMessage(); var stopwatch Stopwatch.StartNew(); for (var i 0; i 200; i) { tasks.Add(httpClient.PostAsync( http://localhost:5000/api/stock/deduct?productId1001, null)); } var responses await Task.WhenAll(tasks); var successCount responses.Count(r r.IsSuccessStatusCode); stopwatch.Stop(); Console.WriteLine($成功响应数: {successCount}); Console.WriteLine($耗时: {stopwatch.ElapsedMilliseconds} ms);如果锁正常工作库存最多扣到 0不会出现负数并且成功扣减的请求数等于初始库存数。如果锁失效你会看到库存变成负数或者成功数超过初始库存。7. API 调用示例与 Redis 数据观察7.1 正常调用启动项目后调用扣库存接口curl -X POST http://localhost:5000/api/stock/deduct?productId1001预期响应{ success: true, remainingStock: 99 }调用订单接口curl -X POST http://localhost:5000/api/order/submit \ -H Content-Type: application/json \ -d {\orderId\: 100001}预期响应{ success: true, orderId: 100001 }7.2 观察 Redis 中的锁 key在接口执行过程中用 Redis 客户端工具查看lock:stock:1001Hash: field: product:1001:xxxx value: 1 TTL: 10 秒如果业务执行时间很短锁会在操作完成后自动删除key 很快消失。如果想观察锁的存在可以在 Controller 中人为加一个await Task.Delay(3000)在延迟期间刷新 Redis 客户端就能看到 Hash 结构和 TTL 在变化。重点看两点第一TTL 是否在看门狗续期。如果锁没有显式传入leaseTime看门狗启动后TTL 会周期性恢复到 30 秒。如果你发现 TTL 一直在缩短说明看门狗没有正常工作或者锁的leaseTime被显式指定后不再续期。第二Hash field 是否是当前请求的 ownerId。如果不匹配说明有别的线程持有了这把锁当前线程应该等待而不是直接释放。7.3 模拟防误删验证防误删是生产环境最容易踩的坑。为了验证“防误删”效果可以人为模拟一个场景把DefaultLeaseTimeSeconds调小到 3 秒在 Controller 的业务代码里加一个await Task.Delay(5000)让锁超时自动释放。然后第二个请求进来成功获取到锁。此时第一个请求恢复后执行ReleaseAsyncLua 脚本发现 ownerId 不匹配返回 0不会删除第二个请求的锁。如果没有防误删机制第二个请求的锁就会被第一个请求的DEL直接删掉导致并发失控。这在生产事故中是真实发生过的案例。8. 资源占用与性能观察分布式锁的 Redis 资源占用非常小。每个锁对应一个 Hash keyfield 是 ownerIdvalue 是整数计数。锁的生命周期只在业务执行期间业务结束立即删除所以 Redis 内存中同一时间只会存在极少数lock:前缀的 key。观察方式redis-cli --scan --pattern lock:*用redis-cli info keyspace可以查看当前 Redis 的 key 数量。正常情况下 lock key 数量应该等于并发执行业务的请求数量峰值过后迅速归零。.NET 服务端的资源占用主要看三点第一Redis 连接数。IConnectionMultiplexer是单例整个应用复用连接不会因为请求数量增长而无限增加连接。如果连接串中设置了abortConnectfalseRedis 暂时不可用时不会直接抛异常导致进程崩溃。第二锁的等待占用。如果大量请求在AcquireAsync里循环重试会有线程被阻塞等待。waitTime和历史堆栈可以结合日志分析。第三看门狗后台任务的线程占用。每个持有锁的请求会启动一个异步续期循环但它是一个轻量的 async 任务不占用独立线程。PeriodicTimer等待时不会阻塞线程池线程所以即使 1000 个锁同时存在也不会创建 1000 个线程。需要关注的性能风险是AcquireWaitTimeMilliseconds不要设得太大。如果锁竞争激烈大量请求在等待表现为接口 RT 升高、线程池繁忙。这时候要从业务层面拆分锁粒度而不是无限加大等待时间。锁粒度越小并发能力越强。9. 常见问题与排查方法问题现象可能原因排查方式解决方案Redis 连接超时连接串地址或端口错误redis-cli ping测试连通性修正ConnectionStrings:Redis加锁后很快自动释放显式指定了leaseTime且太短查看调用代码是否传了 leaseTime去掉显式 leaseTime 启动看门狗或调大过期时间锁一直获取不到其他线程持锁时间过长Redis 客户端查看锁 Hash 和 TTL调大waitTime检查持锁代码是否未释放Redis 中锁 key 不删除业务方法异常且未走await using查看锁 key 的 TTL使用await using或 try-finally 确保释放一个请求的锁被另一个请求误删释放时未校验 ownerId检查释放脚本是否比较 field使用本项目的 Lua 脚本释放锁可重入失败两次加锁使用了不同 ownerId检查 ownerId 传递逻辑同一条业务链路复用同一个 ownerIdStackExchange.Redis TimeoutRedis 端阻塞或连接池耗尽查看 Redis 服务端日志和慢日志优化 Redis 命令排查慢查询看门狗续期失败锁已经不属于当前 ownerId查看续期脚本返回值确认业务持锁期间没有其他线程抢占其中最容易忽略的一点是await using和 try-finally。如果业务代码在持锁期间抛出异常没有走到ReleaseAsync锁只能等 Redis TTL 自动过期。虽然不会死锁但会阻塞其他请求一段时间。所以锁的释放必须用await using包裹或者在finally中调用。另一个常见问题是锁的过期时间设置不合理。如果显式指定leaseTime为 5 秒但业务耗时普遍在 8 到 15 秒锁会提前过期另一个线程会在业务还没结束时抢到锁。如果业务耗时波动大建议不传leaseTime让看门狗自动续期。只有明确知道业务必须“到点放弃”时才显式指定过期时间。10. 最佳实践与生产加固建议10.1 锁名称规范lock:前缀已经用于标识锁后面建议继续加业务前缀和资源 ID例如lock:stock:1001 lock:order:submit:100001 lock:job:clean-expired-data命名太长会占用 Redis 内存但影响很小命名太短反而难以排查问题。生产环境建议在 key 最后加资源 ID方便定位是哪个业务操作在竞争锁。10.2 锁粒度控制锁保护的是“临界区”不是“整个业务方法”。一个常见的反模式是把外部 HTTP 调用、短信发送、文件上传这些耗时操作放在锁里。锁持有时间越长其他请求等待越久系统的并发吞吐越差。正确做法是锁内只做资源检查和状态更新其他耗时操作放到锁外。10.3 数据库与 Redis 的配合分布式锁不是唯一的数据正确性保障。扣库存业务除了加锁数据库也应该有UPDATE stock SET quantity quantity - 1 WHERE product_id id AND quantity 0这样的条件更新作为兜底。锁负责降低冲突概率数据库约束负责最终一致性。10.4 监控告警生产环境建议记录锁相关指标获取锁的等待时间持锁时间获取锁失败次数看门狗续期次数这些指标可以接入日志或 APM 系统。一旦锁的等待时间持续偏高说明锁竞争激烈需要优化锁粒度或业务逻辑。10.5 事故复盘清单如果你在生产环境遇到分布式锁相关事故建议按以下清单排查锁是否在 try-finally 或await using中释放锁的过期时间是否小于业务最大耗时释放锁时是否校验了 ownerId加锁操作是否是原子的Lua 脚本多个实例是否使用同一个 Redis 地址锁的 key 是否包含正确的业务维度业务是否在锁内执行了耗时操作10.6 安全与合规建议任何分布式锁方案都应当限定在合法授权的业务场景内。锁的 key 如果涉及用户标识建议只使用用户 ID 的不可逆哈希不存放原始敏感信息。日志打印锁内容时也要避免输出用户的隐私字段。代码评审和灰度发布是上线前的基本要求。11. 总结与下一步这个项目把分布式锁从“查个 SETNX 教程”提升到了“可以放到项目里的生产级实现”。核心价值有四点Lua 脚本保证加锁和释放的原子性Hash 结构配合 ownerId 实现可重入看门狗续期避免业务长于锁过期时间释放前校验 ownerId 防止误删。建议拿到代码后最先测试的是并发扣库存用 200 个并发请求去扣 100 个库存看最终库存是否稳定在 0同时观察 Redis 中lock:前缀 key 的生命周期。最容易踩的坑是显式传了过短的leaseTime导致锁提前失效。先去掉显式leaseTime让看门狗接管续期再逐步调参理解会更深。后续可以继续扩展的方向包括把锁服务提取成独立类库加入 Redis Cluster 环境适配增加基于 OpenTelemetry 的锁指标监控以及把这个方案接入到你现有的前后端分离项目中。如果你正在做 .NET 10 后端进阶这个项目既可以当面试手写题素材也可以直接沉淀到团队基础组件里。建议收藏备用动手跑一遍比看十遍理论更有价值。
返回列表