在 .NET 10 的 WebAPI 项目里,Redis 分布式锁几乎是后端进阶绕不开的一道坎。很多人第一反应是:这不就是SETNX加个过期时间吗?真做一遍会发现,这个方案要踩的坑,比想象中多得多。我自己第一次把锁从单机部署切到多实例时,就因为漏了“锁超时续期”和“解锁校验”两个细节,导致线上出现了重复扣款。后来把整套方案重做,才理解它真正解决的问题,不是“能不能加锁”,而是“如何让锁的生命周期和业务执行周期保持一致”。这篇文章就把我整理后的完整实现思路写出来。
1. 先搞清楚分布式锁要解决什么问题
1.1 多实例部署后,单机锁为什么会失效
当一个服务只有一个进程,lock、Monitor或SemaphoreSlim都可以保证临界区只有一个线程进入。但当服务为了可用性部署多个实例,前面挂负载均衡,同一个用户请求可能落在不同进程上。每个进程都有自己的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,所以不删除,看起来正确。但问题在于,Get和Delete是两个独立操作,中间可能插入别的 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,它持有IDatabase、key、owner和leaseTime。构造函数里启动一个定时器,每隔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 一个能复用的排查顺序
分布式锁出问题,表象很多,但根因通常集中在几层。我习惯按下面的顺序排查:
- 看现象:是死锁、超卖、重复处理,还是一直拿不到锁?不同现象对应的层级不一样。
- 看 Redis key:
EXISTS、TTL、HGETALL,确认 key 是否还存在、过期时间是否有、重入次数是多少。 - 看 owner:检查加锁、续期、解锁用的 owner 是否都是同一个值。很多可重入失效都是因为 owner 每次都不一样。
- 看续期:给锁服务加上日志,确认看门狗是否在到期前执行。如果日志里续期失败,要看 Redis 连接、Lua 脚本、网络超时。
- 看调用链:同一个请求里,嵌套加锁时是不是传了同一个 key 和 owner。
- 看 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,按业务风险考虑哨兵或强一致方案 |
| 压测 | 并发接口验证互斥性,不能只看功能跑通 |
最后回到最核心的那句话:分布式锁不是setnx加del两个命令,而是一套生命周期管理。加锁、续期、重入、防误删、异常兜底,都必须围绕“让锁的持有者始终是业务的实际执行者”这个目标来设计。把这一点想清楚,后面再复杂的方案,都只是在这个框架上缝缝补补。