.NET 10 还没正式发布的时候,很多人已经在讨论“后端还有没有搞头”。我的看法是:语言和框架更迭远没有并发场景里的“锁”问题过时。今天直接给你一套能落地的方案:WebAPI + Redis 分布式锁,实现锁超时自动释放、看门狗续期、可重入、防误删锁。这套设计在电商库存扣减、定时任务防重复执行、同账号重复提交这些场景里非常实用。
先看核心能力:基于 .NET 10 的 WebAPI 项目,使用 StackExchange.Redis 客户端,通过 Lua 脚本保证加锁和解锁的原子性;引入看门狗机制,让锁在业务未完成时自动续期;支持同一线程重入,避免递归或嵌套业务死锁;删除锁时校验客户端唯一标识,防止误删其他请求的锁。代码可以直接复制到你的项目里改,不需要额外引入 RedLock 等重量级组件。
文章会从环境搭建开始,带你创建 WebAPI 项目、配置 Redis、实现分布式锁核心类,然后逐项验证超时释放、看门狗续期、可重入、防误删锁,最后用并发请求压测一下实际效果。全程用 VS2022 或 Rider 都可以,命令行的 dotnet CLI 也能跑。
如果你正准备面试“分布式锁”相关岗位,或者手上正有一个“前端点一次按钮后端收到多次提交”的 Bug 要修,这篇文章建议先收藏。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 项目类型 | .NET 10 WebAPI 后端服务 |
| 核心依赖 | StackExchange.Redis、Redis 服务端 |
| 锁实现方式 | Redis SET NX EX + Lua 脚本释放锁 |
| 锁超时自动释放 | 通过 Redis Key 过期时间实现 |
| 看门狗续期 | 后台 Timer 定期延长 Key 过期时间 |
| 可重入锁 | 同一线程可重复获取锁,使用计数器维护 |
| 防误删锁 | 删除前校验客户端唯一标识 Value |
| 接口能力 | 提供 HTTP API,可被前端、定时任务、微服务调用 |
| 批量任务 | 支持多线程并发申请锁,锁粒度到业务唯一 Key |
| 启动方式 | dotnet run / VS 直接运行 |
| 适合场景 | 库存扣减、订单防重、定时任务防重、缓存击穿保护 |
这套实现不依赖第三方锁组件,核心逻辑就两个类:RedisLockManager和RedisLockHandle。前者管加锁、解锁、续期,后者管锁的持有状态和 Dispose 释放。
2. 适用场景与使用边界
分布式锁不是所有并发问题的银弹,你需要先分清哪些场景适合它。
适合使用分布式锁的场景:
- 库存扣减:多个用户同时抢购,Redis 中的库存字段不能超卖。
- 防重复提交:前端同一订单在弱网下可能连点多次,后端需要保证同一业务 Key 只处理一次。
- 定时任务防重:多实例部署的 Job 系统,同一时刻只有一个实例执行某个任务。
- 缓存击穿保护:热点 Key 失效瞬间,只允许一个请求回源数据库。
不适合使用分布式锁的场景:
- 单机单进程内的简单并发,用
lock、SemaphoreSlim更轻量。 - 强事务要求极高、需要数据库回滚协同的场景,建议用数据库行锁或事务。
- 需要严格分布式一致性(如跨机房容灾)时,单点 Redis 锁有主从切换丢锁风险,要考虑 RedLock 方案或 etcd 实现。
安全边界也要提示一下:所有锁的 Value 必须带上客户端唯一标识(GUID),防止 A 请求超时后锁被 B 请求获取,A 业务结束后把 B 的锁删掉。这是生产事故最常见的来源。文中会把防误删写进核心代码。
3. 环境准备与前置条件
开始之前,把你的环境过一遍。下面是明确的最低要求:
| 环境项 | 要求 |
|---|---|
| 操作系统 | Windows 10/11、Linux、macOS 均可 |
| .NET SDK | .NET 10 Preview 或更高版本(.NET 8 也可以编译,部分 API 微调) |
| IDE | VS2022 17.8+ 或 Rider,或纯 CLI |
| Redis | Redis 6.x+,Windows 可用 Memurai 或 WSL/ Docker 运行 |
| 包管理器 | NuGet |
| 磁盘空间 | 2GB 以上(SDK + NuGet 缓存) |
3.1 安装 .NET 10 SDK
如果没有安装 SDK,先到 .NET 官方下载页面拉取 SDK。安装完成后验证:
dotnet --version输出类似10.0.x就说明环境正常。
3.2 准备 Redis 服务
本机没有 Redis 时,最简单的方式是用 Docker 起一个:
docker run -d --name redis-local \ -p 6379:6379 \ redis:7-alpineWindows 用户如果没装 Docker,可以用 Memurai 或者 WSL 里装 Redis。启动后验证连接:
redis-cli -h 127.0.0.1 -p 6379 ping返回PONG表示 Redis 可用。密码、端口按你自己的环境改,下面代码里的连接字符串也要注意替换。
3.3 创建 WebAPI 项目
dotnet new webapi -n RedisLockDemo cd RedisLockDemo dotnet add package StackExchange.Redis使用 VS2022 的读者,直接新建 ASP.NET Core Web API 项目,再通过 NuGet 安装 StackExchange.Redis 即可。默认建议勾选“使用控制器”,省掉最小 API 的路由样板。
4. 安装部署与启动方式
先看一下项目的目录结构,下面所有核心代码会逐步添加。
RedisLockDemo/ ├── Controllers/ │ └── LockController.cs ├── Services/ │ ├── RedisLockManager.cs │ └── RedisLockHandle.cs ├── appsettings.json └── Program.cs4.1 配置 Redis 连接
打开appsettings.json,加入 Redis 配置:
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "Redis": { "ConnectionString": "127.0.0.1:6379,password=,abortConnect=false,connectTimeout=5000", "DefaultExpireSeconds": 30 } }abortConnect=false很关键。它让客户端在 Redis 短暂不可用时不会直接抛异常,而是进入重连等待状态。生产环境可以加syncTimeout=5000调整同步超时。
4.2 注册 Redis 服务
在 Program.cs 中注册 StackExchange.Redis 的ConnectionMultiplexer单例,再注册我们自己的锁管理器:
using RedisLockDemo.Services; using StackExchange.Redis; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); var redisConfig = builder.Configuration.GetSection("Redis"); var connectionString = redisConfig["ConnectionString"] ?? "127.0.0.1:6379"; var defaultExpireSeconds = int.Parse(redisConfig["DefaultExpireSeconds"] ?? "30"); builder.Services.AddSingleton<IConnectionMultiplexer>(sp => { return ConnectionMultiplexer.Connect(connectionString); }); builder.Services.AddSingleton<RedisLockManager>(sp => { var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>(); return new RedisLockManager(multiplexer, defaultExpireSeconds); }); var app = builder.Build(); app.UseAuthorization(); app.MapControllers(); app.Run();这个注册方式保证整个进程内只维护一个 Redis 连接池,避免每个请求都创建连接导致连接风暴。
4.3 部署到 Kestrel
开发环境直接跑:
dotnet run默认监听http://localhost:5xxx(启动日志里会输出实际端口)。想固定端口,可以在 launchSettings.json 里改,也可以运行时指定:
dotnet run --urls http://localhost:80805. 分布式锁核心代码实现
这里开始进入正题。先写可释放的锁句柄,再加锁管理器。所有 Redis 操作尽量使用 Lua 脚本,保证原子性。
5.1 锁句柄类RedisLockHandle
一个锁句柄代表当前进程在某段时间内持有一把锁。它需要记录锁的 Key、Value、续期 Timer,并在释放时做防误删删除。
using StackExchange.Redis; namespace RedisLockDemo.Services; public sealed class RedisLockHandle : IDisposable { private readonly IDatabase _db; private readonly Timer? _watchdogTimer; private bool _disposed; public string LockKey { get; } public string LockValue { get; } public TimeSpan ExpireTime { get; private set; } public int ReentrantCount { get; set; } = 1; public RedisLockHandle( IDatabase db, string lockKey, string lockValue, TimeSpan expireTime, TimeSpan watchdogInterval) { _db = db; LockKey = lockKey; LockValue = lockValue; ExpireTime = expireTime; _watchdogTimer = new Timer( ExtendExpire, null, watchdogInterval, watchdogInterval); } private void ExtendExpire(object? state) { try { // 使用 Lua 脚本保证“值匹配则续期”是原子操作 var script = @" if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end "; var result = _db.ScriptEvaluate(script, new RedisKey[] { LockKey }, new RedisValue[] { LockValue, ExpireTime.TotalMilliseconds }); var success = (int)(long)result == 1; if (!success) { // 锁已不存在或被其他线程持有,停止续期 _watchdogTimer?.Change(Timeout.Infinite, Timeout.Infinite); } } catch { // Redis 短暂异常时,保留 Timer,等待下一次续期 } } public void Dispose() { if (_disposed) return; _disposed = true; _watchdogTimer?.Change(Timeout.Infinite, Timeout.Infinite); _watchdogTimer?.Dispose(); // 使用 Lua 脚本防误删 var script = @" if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end "; try { _db.ScriptEvaluate(script, new RedisKey[] { LockKey }, new RedisValue[] { LockValue }); } catch { // 释放失败时,等待 Key 自然过期兜底 } } }这个类里最核心的是防误删逻辑。每次释放锁时,Lua 脚本先比较 Value 是否当前客户端写入的唯一标识,一致才删除。即使发生锁超时后已被其他线程持有,当前线程释放时也只返回 0,不会删除别人的锁。
5.2 锁管理器RedisLockManager
锁管理器负责对外提供加锁、释放、可重入判断三个能力。
using StackExchange.Redis; namespace RedisLockDemo.Services; public sealed class RedisLockManager { private readonly IConnectionMultiplexer _multiplexer; private readonly TimeSpan _defaultExpireTime; private readonly TimeSpan _watchdogInterval; private readonly System.Collections.Concurrent.ConcurrentDictionary<string, RedisLockHandle> _localLocks = new(); public RedisLockManager(IConnectionMultiplexer multiplexer, int defaultExpireSeconds) { _multiplexer = multiplexer; _defaultExpireTime = TimeSpan.FromSeconds(defaultExpireSeconds); _watchdogInterval = TimeSpan.FromSeconds(defaultExpireSeconds / 3.0); } /// <summary> /// 尝试获取分布式锁。 /// 成功返回锁句柄,失败返回 null。 /// </summary> public RedisLockHandle? TryAcquireLock(string lockKey, TimeSpan? expireTime = null) { var db = _multiplexer.GetDatabase(); var value = Guid.NewGuid().ToString("N"); var expiry = expireTime ?? _defaultExpireTime; // 可重入:同一线程已经持有同一 Key 的锁时,直接增加计数 var currentThreadId = Environment.CurrentManagedThreadId; var localKey = BuildLocalKey(lockKey, currentThreadId); if (_localLocks.TryGetValue(localKey, out var existing)) { existing.ReentrantCount++; return existing; } var script = @" if redis.call('set', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then return 1 else return 0 end "; var result = db.ScriptEvaluate(script, new RedisKey[] { lockKey }, new RedisValue[] { value, expiry.TotalMilliseconds }); if ((int)(long)result == 1) { var handle = new RedisLockHandle( db, lockKey, value, expiry, _watchdogInterval); _localLocks[localKey] = handle; return handle; } return null; } /// <summary> /// 释放锁。 /// </summary> public void ReleaseLock(RedisLockHandle? handle) { if (handle == null) return; var currentThreadId = Environment.CurrentManagedThreadId; var localKey = BuildLocalKey(handle.LockKey, currentThreadId); if (_localLocks.TryGetValue(localKey, out var localHandle)) { localHandle.ReentrantCount--; if (localHandle.ReentrantCount > 0) { return; // 还有外层锁,不真正释放 } _localLocks.TryRemove(localKey, out _); } handle.Dispose(); } private static string BuildLocalKey(string lockKey, int threadId) => $"{lockKey}:{threadId}"; }这段代码用ConcurrentDictionary做进程内可重入计数,同一 Key、同一线程再次加锁时不会发送新的 Redis 命令,直接增加计数。最后一次性释放时,通过 Lua 脚本做 Redis 端的删除。
可重入锁要特别注意:RedisLockHandle里的ReentrantCount字段是进程内计数,不写入 Redis。如果业务代码里出现 Task 跨线程传递,Environment.CurrentManagedThreadId会变,可重入判断会失效。生产环境建议用 AsyncLocal 保存线程上下文,或者直接限制锁的使用范围在同步方法内。
6. 控制器接口与业务测试
光有锁类还不够,我们写几个真实业务接口来验证。一个模拟库存扣减,一个模拟订单创建,一个测试可重入。
6.1 模拟库存扣减
using Microsoft.AspNetCore.Mvc; using RedisLockDemo.Services; namespace RedisLockDemo.Controllers; [ApiController] [Route("api/lock")] public class LockController : ControllerBase { private readonly RedisLockManager _lockManager; private static int _stock = 100; public LockController(RedisLockManager lockManager) { _lockManager = lockManager; } [HttpPost("deduct-stock")] public IActionResult DeductStock([FromQuery] string productId, [FromQuery] int quantity = 1) { if (string.IsNullOrWhiteSpace(productId)) { return BadRequest(new { Success = false, Message = "productId 不能为空" }); } var lockKey = $"lock:stock:{productId}"; using var handle = _lockManager.TryAcquireLock(lockKey, TimeSpan.FromSeconds(10)); if (handle == null) { return Conflict(new { Success = false, Message = "系统繁忙,请稍后重试" }); } try { if (_stock < quantity) { return BadRequest(new { Success = false, Message = "库存不足" }); } // 模拟耗时业务 Thread.Sleep(100); _stock -= quantity; return Ok(new { Success = true, Stock = _stock, Message = "扣减成功" }); } catch (Exception ex) { return StatusCode(500, new { Success = false, Message = ex.Message }); } } }这里加了Thread.Sleep(100)模拟真实业务耗时。真实项目中不要用静态 int 存库存,这里只是为了演示锁的效果。生产环境库存应该放 Redis 或数据库,扣减逻辑用原子操作再包一层锁保护。
6.2 测试可重入锁
可重入的一个典型场景是:一个接口内部先加锁,然后调用另一个也需要同一把锁的方法。比如订单创建流程里,检查库存和锁定库存分别封装在两个方法里。
[HttpPost("reentrant-test")] public IActionResult ReentrantTest() { var lockKey = "lock:order:create"; using var outerLock = _lockManager.TryAcquireLock(lockKey, TimeSpan.FromSeconds(30)); if (outerLock == null) { return Conflict(new { Success = false, Message = "获取外层锁失败" }); } var innerLock = _lockManager.TryAcquireLock(lockKey, TimeSpan.FromSeconds(30)); if (innerLock == null) { return StatusCode(500, new { Success = false, Message = "可重入锁失败,同一线程无法再次获取锁" }); } _lockManager.ReleaseLock(innerLock); _lockManager.ReleaseLock(outerLock); return Ok(new { Success = true, Message = "可重入锁验证成功,同一线程连续获取同一把锁未阻塞" }); }注意这里没有用using释放内层锁,因为内层锁和外层锁实际指向同一个RedisLockHandle实例。如果对内层锁也调用Dispose,会直接删除 Redis Key,外层锁就失去了保护。因此必须统一走ReleaseLock方法,用计数器控制释放时机。
6.3 锁超时自动释放验证
锁超时自动释放是 Redis Key 过期机制天然提供的兜底能力。业务如果崩溃、进程被杀、网络分区,锁最终一定会过期。
写一个接口,模拟锁超时后另一种请求能重新获取锁:
[HttpPost("timeout-test")] public async Task<IActionResult> TimeoutTest() { var lockKey = "lock:timeout:test"; var handle = _lockManager.TryAcquireLock(lockKey, TimeSpan.FromSeconds(5)); if (handle == null) { return Conflict(new { Success = false, Message = "锁被持有" }); } // 模拟持有锁的线程意外终止,不做任何释放操作 return Ok(new { Success = true, Message = "已获取锁,等待 5 秒后自动过期" }); }调用这个接口后,立即再调用一次,第二次会返回 409 Conflict。等待 5 秒后再次调用,就能成功获取锁。这个测试验证的就是“锁超时自动释放”的核心兜底能力。
7. 看门狗续期机制与测试
看门狗是分布式锁生产级方案里的关键点。锁没有设置超时时间,Redis Key 不会过期,一旦持有锁的进程崩溃,其他线程永远拿不到锁;锁超时时间设置过短,业务还没执行完锁就过期,并发请求立刻涌入。看门狗解决了这个矛盾:给锁设置一个初始过期时间,通过后台 Timer 持续续期,只要锁持有者还活着,锁就不会过期。
看门狗的工作流程:
- 加锁时设置一个较短的过期时间(比如 30 秒)。
- 启动一个 Timer,每隔过期时间的三分之一执行一次续期。
- 续期时,通过 Lua 脚本检查 Value 是否还是本客户端的标识。
- 如果锁还在,就重置过期时间为 30 秒。
- 如果锁已被别人持有,停止续期并结束 Timer。
当前项目里的RedisLockHandle已经实现了这个逻辑。续期 Interval 默认是过期时间的三分之一,也就是 30 秒过期时,每 10 秒续一次。
验证看门狗是否生效的方法:
给 Redis 里的锁 Key 设置一个较短的过期时间,比如 5 秒,然后观察 Key 的 TTL 是否持续重置。
[HttpPost("watchdog-test")] public async Task<IActionResult> WatchdogTest() { var lockKey = "lock:watchdog:test"; using var handle = _lockManager.TryAcquireLock(lockKey, TimeSpan.FromSeconds(5)); if (handle == null) { return Conflict(new { Success = false, Message = "锁被持有" }); } // 持续观察锁 TTL var db = _lockManager.GetDatabase(); for (int i = 0; i < 3; i++) { var ttl = db.KeyTimeToLive(lockKey); Console.WriteLine($"第 {i + 1} 次观察 TTL: {ttl?.TotalSeconds} 秒"); await Task.Delay(2000); } return Ok(new { Success = true, Message = "看门狗续期验证完成,观察服务端日志" }); }这段代码需要一个暴露GetDatabase()的方法,可以在RedisLockManager里加一个:
public IDatabase GetDatabase() => _multiplexer.GetDatabase();正常现象是:初始 TTL 5 秒,每 2 秒观察一次,TTL 一直在 3~5 秒之间波动,而不是倒数到 0。如果 TTL 一直在倒数,说明看门狗没有生效,优先检查 Timer 是否被 GC 回收,或者在异常捕获中把 Timer 停了。
需要提醒的是,看门狗续期是基于“进程内持有锁”的前提。如果应用实例发生 GC 停顿或线程池饥饿超过续期间隔,锁仍然会过期。这是任何续期方案都无法完全避免的,只能在业务设计上接受这个极小概率窗口。
8. 接口 API 与批量任务集成
学会了基本接口,下一步就是把锁服务集成到你的真实场景里。这里给出两种常见集成方式。
8.1 HTTP 接口调用
把锁的获取和释放封装成 REST 接口,适合多语言微服务调用。获取锁的接口需要两个参数:锁的 Key 和期望的过期时间。
POST /api/lock/acquire?key=order:123&expireSeconds=10curl -X POST "http://localhost:5180/api/lock/acquire?key=order:123&expireSeconds=10"返回示例:
{ "Success": true, "Token": "f0a2b1c3d4e5f6a7b8c9d0e1f2a3b4c5", "ExpireSeconds": 10 }释放锁时带上 Token,防误删校验由 Lua 脚本完成:
curl -X POST "http://localhost:5180/api/lock/release" \ -H "Content-Type: application/json" \ -d "{\"key\":\"order:123\",\"token\":\"f0a2b1c3d4e5f6a7b8c9d0e1f2a3b4c5\"}"如果是跨语言调用,需要把 Token 返回给调用方,后续释放锁时原样传回。自己的 .NET 服务内调用,直接使用RedisLockManager即可,不需要走 HTTP 层。
8.2 批量任务防重复执行
批量任务场景里,分布式锁最常见的用法是保证同一批数据不会同时被两个 Worker 消费。清点任务列表时,每个任务生成唯一的锁 Key,能拿到锁的 Worker 才执行任务,拿不到的 Worker 直接跳过。
public async Task ExecuteBatchAsync(IEnumerable<string> taskIds) { foreach (var taskId in taskIds) { var lockKey = $"lock:task:{taskId}"; using var handle = _lockManager.TryAcquireLock(lockKey, TimeSpan.FromMinutes(5)); if (handle == null) { continue; // 其他 Worker 正在处理 } try { await ProcessTaskAsync(taskId); } catch (Exception ex) { // 记录日志,任务失败,标记重试 } } }批量处理场景特别要注意锁粒度和 Redis 连接数量。如果任务数非常多,不要每遍历一个 Task 就新建 Redis 连接,直接用前面注册的单例ConnectionMultiplexer。锁 Key 的过期时间不要设置太短,尤其是处理超大数据量的任务时,尽量依赖看门狗自动续期。
9. 资源占用与性能观察
分布式锁不是纯粹免费的抽象,它会给 Redis 和 .NET 进程带来额外开销。观察性能时主要看三个指标。
9.1 Redis 内存与 Key 数量
每个锁对应一个 Redis Key,锁的 Value 大约 36 字节(GUID),Key 名称根据业务决定。高并发场景下大量锁 Key 同时存在,Redis 内存会上升。可以定期使用redis-cli --bigkeys扫描最长 Key,排查是否有不及时释放的锁。
redis-cli --bigkeys9.2 Redis 命令 QPS
加锁一次需要一次EVAL,释放锁一次需要一次EVAL,看门狗续期每 10 秒一次。100 QPS 的锁请求,Redis 的 QPS 大约 200 到 300。这个数字对单机 Redis 完全没有压力,但如果业务里每个请求都加锁解锁,建议统计锁的调用频率,避免锁成为 Redis 的瓶颈。
9.3 观察 Timers 数量与线程占用
看门狗 Timer 会废弃掉锁句柄后停止。但如果业务中大量使用using var handle,每次锁持有 30 秒且每 10 秒续期一次,100 个并发请求会启动 100 个 Timer。虽然 .NET 的 Timer 成本很低,但设计上最好复用同一个 Timer 做批量续期。更简单的优化方法是把看门狗间隔调大,比如 30 秒过期时 15 秒续期一次,减少续期频率。
9.4 降低锁开销的实践
- 锁粒度越小越好,优先使用业务主键作为锁 Key,而不是全局限流 Key。
- 锁持有时间越短越好,只在需要保护的关键代码段加锁,不要整个请求都加锁。
- Redis 操作合并,加锁和业务逻辑里的 Redis 操作走同一个
ConnectionMultiplexer,避免新连接开销。 - 启用
abortConnect=false,Redis 短暂故障时等待而不是立即抛出异常。 - 大批量任务建议对锁 Key 做分片,比如
lock:task:{taskId % 100}控制并发度。
10. 常见问题与排查方法
实际部署和使用中容易踩的坑不少。直接整理成表格,方便你对照排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 所有请求都拿不到锁 | Redis 连接断开或 Key 未过期 | redis-cli查看 TTL,info clients查看连接数 | 检查 Redis 服务状态,重启服务 |
| 锁刚获取就丢失 | 过期时间设置过短,业务没跑完 | 查看业务耗时和 Redis TTL 变化 | 开启看门狗续期,或调大默认过期时间 |
| 误删其他线程的锁 | 释放锁时没有校验 Value | 检查解锁 Lua 脚本是否判断 Value 一致 | 使用统一RedisLockHandle.Dispose释放锁 |
| 可重入锁失效 | 异步方法跨线程传递 | 打印线程 ID,检查是否跨 Task | 用 AsyncLocal 保存锁上下文 |
| 看门狗没有续期 | Timer 被 GC 回收或异常抛出后未处理 | 在续期方法里加日志,观察日志输出 | 保证RedisLockHandle不被 GC,捕获异常后继续续期 |
| 锁 Key 大量堆积在 Redis | 业务线程崩溃,using未执行 | redis-cli --scan --pattern "lock:*" | 设置锁过期时间,加看门狗续期 |
| Redis 主从切换后锁丢失 | 主节点宕机,从节点丢失未同步的数据 | info replication查看同步状态 | 生产环境使用 RedLock 或多实例一致性方案 |
| 并发扣减超卖 | 锁获取成功,但库存扣减不是原子操作 | 查看扣减逻辑是否在锁保护范围内 | 锁内使用 Redis 原子递减StringDecrement |
| 启动后连接超时 | Redis 配置了密码但连接串未带 | 检查 ConnectionString 的 password 字段 | 修改连接字符串,重启服务 |
| HTTP 接口返回 409 | 锁已被其他请求持有 | 查看 Redis TTL | 等锁过期重试,或调整业务并发策略 |
11. 最佳实践与使用建议
到这里,一个完整可用的 .NET 10 + WebAPI + Redis 分布式锁方案已经落地了。最后给几条工程化建议,这些都是在真实项目里积累出来的经验。
第一次接入先做最小验证。不要直接把锁套在核心业务上,先用一个简单的测试接口跑通加锁、解锁、超时释放三个流程,确认 Redis 连接和各参数符合预期,再扩展到真实业务。
锁内代码要短、要快。分布式锁保护的代码段应该尽可能只包含必须串行的部分。如果锁内涉及外部 HTTP 调用或数据库事务,要明确设置超时时间,避免业务线程卡死导致锁长期被持有。
释放锁必须走 finally 或 using。业务代码里无论如何都不能跳过锁释放。using var handle是最保险的写法,编译器会保证异常时也调用 Dispose。签名里面的返回值、异常捕获是否完整,要做 Code Review。
不要用固定字符串作为锁 Value。每个线程加锁必须使用独立的 GUID 或组合标识,这是防误删的基础。
锁 Key 要规范。统一用lock:业务域:业务ID的格式,例如lock:stock:sku_1001。这样 Redis 里对锁 Key 的扫描、排查、按前缀清理都很方便。
生产环境做监控。记录每次加锁失败的情况,统计锁等待时间和持有时间。锁冲突率突然升高往往说明业务并发出现问题或锁粒度设计不合理。
合规提醒。如果这个接口被前端直接调用,要考虑用户身份认证和接口幂等,防止恶意循环请求打爆 Redis。锁内处理的数据涉及用户隐私或订单信息,日志不要全量打印,尽量脱敏。
12. 总结与下一步
这套方案的核心价值有三个:第一,基于 Redis 原生 Lua 脚本实现加锁和解锁的原子性,不引入额外第三方组件,代码可控;第二,看门狗续期机制让锁不会因为业务超时而提前失效,也不会因为进程崩溃而永久占锁;第三,可重入和防误删补上了容易被忽略的生产级细节,让方案能真正用在订单、库存、定时任务这些关键链路上。
如果你在 .NET 项目里还没接触过分布式锁,建议先跑通库存扣减和可重入测试两个接口,感受一下锁的获取和释放时机。已经用过 Redisson 这类库的读者,可以对照本文的看门狗和 Lua 脚本实现,看看自己项目里的锁释放是否也有 Value 校验,是否有完善的续期机制。
下一步可以继续扩展的方向:用AsyncLocal改造可重入锁以支持异步链路;集成 RedLock 方案解决 Redis 主从切换场景下的极端一致性;把分布式锁封装成自定义 Attribute,用 AOP 方式声明式加锁;或者结合 .NET 10 的中间件体系,把锁的获取和释放做成请求级自动管理。建议先把本文代码落到你的测试项目里跑一遍,收藏备用,遇到并发问题时回来翻一翻这张排查表。