news 2026/9/8 13:29:05

.NET 10 WebAPI 中 Redis 分布式锁的生产级实现:从互斥到生命周期管理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
.NET 10 WebAPI 中 Redis 分布式锁的生产级实现:从互斥到生命周期管理

在 .NET 10 的 WebAPI 项目里,Redis 分布式锁几乎是后端进阶绕不开的一道坎。很多人第一反应是:这不就是SETNX加个过期时间吗?真做一遍会发现,这个方案要踩的坑,比想象中多得多。我自己第一次把锁从单机部署切到多实例时,就因为漏了“锁超时续期”和“解锁校验”两个细节,导致线上出现了重复扣款。后来把整套方案重做,才理解它真正解决的问题,不是“能不能加锁”,而是“如何让锁的生命周期和业务执行周期保持一致”。这篇文章就把我整理后的完整实现思路写出来。

1. 先搞清楚分布式锁要解决什么问题

1.1 多实例部署后,单机锁为什么会失效

当一个服务只有一个进程,lockMonitorSemaphoreSlim都可以保证临界区只有一个线程进入。但当服务为了可用性部署多个实例,前面挂负载均衡,同一个用户请求可能落在不同进程上。每个进程都有自己的lock,彼此看不见,于是两个进程同时进入临界区。库存、余额、订单状态这类共享资源,就会出问题。

数据库里的唯一约束和乐观锁能兜住一部分,但有些场景需要的是短时间互斥:同一订单不能重复提交、同一任务不能同时被两个实例执行、秒杀扣减时只能有一个进程减库存。这时就需要一把所有进程都能看到的锁。Redis 因为高性能、支持原子命令,成了常见选择。

1.2 评判分布式锁的四个核心问题

一个生产级分布式锁,通常要回答下面四个问题:

  • 互斥性:同一时刻只有一个客户端能持有锁。
  • 安全性:解锁时只能释放自己持有的锁,不能误删别人的锁。
  • 可用性:持有锁的客户端崩溃后,锁能自动释放,不会死锁。
  • 可重入性:同一个调用链路里,如果嵌套加同一把锁,不会把自己锁死。

这四个问题对应到 Redis 实现上,分别是:SETNX是否成功、解锁时是否校验owner、key 是否设置过期时间、存储结构是否支持重入计数。

只看框架可能还不直观。接下来用一个最小 WebAPI 项目,先把流程跑通,再逐步补齐生产级能力。

2. 搭建最小 WebAPI + Redis 环境,先把流程跑通

2.1 环境准备:.NET 10 WebAPI 和 Redis 服务

先创建一个 .NET 10 WebAPI 项目:

dotnet new webapi -n DistLockDemo cd DistLockDemo dotnet add package StackExchange.Redis

然后准备一个 Redis 服务。如果本地有 Docker,执行:

docker run -d -p 6379:6379 --name redis-lock redis:7-alpine

如果只想在 Windows 本地跑,也可以下载 Redis 的 Windows 移植版,或者用 Redis Desktop Manager 之类的客户端去查看 key。Redis 7 以上对 Lua 脚本和过期的支持比较稳定,建议优先选新一点的小版本。

appsettings.json里放连接字符串,然后在Program.cs里注册ConnectionMultiplexer。不要每次请求都重新创建连接,应该让ConnectionMultiplexer以单例方式复用。

2.2 第一版加锁:StringSet + 过期时间

最简单的加锁是使用StringSetAsync,只有在 key 不存在时才写入:

var db = redis.GetDatabase(); var ok = await db.StringSetAsync(key, owner, expiry, When.NotExists); if (ok) { // 执行业务 }

参数解释:

  • key:锁资源,比如order:1001
  • owner:当前请求的唯一标识,通常用Guid.NewGuid().ToString("N")
  • expiry:锁的过期时间。
  • When.NotExists:对应 Redis 的SET NX,保证只有一个客户端能设置成功。

注意:这里的锁超时时间必须设置。如果不设置,一旦拿到锁的进程在释放前崩溃,Redis 里的 key 永远不会消失,后面所有请求都会卡死。很多教程里的SETNX不加EXPIRE,在生产环境是绝对不能直接用的。

2.3 第一版解锁:为什么必须用 Lua 脚本

第一版最容易犯的错是:先Get判断 owner,再Delete。这个顺序看起来没问题,但并发下会失效。

比如线程 A 拿到锁,执行时间很长,锁过期了;线程 B 拿到同一把锁,写入自己的 owner;此时线程 A 终于执行完,去释放锁。如果 A 先Get到 value,发现不是自己的 owner,所以不删除,看起来正确。但问题在于,GetDelete是两个独立操作,中间可能插入别的 Redis 命令。一旦中间状态发生变化,就可能误删别人的锁。最稳妥的方式是把“判断 owner + 删除 key”放进一个原子操作,由 Redis Lua 脚本保证。

解锁脚本可以这样写:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

用 C# 执行:

var script = @" if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; var result = (long)await db.ScriptEvaluateAsync(script, new RedisKey[] { key }, new RedisValue[] { owner });

这段脚本就是防误删锁的核心。只要在删除前确认value还是自己的owner,就不会把别人的锁删掉。

3. 生产级分布式锁需要补齐四块拼图

第一版能在测试环境跑通,但离生产还有距离。下面四块拼图是必须补的。

3.1 锁超时自动释放:给业务一个“最后期限”

过期时间是分布式锁的兜底机制,不是业务建议时间。之前提到不设过期时间会死锁,但设了以后,新的矛盾就来了:如果业务执行时间超过过期时间,锁提前释放,另一个请求进来,临界区又一次并发执行。

所以锁超时时间不能拍脑袋。常见做法是取业务预估最大耗时的 2 到 3 倍,或者监控 99 分位耗时后再定。这个时间不是越大越好,因为时间越长,持有锁的进程崩溃后,其他进程要等更久才能拿到锁。

更合理的说法是:锁超时时间只是“最后期限”,正常情况下的锁应该在业务结束后立刻释放,不应该靠等过期。后面的看门狗,就是为了让这个最后期限不再成为业务瓶颈。

3.2 看门狗续期:让锁跟随业务生命周期

什么是看门狗?你可以把它理解成一个后台任务。持有锁之后,如果业务还没结束,就定时给锁续期;如果业务结束了,就释放锁并停止续期。这样锁的过期时间被动态拉长,不会出现“锁先过期,业务还在跑”的问题。

这个思路在成熟的分布式锁库里非常常见。实现上,我给每个锁生成一个RedisLockHandle,它持有IDatabasekeyownerleaseTime。构造函数里启动一个定时器,每隔leaseTime / 3的时间执行一次续期。为什么是三分之一?留足网络和 GC 的缓冲,避免续期还没执行,锁先过期。

续期的 Lua 脚本和防误删一样,必须校验 owner:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end

如果 key 不是自己的 owner,说明锁已经因为某种原因被释放,续期脚本返回 0,此时看门狗应该停止,而不是继续无限续期。

需要特别强调:看门狗不是无限续期。对一个异常到永远不会结束的任务,不能让它一直持有锁。正确做法是同时设置一个业务最大执行时间,超过这个时间就让任务失败并强制释放。你可以把它理解成“允许正常波动,但不允许无限占用”。实际项目里,我通常把 leaseTime 设为 30 秒,看门狗每 10 秒续一次,同时业务侧设置一个 3 分钟的超时兜底。

3.3 可重入:从 String 换成 Hash,自己别锁死自己

如果你在 A 方法里加锁,然后调用 B 方法,B 方法又对同一把锁加锁,这时会发生什么?如果锁不支持重入,B 方法发现 key 已经存在,会一直等待,而等待的前提是 A 方法释放锁,A 方法又在等 B 结束,于是自己把自己锁死。

解决思路是给同一个 owner 维护一个计数器。每次加锁 +1,每次解锁 -1,只有计数归零时才真正删除 key。Redis 的 Hash 结构天然适合做这件事:key 是锁资源,field 是 owner,value 是重入次数。

加锁脚本:

-- KEYS[1] = key -- ARGV[1] = owner -- ARGV[2] = expire milliseconds 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

解锁脚本:

if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return 0 end local count = redis.call('hincrby', KEYS[1], ARGV[1], -1) if count <= 0 then redis.call('del', KEYS[1]) return 1 end return count

可以看到,解锁不是简单DEL,而是先减计数器,归零才删除。这既实现了可重入,也保留了防误删能力,因为只有持有者的owner才能操作这个 field。

这里有个 .NET 异步环境下的细节:在异步代码里,Thread.CurrentThread.ManagedThreadId是会变化的,不能直接拿它当 owner。更稳妥的做法是用AsyncLocal<string>保存一个请求级别的标识,让同一个异步调用链共享同一个 owner。否则同一个请求在不同线程上继续执行时,会误认为是两个不同的锁持有者。

3.4 防误删:把 owner 校验放进所有写操作里

防误删的核心不是某一段脚本,而是一个原则:所有对锁的写操作,都必须先校验 owner。加锁时要写入 owner,续期时要校验 owner,解锁时要校验 owner。任何一步跳过 owner 校验,都可能在并发下误操作别人的锁。

这里顺便提一个容易忽略的问题:owner 本身要足够随机。如果多个请求的 owner 都一样,比如都用"lock"这个字符串,那所有请求都“持有同一把锁”,可重入计数会乱掉。生产环境至少用Guid.NewGuid().ToString("N"),最好再拼上 TraceId 或用户Id,方便排查。

4. 在 WebAPI 里落地:一个下单防重复提交的完整例子

4.1 封装 DistributedLockService

上面的逻辑不应该散落在 Controller 里。可以封装一个DistributedLockService,核心方法返回一个RedisLockHandle,让调用方通过await using释放锁。

先定义返回类型:

public sealed class RedisLockHandle : IAsyncDisposable { private readonly IDatabase _db; private readonly string _key; private readonly string _owner; private readonly TimeSpan _leaseTime; private Timer? _timer; private bool _disposed; public RedisLockHandle(IDatabase db, string key, string owner, TimeSpan leaseTime) { _db = db; _key = key; _owner = owner; _leaseTime = leaseTime; } public void StartWatchdog() { var period = TimeSpan.FromMilliseconds(_leaseTime.TotalMilliseconds / 3); _timer = new Timer(DoRenew, null, period, period); } private void DoRenew(object? state) { // 这里用同步执行,简化示意。 // 生产环境建议用 PeriodicTimer + 后台任务,避免 Timer 回调和释放逻辑并发。 var script = @" if redis.call('exists', KEYS[1]) == 1 and redis.call('hexists', KEYS[1], ARGV[1]) == 1 then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end"; try { _db.ScriptEvaluate(script, new RedisKey[] { _key }, new RedisValue[] { _owner, _leaseTime.TotalMilliseconds }); } catch { // 日志记录续期失败 } } public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; _timer?.Dispose(); var script = @" if redis.call('exists', KEYS[1]) == 1 and redis.call('hexists', KEYS[1], ARGV[1]) == 1 then local count = redis.call('hincrby', KEYS[1], ARGV[1], -1) if count <= 0 then redis.call('del', KEYS[1]) end return count else return 0 end"; await _db.ScriptEvaluateAsync(script, new RedisKey[] { _key }, new RedisValue[] { _owner }); } }

再提供一个获取锁的服务方法:

public class DistributedLockService { private readonly IConnectionMultiplexer _redis; public DistributedLockService(IConnectionMultiplexer redis) { _redis = redis; } public async Task<RedisLockHandle?> AcquireAsync( string key, string owner, TimeSpan leaseTime, TimeSpan waitTimeout) { var db = _redis.GetDatabase(); var start = DateTime.UtcNow; while (DateTime.UtcNow - start < waitTimeout) { var ok = await TryAcquireCoreAsync(db, key, owner, leaseTime); if (ok) { var handle = new RedisLockHandle(db, key, owner, leaseTime); handle.StartWatchdog(); return handle; } await Task.Delay(50); } return null; } private static async Task<bool> TryAcquireCoreAsync(IDatabase db, string key, string owner, TimeSpan leaseTime) { var script = @" 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"; var result = (long)await db.ScriptEvaluateAsync(script, new RedisKey[] { key }, new RedisValue[] { owner, leaseTime.TotalMilliseconds }); return result == 1; } }

这里的等待逻辑用while + Task.Delay(50),实际项目可以换成更精细的重试策略。waitTimeout是调用方愿意等待锁的最长时间,避免无限等待导致接口挂住。

4.2 在 Controller 里使用锁

以下单接口为例:

[ApiController] [Route("api/order")] public class OrderController : ControllerBase { private readonly DistributedLockService _lockService; public OrderController(DistributedLockService lockService) { _lockService = lockService; } [HttpPost("submit")] public async Task<IActionResult> Submit(SubmitOrderRequest request) { var key = $"order:{request.OrderId}"; var owner = $"{Guid.NewGuid():N}:{request.UserId}"; await using var handle = await _lockService.AcquireAsync( key, owner, leaseTime: TimeSpan.FromSeconds(30), waitTimeout: TimeSpan.FromSeconds(5)); if (handle == null) { return Conflict(new { message = "当前订单正在处理中,请勿重复提交" }); } // 真正的业务逻辑:校验库存、创建订单、扣减库存等 await _orderService.SubmitAsync(request); return Ok(new { message = "提交成功" }); } }

注意await using var handle = ...的写法。这个方法块结束后,会自动调用DisposeAsync释放锁。如果忘记释放,就会出现锁一直被占用的现象,即使业务已经结束。

同时要注意:owner 应该来自当前请求链路,不是每次取锁都生成一个全新的。否则在同一个请求里两次调用AcquireAsync时,可重入就失效了。实际项目里可以用AsyncLocal或传递一个TraceId

4.3 并发验证:怎么确认锁真的解决了问题

写完代码后,先别急着上线。可以用一个最简单的验证方式:在进入业务临界区时打印唯一日志,再并发请求同一个订单。

如果使用 curl,可以模拟并发:

for i in {1..20}; do curl -s -X POST http://localhost:5000/api/order/submit \ -H "Content-Type: application/json" \ -d '{"orderId":"1001","userId":"u001"}' & done wait

然后看日志。如果锁生效,进入临界区的日志应该是串行的:第一个请求开始、结束,第二个请求才开始。如果看到多个请求同时进入临界区,就要检查 Redis 连接、owner是否正确、锁脚本是否执行成功。

还可以在 Redis 客户端里观察 key 的状态:

HGETALL order:1001 TTL order:1001

如果锁已经释放,HGETALL应该返回空;如果还在持有,可以看到 owner 和重入次数。

5. 常见坑和排查链路

5.1 一个能复用的排查顺序

分布式锁出问题,表象很多,但根因通常集中在几层。我习惯按下面的顺序排查:

  1. 看现象:是死锁、超卖、重复处理,还是一直拿不到锁?不同现象对应的层级不一样。
  2. 看 Redis key:EXISTSTTLHGETALL,确认 key 是否还存在、过期时间是否有、重入次数是多少。
  3. 看 owner:检查加锁、续期、解锁用的 owner 是否都是同一个值。很多可重入失效都是因为 owner 每次都不一样。
  4. 看续期:给锁服务加上日志,确认看门狗是否在到期前执行。如果日志里续期失败,要看 Redis 连接、Lua 脚本、网络超时。
  5. 看调用链:同一个请求里,嵌套加锁时是不是传了同一个 key 和 owner。
  6. 看 Redis 自身:INFO里的内存、连接数、持久化配置、主从状态。Redis 卡顿或主从切换,都可能让锁在极端情况下失效。

这套顺序不是万能,但能覆盖 90% 的分布式锁问题。核心原则是:先确认锁本身的状态,再排查业务代码,不要一开始就去怀疑 Redis。

5.2 高频踩坑点

下面这些坑,是我自己或身边同事真正踩过的:

现象原因解决
key 没设过期时间持有锁的进程崩溃后,后续全部卡死SETNX后再EXPIRE非原子,或者忘了设置SET key value NX PX timeout或 Lua 脚本一并设置
解锁没校验 owner锁被其他线程释放,临界区并发进入直接DEL,不管 value 是谁用 Lua 脚本判断 owner 再删除
业务耗时超过锁过期时间锁提前释放,另一个请求进入过期时间小于业务实际耗时设置合理初始过期时间 + 看门狗续期
可重入失效嵌套加锁直接死锁owner 每次不同,Hash 计数加不进去在异步链路里传递同一个 owner
看门狗无限续期一个异常任务永远占着锁续期没有最大执行时间限制给业务设置最大执行时间,超过后强制终止并释放
等待锁没有超时接口一直转圈,最后网关超时获取锁用死循环,没有 waitTimeout设置等待锁的最长时间,超过就返回失败

5.3 主从切换、持久化和 RedLock 的边界

再往深一层,Redis 分布式锁还有一个绕不开的讨论:主从切换时锁会不会丢。

如果用单个 Redis 节点,master 写入锁后,在没有同步到 slave 之前 master 宕机,slave 提升为 master,此时新 master 里没有这把锁,另一个客户端就能拿到同一把锁。在极端高一致场景下,这不是可接受的。

通常的应对是 RedLock 算法,向多个 Redis 节点依次申请锁,超过半数成功才算获得锁。但这个算法在分布式系统领域也有争论,比如 GC 暂停和时间问题可能导致多个客户端同时持有锁。所以它并不能做到绝对安全,只是提高了可用性边界。

我的建议是:先想清楚业务对一致性的真实要求。大多数业务场景,比如防止重复提交订单、秒杀扣减,Redis 单节点加合理兜底已经够用;如果业务真的要求非常高,与其追求 RedLock,不如换 ZooKeeper、etcd 这类带强一致协议的服务。锁不是越复杂越好,而是和业务风险匹配。

另外,Redis 持久化也会影响锁恢复:如果 Redis 没有开持久化,重启后锁 key 全部丢失;如果开了 AOF 且 appendfsync 是 everysec,极端情况下也可能丢 1 秒内的锁。生产环境至少开启 AOF,并对 Redis 做监控。

6. 我的建议:分布式锁的适用边界与生产化清单

6.1 适合什么,不适合什么

不难发现,Redis 分布式锁并不是银弹。它适合下面这些场景:

  • 短时间互斥操作:比如订单防重复提交、用户幂等操作、任务调度防重复执行。
  • 对一致性要求不是最高,但需要低成本、高性能互斥的业务。
  • 团队已经有 Redis 基础设施,不想再引入新组件。

不适合的场景也要写清楚:

  • 需要强一致的金融级操作:操作金额、转账、账户扣款,建议走数据库事务或带强一致协议的分布式协调组件。
  • 业务执行时间极长:比如几小时的大任务,用 Redis 锁做互斥会很难受,即使有看门狗也要处理进程暂停、发布重启等问题。
  • 跨机房强一致场景:Redis 的同步复制、主从切换、网络分区都会带来锁丢失风险。
  • 高频短锁且 Redis 压力已经很重的场景:可以考虑数据库乐观锁、消息队列串行化,而不是继续加 Redis 请求。

6.2 生产化清单

最后给一张生产化清单,做分布式锁方案的同学可以直接对照检查:

检查项建议
锁 key 命名统一前缀,比如dist:lock:order:1001,方便排查
owner 生成Guid + 请求标识 + 用户标识,保证全局唯一
获取锁等待设置waitTimeout,不能无限等待
锁过期时间设成业务预估耗时的 2-3 倍,配合看门狗
看门狗每 1/3 锁过期时间续期,设置最大执行时间
解锁使用 Lua 脚本,先校验 owner,再减计数,归零删除
可重入使用 Hash 结构,owner 为 field,值为计数
日志记录加锁结果、owner、耗时、续期失败次数
Redis 可用性有监控告警,开启 AOF,按业务风险考虑哨兵或强一致方案
压测并发接口验证互斥性,不能只看功能跑通

最后回到最核心的那句话:分布式锁不是setnxdel两个命令,而是一套生命周期管理。加锁、续期、重入、防误删、异常兜底,都必须围绕“让锁的持有者始终是业务的实际执行者”这个目标来设计。把这一点想清楚,后面再复杂的方案,都只是在这个框架上缝缝补补。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 13:27:23

DeepSeek Harness 配置实战:通用设置与 Agent 预设调优指南

1. 内容整体设计与思路拆解 1.1 为什么说 Harness 的核心是“设置”而不是“模型” 很多人刚接触 DeepSeek Harness 时&#xff0c;第一个反应是去折腾模型下载、API Key 配置这些重活&#xff0c;我最初也是这么干的。但实际用下来才发现&#xff0c;真正决定这个工具好不好用…

作者头像 李华
网站建设 2026/9/8 13:27:17

嵌入式调试笔记:旋钮开关省IO采集与Modbus浮点传输实战

做现场设备调试的兄弟应该都有这种体会&#xff1a;MCU的IO口永远不够用&#xff0c;尤其是带旋钮开关的面板设备&#xff0c;几个档位塞进去&#xff0c;一组IO就没了&#xff1b;好不容易把硬件改完&#xff0c;又要和上位机走Modbus通信&#xff0c;float数据发过去全是乱码…

作者头像 李华
网站建设 2026/9/8 13:26:11

Qt字符串处理避坑:QString::chop()越界风险与安全截断方案

如果你在Qt里处理字符串&#xff0c;大概率用过 QString::chop() 。这个函数看着人畜无害&#xff0c;作用就是从尾部移除N个字符&#xff0c;很多人在解析报文、清理路径、去换行符时都会顺手用一下。但我最近在排查一个协议解析的bug时&#xff0c;发现 chop() 的行为远比…

作者头像 李华
网站建设 2026/9/8 13:25:06

从Context到Long-term Memory:企业级Agent记忆架构与MCP落地

企业级对话系统一旦跨过 Demo 阶段&#xff0c;最先暴露的问题通常是记忆。不是模型不会回答&#xff0c;而是它记不住&#xff1a;用户三分钟前提过的需求&#xff0c;换个会话就完全消失&#xff1b;管理员整理好的业务偏好&#xff0c;每次都要重新描述。AI 大模型虽然有越来…

作者头像 李华
网站建设 2026/9/8 13:24:49

.NET 10高并发实战:Redis分布式锁与秒杀防超卖方案

后端开发做到一定阶段&#xff0c;几乎都会遇到这类需求&#xff1a;秒杀、抢券、预约报名。业务逻辑本身并不复杂——查库存、判断状态、扣减库存、写订单&#xff0c;但一旦把服务从单机部署扩展成多台实例&#xff0c;原本好用的内存锁就全部失效了。这也是很多团队从单体架…

作者头像 李华
网站建设 2026/9/8 13:24:38

ComfyUI V9.5中文整合包:全界面汉化与中文提示词优化指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华