.NET 10与Redis生产级分布式锁:可重入、看门狗续期与防误删
2026/9/7 5:37:50 网站建设 项目流程

这次我们来看一个 .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 Hash,field 为 ownerId,value 为重入计数
启动方式常规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.0,VS 里新建项目时也可以在目标框架下拉框中选择 .NET 10.0。

3.2 Redis 服务

推荐优先使用 Docker 启动 Redis,速度快、环境隔离、卸载方便:

docker run -d \ --name redis7 \ -p 6379:6379 \ -v redis-data:/data \ redis:7-alpine

如果本机没有 Docker,Windows 用户可以考虑 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.cs

Infra目录放锁服务基础设施,Controllers放业务接口。这样结构清晰,锁服务可以单独复制到其他项目复用。

4.2 NuGet 说明

核心客户端是StackExchange.Redis。它是 .NET 生态最主流的 Redis 客户端,提供IDatabase.ScriptEvaluateAsync方法,可以执行 Lua 脚本,是实现原子性锁操作的基础。生产环境注意要使用稳定版本,不要长期使用 RC 或 Preview 版本。

4.3 appsettings.json 配置

{ "ConnectionStrings": { "Redis": "127.0.0.1:6379,abortConnect=false" }, "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.Configure<RedisLockOptions>( builder.Configuration.GetSection("RedisLock")); builder.Services.AddSingleton<IConnectionMultiplexer>(sp => ConnectionMultiplexer.Connect( builder.Configuration.GetConnectionString("Redis")!)); builder.Services.AddSingleton<IDistributedLockService, 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 { Task<RedisLockHandle?> AcquireAsync( string key, TimeSpan? leaseTime = null, TimeSpan? waitTime = null, string? ownerId = null); Task ExecuteWithLockAsync( string key, Func<Task> 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 Task<bool> 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 是ownerId,value 是重入计数。这样可以让一个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, IOptions<RedisLockOptions> options) { _db = redis.GetDatabase(); _options = options.Value; } public async Task<RedisLockHandle?> 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 Task<bool> 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, Func<Task> 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);

因为两次加锁使用了同一个ownerId,Redis 脚本识别到 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 ILogger<StockController> _logger; private readonly IDistributedLockService _lockService; public StockController( ILogger<StockController> logger, IDistributedLockService lockService) { _logger = logger; _lockService = lockService; } [HttpPost("deduct")] public async Task<IActionResult> 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 ILogger<OrderController> _logger; private readonly IDistributedLockService _lockService; public OrderController( ILogger<OrderController> logger, IDistributedLockService lockService) { _logger = logger; _lockService = lockService; } [HttpPost("submit")] public async Task<IActionResult> 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?productId=1001" > /dev/null & done wait

因为 WebAPI 默认并发处理能力有限,实际并发度取决于线程池和 HttpClient 连接限制。这个脚本在 Linux/Mac 下可以跑,Windows 下用 PowerShell 也可以实现类似效果。

更可控的是用 .NET 本身写一个并发压测:

using System.Diagnostics; var httpClient = new HttpClient(); var tasks = new List<Task<HttpResponseMessage>>(); var stopwatch = Stopwatch.StartNew(); for (var i = 0; i < 200; i++) { tasks.Add(httpClient.PostAsync( "http://localhost:5000/api/stock/deduct?productId=1001", 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?productId=1001"

预期响应:

{ "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:1001

Hash: 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),让锁超时自动释放。然后第二个请求进来成功获取到锁。此时第一个请求恢复后执行ReleaseAsync,Lua 脚本发现 ownerId 不匹配,返回 0,不会删除第二个请求的锁。

如果没有防误删机制,第二个请求的锁就会被第一个请求的DEL直接删掉,导致并发失控。这在生产事故中是真实发生过的案例。

8. 资源占用与性能观察

分布式锁的 Redis 资源占用非常小。每个锁对应一个 Hash key,field 是 ownerId,value 是整数计数。锁的生命周期只在业务执行期间,业务结束立即删除,所以 Redis 内存中同一时间只会存在极少数lock:前缀的 key。

观察方式:

redis-cli --scan --pattern "lock:*"

redis-cli info keyspace可以查看当前 Redis 的 key 数量。正常情况下 lock key 数量应该等于并发执行业务的请求数量,峰值过后迅速归零。

.NET 服务端的资源占用主要看三点:

第一,Redis 连接数IConnectionMultiplexer是单例,整个应用复用连接,不会因为请求数量增长而无限增加连接。如果连接串中设置了abortConnect=false,Redis 暂时不可用时不会直接抛异常导致进程崩溃。

第二,锁的等待占用。如果大量请求在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 传递逻辑同一条业务链路复用同一个 ownerId
StackExchange.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 事故复盘清单

如果你在生产环境遇到分布式锁相关事故,建议按以下清单排查:

  1. 锁是否在 try-finally 或await using中释放?
  2. 锁的过期时间是否小于业务最大耗时?
  3. 释放锁时是否校验了 ownerId?
  4. 加锁操作是否是原子的(Lua 脚本)?
  5. 多个实例是否使用同一个 Redis 地址?
  6. 锁的 key 是否包含正确的业务维度?
  7. 业务是否在锁内执行了耗时操作?

10.6 安全与合规建议

任何分布式锁方案都应当限定在合法授权的业务场景内。锁的 key 如果涉及用户标识,建议只使用用户 ID 的不可逆哈希,不存放原始敏感信息。日志打印锁内容时,也要避免输出用户的隐私字段。代码评审和灰度发布是上线前的基本要求。

11. 总结与下一步

这个项目把分布式锁从“查个 SETNX 教程”提升到了“可以放到项目里的生产级实现”。核心价值有四点:Lua 脚本保证加锁和释放的原子性;Hash 结构配合 ownerId 实现可重入;看门狗续期避免业务长于锁过期时间;释放前校验 ownerId 防止误删。

建议拿到代码后最先测试的是并发扣库存:用 200 个并发请求去扣 100 个库存,看最终库存是否稳定在 0,同时观察 Redis 中lock:前缀 key 的生命周期。最容易踩的坑是显式传了过短的leaseTime,导致锁提前失效。先去掉显式leaseTime,让看门狗接管续期,再逐步调参,理解会更深。

后续可以继续扩展的方向包括:把锁服务提取成独立类库,加入 Redis Cluster 环境适配,增加基于 OpenTelemetry 的锁指标监控,以及把这个方案接入到你现有的前后端分离项目中。如果你正在做 .NET 10 后端进阶,这个项目既可以当面试手写题素材,也可以直接沉淀到团队基础组件里。建议收藏备用,动手跑一遍比看十遍理论更有价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询