ARTICLE DETAIL

资讯详情

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

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

.NET 10 WebAPI 中 Redis 分布式锁的生产级实现:从互斥到生命周期管理 在 .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 脚本和防误删一样必须校验 ownerif 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 是 ownervalue 是重入次数。加锁脚本-- 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。更稳妥的做法是用AsyncLocalstring保存一个请求级别的标识让同一个异步调用链共享同一个 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 TaskRedisLockHandle? 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 Taskbool 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 TaskIActionResult 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 keyEXISTS、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两个命令而是一套生命周期管理。加锁、续期、重入、防误删、异常兜底都必须围绕“让锁的持有者始终是业务的实际执行者”这个目标来设计。把这一点想清楚后面再复杂的方案都只是在这个框架上缝缝补补。
返回列表