尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Redis分布式锁从原理到实战:手写实现与Redisson最佳实践

Redis分布式锁从原理到实战:手写实现与Redisson最佳实践 上上周刚给团队排查完一个库存超卖的事故最后定位在分布式锁上。其实这类问题很常见两个服务实例同时扣库存读-改-写不是原子操作锁又没锁住关键代码数据就乱了。这篇文章我想把 Redis 分布式锁这条路完整走一遍先讲锁到底在锁什么再带你从最朴素的 SETNX 一点点手写到能用的版本接着看 Redisson 是怎么把这些细节工业化的最后结合线上故障聊排查思路。适合准备分布式锁面试题的新人也适合正在自研锁、想替换成 Redisson 的老手。读完你拿到的不是某个封闭结论而是一套判断锁方案好坏的思维框架。1. 锁的本质和选型逻辑先想清楚再写代码1.1 并发冲突的本质临界区与“读改写”的不可分割分布式锁锁的从来不是“代码”而是临界区资源。库存扣减就是最典型的例子多个服务实例同时执行“查询库存 - 判断是否充足 - 扣减并写回”这条链路里任何一个步骤稍微错位就会出现两个线程都读到 100、都扣 1、都写回 99 的经典错误。问题出在“读改写”不是原子操作。单机时代我们用synchronized或ReentrantLock给临界区加锁但应用部署成多实例之后JVM 里的锁是各管各的两个实例上的两个线程可以同时进入同一段代码。这时候分布式锁才上场让多个实例之间对同一个共享资源的访问串行化。我用生活化类比给你说透Redis 分布式锁就像公共厕所门上那种“红色占用/绿色空位”的标志牌。一个人进去之前必须先看到绿色然后把牌子翻成红色出来再翻回来。如果翻牌子这个动作不是原子的两个人就会同时挤进去。Redis 单线程执行命令的特性恰好能保证“看一眼牌子 翻牌子 定一个自动恢复时间”这些动作合并成一次操作。1.2 一把合格的分布式锁至少得满足四个硬性要求我见过很多人手写锁写个SETNX就觉得完事了。但实际上一把合格的分布式锁至少要同时满足四个条件互斥性任意时刻只能有一个客户端持有锁。这是锁的立身之本。防死锁如果持有锁的客户端在执行业务时宕机、网络断掉锁必须能自动释放不能永久占用。解决办法就是给锁设置过期时间。防误删客户端只能释放自己持有的锁不能把别人持有的锁删掉。否则上一个线程的锁刚过期下一个线程刚拿到锁上一个线程却一个DEL把人家的锁删了临界区立刻失守。高可用与高性能加锁、解锁的开销要尽量小不能让一个分布式锁把接口性能拖垮。更高级的要求还包括可重入、自动续期、公平性等等。这四个条件背后每一个都对应一个具体的线上故障场景。面试官问“Redis 分布式锁怎么实现”很多人倒背如流但一问“为什么释放锁要用 Lua 脚本”就卡壳问题就出在只背了命令没有理解第四个“防误删”和“释放原子性”之间的联系。1.3 为什么大家都选 Redis而不是数据库锁或 ZooKeeper聊完四个硬性要求自然要回答为什么不是数据库锁不是 ZooKeeper偏偏是 RedisMySQL 乐观锁/悲观锁悲观锁会用行锁把更新串行化但数据库连接资源本身就贵高并发场景下大量请求会堆积在锁等待上乐观锁要么依赖版本号不断重试要么在冲突率高的时候让用户体验稀碎。ZooKeeper / Etcd本身就是强一致性系统锁的可靠性高但需要额外维护一个分布式协调集群运维成本高一次加锁解锁的往返链路也明显比 Redis 更重。Redis单线程事件循环天然让命令执行具备原子性SET key value NX EX一条命令就能搞定互斥和过期时间纯内存操作性能能到几十万 QPS 量级。缺点也很明确主从复制是异步的极端情况下锁可能丢失。选择 Redis 不是因为它完美而是因为它在“绝大多数业务可接受的可靠性 高性能 易用性”这个交叉点上平衡得最好。我自己做技术方案的原则是如果一个锁方案能覆盖 99% 的场景剩下 1% 用数据库的唯一索引或幂等表去兜底那么这比为了 100% 一致性去上 ZooKeeper 划算得多。这个认知也是后面判断 Redisson 甚至 RedLock 选型的基础。2. 手写 Redis 分布式锁从三行命令到能跑的四步演进2.1 第一版SETNX DEL一个典型的反面教材手写分布式锁很多人第一步会写出这样的命令SETNX product:123:lock 1 # 执行业务代码 DEL product:123:lockSETNX在 key 不存在时写入并返回 1key 已存在时返回 0。这套逻辑单看是正确的第一个线程抢到锁执行业务业务结束释放锁第二个线程拿不到锁就等重试。但这个版本有个致命缺陷没有设置过期时间。假如执行业务的实例突然宕机DEL永远不会执行product:123:lock这个 key 会一直存在所有其他实例永远拿不到锁整个服务直接“假死”。有人会说那我再补一条EXPIRE命令设置过期时间不就行了这就引申出第二版要解决的原子性问题。2.2 第二版SET ... NX EX 原子搞定互斥和过期既然SETNX和EXPIRE分两步执行会产生间隙那最直接的思路就是把两步合成一步。Redis 从 2.6.12 版本开始给SET命令扩展了 NX 和 EX 参数SET product:123:lock 1 NX EX 10这条命令的意思是key不存在时才能写入同时设置 10 秒过期时间。整个操作是原子的Redis 单线程执行期间不会插入其他命令。执行结果返回OK代表拿到锁返回nil代表锁被其他人持有。这个版本已经能防死锁了就算业务线程运行到一半崩溃10 秒后 key 自动消失锁自动释放。注意这里 10 秒一定得根据业务耗时来评估太短会导致锁提前释放太长会导致冲突时等待时间过长后面我会专门讲参数怎么定。2.3 第三版给锁的 Value 加唯一标识防止误删第二版能解决死锁但还藏着一个特别隐蔽的坑锁过期之后自己把别人刚拿到的锁给删了。画个时序你感受一下A 线程加锁成功value 是 1设置了 10 秒过期。A 线程业务执行比较慢超过 10 秒还没结束锁自动过期。B 线程加锁成功同样拿到值 1。此时 A 线程终于执行完执行DEL product:123:lock把 B 线程持有的锁删了。C 线程一看锁没了立刻加锁成功于是 B 和 C 同时进入临界区锁形同虚设。解法也很简单给每个线程一个唯一的标识释放锁之前先检查 value 是不是自己的。这个唯一标识在 Java 里通常用UUID.randomUUID().toString()或者用当前线程 ID 再加一个随机数保证在同一 JVM 内也不会重复。接下来重新定义加锁和解锁逻辑# 加锁token 是客户端生成的唯一标识 SET product:123:lock token NX EX 10 # 解锁前先查一下 value 是不是自己的 GET product:123:lock # 如果返回的 value token才执行 DEL2.4 第四版用 Lua 脚本把“检查 删除”做成原子操作第三版还有最后一块拼图没补上先GET再DEL是两步操作中间隔了网络往返。万一GET返回了 token还没来得及执行DEL锁就过期了另一个线程拿到了锁这时候你的DEL又把人家的锁删了。正确的做法是把判断和删除放进同一个 Lua 脚本里让 Redis 把整个脚本当作一个整体原子执行if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end脚本逻辑很简单KEYS[1]是锁的 keyARGV[1]是当前线程的 token。如果 key 的当前值等于自己的 token就删除 key否则什么也不做。因为 Lua 脚本在 Redis 中执行期间不会插入其他命令所以“判断”和“删除”自然就变成了一次原子操作。在 Spring Data Redis 项目里这段脚本的调用范例如下private static final String UNLOCK_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(UNLOCK_SCRIPT, Long.class); // 加锁 String token UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, token, 10, TimeUnit.SECONDS); // 业务只需满足 locked 为 true if (Boolean.TRUE.equals(locked)) { try { // 临界区业务逻辑 } finally { // 解锁原子执行 Lua 脚本 redisTemplate.execute(redisScript, List.of(lockKey), token); } }因为锁这里只用到了 Redis 的 String 类型value 里存的是一段唯一 token这就是为什么面试时有人问“Redis 那么多数据类型分布式锁用的是什么”答案就是 String。2.5 手写方案依然绕不开的三个大坑走到第四版你已经能写出一个“基本可用”的分布式锁了。但如果你真正把它放到高并发生产环境还有三个坑是常规手写方案绕不过去的不可重入同一个线程在方法 A 里加了锁方法 A 内部又调用方法 BB 也尝试加同一把锁结果必然是互相死锁。Java 的synchronized和ReentrantLock天然支持可重入但 Redis 锁没有线程概念需要你自己在客户端维护“当前线程已持锁”的 ThreadLocal 计数工程量一下子就上来了。没有自动续期业务执行时间很难预估。你把锁过期时间设成 5 秒业务正常跑完只要 100 毫秒但遇到慢查询、下游接口卡顿一卡就是 10 秒锁提前过期临界区就失守了。设成 60 秒呢万一持有锁的实例宕机其他线程要等整整 1 分钟才能恢复服务。这个矛盾靠手写很难平衡。主从复制导致锁丢失Redis 主从是异步复制Master 上写入了锁但还没同步给 Slave 就宕机了Slave 被提升为新的 Master 后压根没有这把锁另一个客户端就能趁虚而入。这三个问题不是“优化一下”就能解决的它们需要的是一个相对完整的框架去封装可重入计数、过期时间自动续约、多节点场景下的故障考虑。这也正是我们下面要讲的 Redisson 存在的意义。我见过一些团队自研锁用了半年最后代码里还是堆满了各种补救逻辑等于自己造了一个“县城版 Redisson”造完之后还得自己维护。3. Redisson 最佳实践把锁的细节交给工业级框架3.1 为什么 Redisson 能成为事实标准Redisson 是基于 Netty 的 Redis Java 客户端和 Jedis、Lettuce 那种偏“底层命令客户端”的定位不一样它把 Redis 的很多高级能力都封装成了 Java 开发者熟悉的接口。光说分布式锁它直接给你实现了java.util.concurrent.locks.Lock的子接口RLock用起来几乎可以无缝替换本地锁。Redis 官方在分布式锁设计文档里也明确提到了 Redisson 作为 Java 端的参考实现。再多说一句Redisson 不止能做锁像本地缓存、分布式对象、分布式计数信号量、延迟队列这些场景它都有现成封装锁只是其中最出名的一个模块。如果你新项目里已经在用 Redisson没必要再单独引一套 Jedis 去维护连接一套客户端就能覆盖绝大多数高可用场景。3.2 看门狗Watchdog是怎么给锁续命的Redisson 锁的核心亮点就是看门狗机制一句话说明白如果你没给锁指定“租约时间”Redisson 默认锁的有效期是 30 秒并且启动一个后台定时任务只要当前线程还持有锁每隔约 10 秒就把锁的过期时间重置回 30 秒。业务正常结束unlock()会主动删除锁并取消续期任务业务线程彻底崩溃锁最多在 30 秒后自动过期不会永久死锁。看门狗用到的底层逻辑可以在源码里看到Redisson 在RedissonLock里通过scheduleExpirationRenewal方法注册一个定时任务每次续期会重新设置过期时间并再次调度直到持有锁的线程调用unlock或被标记为不再持有锁。它维护的是一个引用计数结构同一个线程多次加锁时不会重复创建定时任务。这里有个特别容易踩的坑看门狗只在没有显式传 leaseTime 时生效。如果你调用的是tryLock(waitTime, leaseTime, TimeUnit)那么锁会在leaseTime之后准时过期没有任何续期。很多项目里写了lock.tryLock(0, 10, TimeUnit.SECONDS)本意是“抢不到就放弃跑了 10 秒自动释放”结果业务一执行就超过 10 秒锁提前释放排障时怎么都想不到是这里出了问题。3.3 正确姿势tryLock 配合 finally 释放锁的完整模板Redisson 的官方锁虽然好用但用错了照样出事。我在项目里落地的标准模板是这样的Resource private RedissonClient redissonClient; public void deductStock(Long skuId, Integer count) { String lockKey stock:lock:sku: skuId; RLock lock redissonClient.getLock(lockKey); boolean isLocked false; try { // 等待 2 秒拿锁没拿到就直接返回失败 isLocked lock.tryLock(2, TimeUnit.SECONDS); if (!isLocked) { throw new BusinessException(系统繁忙请稍后重试); } // 临界区查询库存 - 校验 - 扣减 - 落库 doDeduct(skuId, count); } finally { // 只有真正持锁且锁还属于当前线程时才解锁 if (isLocked lock.isHeldByCurrentThread()) { lock.unlock(); } } }这个模板里三个细节值得单独说tryLock(2, TimeUnit.SECONDS)只传等待时间没传租约时间所以看门狗生效锁不会被业务耗时“意外过期”。如果你传了时间就变成tryLock(2, 10, TimeUnit.SECONDS)锁 10 秒后必然释放适合那些有明确执行时间上限、且不允许长时间占用的特殊场景。解锁必须在finally里做否则业务执行抛异常时锁永远不会释放下一次请求只能干等。解锁前一定要判断isHeldByCurrentThread()。在极端场景下当前线程的锁可能因为网络等原因已经丢失、被另一个线程拿到了这时候你要是直接unlock()删的就是别人的锁。3.4 可重入锁、公平锁、读写锁、红锁到底怎么选继续往深了说Redisson 的getLock默认返回的其实是可重入锁同一个线程多次lock()时锁的计数会累加每次unlock()递减降到 0 才真正删除 Redis key。这意味着你的业务代码里可以放心嵌套调用不用像手写锁那样担心自己锁死自己。这一点在重构时会非常受用老代码里到处都是互相调用的方法可重入能力能省掉大量额外处理。有些场景还需要指定锁的公平性getFairLock()底层维护了一个等待队列先来先得适合“秒杀限量资源”这种每个用户都有排队顺序诉求的业务。缺点是 FairLock 每次加锁都要扫描队列并标记状态性能开销明显高于普通锁能不用尽量别用。读写锁getReadWriteLock()我反而觉得很多读多写少的业务值得尝试读读可以并发写写必须互斥。比如商品详情页的缓存重建多个请求同时读没问题但只有更新缓存时才需要写锁整体吞吐量能高出一截。至于红锁RedissonRedLock我的态度比较务实非必要不上。RedLock 要求多个独立 Redis 实例同时参与仲裁运维成本和硬件成本都翻好几倍而且分布式存储领域关于“时钟跳跃、GC 停顿”对 RedLock 安全性的争议一直没有定论。如果你的一致性要求真的高到普通 Redis 锁扛不住我会直接建议你换 ZooKeeper 或 etcd而不是在 Redis 锁的方案上继续打补丁。这也是我给团队做技术选型时反复强调的一点锁的可靠性不是靠“更复杂的 Redis 玩法”堆出来的而是要看整体架构里的兜底手段。3.5 项目里最常用的配置与参数调优经验想把 Redisson 用得稳除了锁 API 本身连接池和几个超时参数也很重要连接池配置Redisson 的 Netty 连接默认状态足够大多数情况但高并发锁场景下connectionMinimumIdleSize和connectionPoolSize要结合接口 QPS 来调。如果每个请求都要抢锁连接数是很容易被打满的一旦连接池耗尽问题会从“等待锁”变成“等待连接”表现和死锁很像。watchdog 超时时间默认 30 秒一般够用。如果业务经常超过 30 秒别急着把lockWatchdogTimeout调到 10 分钟这会让“宕机后锁迟迟不释放”的风险窗口变得巨大。正确做法是拆锁、缩小临界区、把长时间操作异步化。锁等待时间 waitTimetryLock的等待时间 1~3 秒比较符合用户感知。设成 10 秒的话高并发下请求会大量堆积在线程池里表面上是锁的竞争实际上是资源被无谓占满。锁 key 设计一律用“业务前缀 唯一业务 ID”像stock:lock:sku:9527这种。一个服务里最多只允许几类锁不要搞出成百上千个不同前缀否则排查问题时连规律都找不到。4. 实际项目里那些“锁没锁住”的故障与排查实录4.1 案例一库存超卖怎么一步步定位到锁线上突然出现库存扣成负数第一反应别直接改代码先回答这个问题超卖数据出现在同一把锁的保护范围内吗我一般按这个顺序排查把超卖订单的时间戳、SKU、实例 IP 拉出来看确认是不是同一秒、不同实例之间同时放行。检查加锁代码是不是真正包住了“查询 stock - 判断库存 - 扣减 - 写回”。很多代码喜欢把锁加在 controller 层但真正执行扣减的是 service 层另一个方法两边根本没锁在同一把 key 上。到 Redis 里观察锁 key 在业务执行期间的 TTL。如果锁过期时间只有几秒而业务执行要十几秒那不用看锁早没了。可以用SCAN按前缀找到锁 key再用TTL key看剩余时间。检查释放锁逻辑是不是有问题是否没用唯一 value、是否没有用 Lua、是否在finally外 unlock。任何一个瑕疵都可能导致误删锁。最后检查 Redisson 调用姿势是不是显式传了leaseTime导致看门狗失效是不是在tryLock拿不到锁后没有抛异常而是继续往下走业务。这整套排查工夫做好之后大多数“锁没锁住”的问题根因都出在加锁范围或者过期时间上真正需要调 Redisson 内部实现的情况反而很少。4.2 案例二一把大锁把接口性能打到几百 QPS还有个高频踩坑加了锁之后接口从秒级并发变成串行。最典型的是整个抢购接口共用一把lock(seckill)所有商品、所有用户都在抢同一把锁。一件商品的库存 1000本来可以 1000 个请求同时扣结果被锁串行化成一个一个过QPS 自然惨不忍睹。优化的思路分三步锁粒度细分把全局锁拆成seckill:sku:{goodsId}每个商品独立竞争更极致一点可以按用户维度加锁user:{userId}:seckill但要注意用户维度的锁无法防止同一个用户同时下单需要在业务上再做幂等校验。热点分段真实秒杀场景还可以用“库存分段”的思路把一件商品的库存拆成 10 段每段对应一个子锁。请求进来先按用户或请求 ID 哈希取模落到某个分段上加分段锁扣库存。这和 ConcurrentHashMap 的分段锁思想是同理的。缩小临界区只对“查询库存 扣减 写回”这段必须串行的逻辑加锁把缓存刷新、埋点、发通知这些 IO 操作移出锁外。锁内执行时间越短整个系统的吞吐量越高。4.3 案例三主从切换时锁丢了怎么兜底主从切换导致锁丢失是 Redis 异步复制机制决定的不是 Redisson 能解决的问题。现象是锁在 Master 上写入后还没同步到 SlaveMaster 宕机Slave 上升为 Master锁数据不在了此时另一个客户端成功加锁两个客户端同时进入临界区。我的处理建议分三个层级第一层先评估业务容忍度。如果这个锁保护的是“用户领取一张优惠券”“扣减一次库存”这类高频操作把锁丢的概率降到最低之后真正可能出现重复只发生在极端故障窗口。与其上复杂的分布式锁方案不如加一张唯一索引或幂等表做兜底。数据库唯一约束才是这类问题的终极防线。第二层配置层面降低丢失窗口。让 Redis 主从尽量同步比如设置min-slaves-to-write或新版本里的replica相关配置要求至少一个从节点确认写入才返回能有效减小锁丢失的概率但这不是绝对一致。第三层真有强一致需求就换技术栈。用 ZooKeeper/etcd 的临时顺序节点实现锁或引入数据库事务。一句话不要因为“锁丢了”就给 Redis 锁套上一堆复杂补丁补到最后往往是运维复杂度爆炸收益却不到 0.1%。4.4 面试里高频出现的几个分布式锁问题既然标题关联了一大堆面试热词这里干脆把面试官最爱问的几个点一起回答了Redis 分布式锁的实现原理是什么标准回答分两层底层是用SET key value NX EX原子加锁用 Lua 脚本原子释放上层用 Redisson 处理可重入、续期等工程问题。能提到看门狗机制说明你有实际生产经验。业务执行时间超过了锁过期时间怎么办分三种情况使用 Redisson 默认不加 leaseTime走看门狗续期使用手写方案的话只能手动续期或者干脆缩短临界区如果业务必须在固定时间内结束且不允许续期那要评估延长过期时间是否可接受。Redis 锁和 ZooKeeper 锁的区别Redis 优点是性能好、实现简单缺点是主从切换可能丢锁ZooKeeper 优点是顺序节点和临时节点机制保证一致性缺点是性能相对低、维护成本高。RedLock 有争议吗有主要争论点是它依赖多节点仲裁但如果节点上有时钟跳跃或长 GC 停顿依然可能出现两个客户端同时持有锁的情况。本质上分布式系统没有完美方案只能根据业务场景选择“更合适”的一致性水平。面试官其实不是想听你背命令而是想确认你有没有想清楚锁是协调工具不是银弹。你如果能把自己的线上案例聊出来比背一百页八股文都有用。4.5 调试 Redis 锁常用的命令与工具最后分享几个我实际排查锁问题时常用的小工具和命令网上很少有人系统提过本地或测试环境装 RedismacOS 可以用brew install redisWindows 最省事的方式是 Docker 跑一个容器一条docker run -d -p 6379:6379 redis:7就搞定了。想省内存可以用redis-server --save --appendonly no关掉持久化只作为锁调试环境。redis-cli 快速查看锁状态GET看当前锁的 valueTTL看剩余过期时间SCAN MATCH stock:lock:* COUNT 1000批量扫描锁前缀。实时观察锁的命令流redis-cli MONITOR可以短暂开几秒观察其他服务实际写入的锁 key 和 TTL。注意生产环境不要长时间开启 MONITOR它会拖慢 Redis 性能。可视化工具RedisInsight 是官方推出的桌面客户端Another Redis Desktop Manager 也有很多人用。对于 String 类型的锁 key直接看 key 的 value 和 TTL 非常直观比在命令行里盲猜舒服得多。Redisson 调 debug 日志把org.redisson.RedissonLock的日志级别调整到 DEBUG可以看到加锁、解锁、续期的具体日志线上最能说明问题的证据就在这里。当你看到某个锁 key 的 TTL 一直维持在 30 秒左右反复重置说明看门狗在正常工作如果你发现有unlock日志但业务还没结束就要检查是不是有人提前释放了锁。最后分享一个自己摸索出的体会。我最早做分布式锁也是SETNXDEL一把梭后来在压测时翻过车才老老实实换成了 Redisson。踩过几次坑之后我现在写锁相关代码会反复问自己三句话锁的 key 能设计得多小临界区能缩短到什么程度如果 Redis 真出现极端故障这个业务能不能接受极小概率的重复执行想清楚这三件事很多锁问题在设计阶段就能被消掉。手写锁最大的价值是帮你理解原理但追求落地产出的话还是直接上 Redisson 吧然后把 Lua 脚本、看门狗这些知识留在脑子里用来排查线上问题而不是用来维护自己写的锁。
返回列表