ARTICLE DETAIL

资讯详情

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

Redis setnx + UUID 分布式锁:原理、实现与避坑指南

Redis setnx + UUID 分布式锁:原理、实现与避坑指南 看到“Redis setnx UUID”这个标题我第一反应就是分布式锁。这个词条组合在Java后端几乎是一个固定搭配网上能搜到的Redis分布式锁教程十有八九都是这个套路用setnx去抢锁用UUID作为锁的持有者标识释放的时候再比对一下UUID防止把别人的锁误删。方案本身确实经典但很多人只是照着代码抄并不知道每个环节背后的为什么。这导致上线之后踩坑的不少死锁的、锁被误删的、Redis被打满的、并发还是没控制住的各种翻车现场我都见过。这篇文章会把Redis setnx UUID这套方案从原理到落地完整拆一遍包含加锁解锁的完整代码、过期时间的设置逻辑、序列化配置的坑、以及线上排查思路。适合用过Redis但没系统梳理过分布式锁的开发者也适合准备面试想把这套方案讲透的人。文章里我会把关键步骤和参数选择都解释清楚保证你看完能直接照着写出一把能用的锁也能在面试官问“为什么用UUID”的时候给出让人信服的答案。1. 先厘清分布式锁要解决的核心问题1.1 单机锁与分布式锁的差异传统的Java并发编程里我们处理多线程竞争用的是synchronized或者ReentrantLock这些锁都基于JVM内存只在当前进程内生效。但一旦服务从单机部署变成多实例部署情况就完全不同了。假设你有两台服务器同时跑同一个订单服务Nginx把请求分别打到这两台机器上。两个请求同时要为同一个订单做支付回调各自在本地JVM里都拿到了synchronized锁。问题是这两个锁互相不可见机器A上的线程认为临界区只有自己进来机器B上的线程也认为自己独占结果就是两段支付逻辑同时执行账务出现重复。这就是经典的“单机锁在多实例下失效”问题。分布式锁解决的正是这个跨进程的互斥问题。它需要一个所有服务实例都能访问到的公共组件作为判定的中心Redis恰好是最常用的一个。核心思路很简单谁能够在Redis里成功写入一个指定key谁就获得了锁其他人只能等待。因为Redis是单线程处理命令的同一时刻只有一个客户端能写入成功互斥性天然成立。1.2 一把合格的分布式锁需要满足什么不要急着写代码先把评价标准定下来后面所有设计决策都用它来检验。一把合格的分布式锁至少满足三个条件互斥性任意时刻只有一个客户端能持有锁。安全性加锁和解锁必须由同一个客户端完成不能出现A释放B的锁。可用性持有锁的客户端崩溃后锁能够自动释放不会造成死锁。另外在并发较高的场景还要考虑性能和容错性。性能指的是加锁解锁操作本身不能太重最好一次Redis命令完成容错性指的是当某个持有锁的节点宕机其他节点还能继续拿到锁而不是全线卡死。明确这几个标准之后再回头看setnx UUID这个组合你会发现它每一个组件都有明确职责setnx负责互斥和原子性UUID负责标识持有者过期时间负责可用性Lua脚本负责安全释放。没有一个是多余的。2. 为什么 setnx UUID 是经典组合2.1 setnx 的原子性锁的互斥从哪来setnx全称是SET if Not eXists意思是“只有当Key不存在时才设置成功”。这个命令从名字上就能看出天生适合做互斥判断。我举个例子。三个线程同时执行SET lock_order 1 NXRedis是单线程模型命令在Redis内部会逐个执行因此三个请求最终只有一个返回OK另外两个返回nil。返回OK的线程认为自己抢到了锁可以进入临界区其他线程只能等待或者重试。这里要特别强调一个概念原子性。你可能会想用GET先查一下key存不存在如果不存在再SET不也能实现同样的效果吗理论上是但实际操作中这两条命令之间存在时间窗口。线程A先GET到key不存在还没来得及SET线程B也GET到key不存在随后B先SET成功A再SET也成功两个线程同时认为自己拿到了锁。这就是非原子操作导致的并发漏洞。setnx把“判断是否存在”和“写入值”这两个动作合并为一条命令Redis执行时不会插入任何其他命令天然避免了上述竞态。这是整个方案最底层的保障再往上的所有操作都必须建筑在这个原子性之上。2.2 UUID 存在的理由锁不能被人乱删如果只用setnx会出现一个很典型的坑锁的误删。沿着上面的场景继续推演假设线程A拿到了锁设置的过期时间是30秒。A执行业务时因为GC停顿或者其他原因超过了30秒Redis上的锁自动过期了。这时线程B拿着同一个key来setnx马上成功B获得锁进入临界区。又过了一会儿A的业务终于执行完了进入finally块释放锁执行DEL lock_order。锁定眼一看把B的锁删掉了。这时候线程C又进来setnx发现key不存在加锁成功。于是B和C同时执行临界区代码互斥性被破坏业务上就出问题了。问题的根源在于A删除锁的时候锁的持有者可能已经变成了B。要解决这个误删问题锁的value就不能是固定的1之类的常量而必须是能够标识唯一持有者的信息。UUID就是干这个的。每个客户端加锁时生成一个全局唯一的UUID作为value写入Redis。释放锁的时候先取出Redis里的value和当前线程持有的UUID比对只有相等才能执行删除。这样一来就算A的锁已经过期、B已经拿到锁A在释放时发现Redis里的value是B的UUID跟自己手里的UUID对不上删除操作就会放弃不会误删B的锁。2.3 从 SETNX 到 SET NX EX一个经典的演进坑早期的Redis教程里加锁是分两步写的步骤一执行SETNX lock_key uuid尝试加锁。步骤二如果加锁成功再执行EXPIRE lock_key 30设置过期时间。这段代码表面上看没什么问题实际上埋着一个巨大的隐患。如果步骤一执行成功后客户端在步骤二执行前崩溃了或者网络闪断Redis里的key就永远不会过期整把锁就永久死锁了。所有其他线程再也无法获得锁服务直接瘫痪。修复方案有两种。一种是加锁时在finally里补偿但这解决不了进程突然崩溃的情况。真正优雅的做法是用Redis从2.6.12版本开始支持的组合命令SET key value NX EX expireTime。这一条命令同时完成“不存在才设置”和“设置过期时间”两个动作原子执行彻底消除了锁永不失效的死锁风险。所以现在严谨的写法都是SET lock_order 550e8400-e29b-41d4-a716-446655440000 NX EX 30其中NX表示只有当key不存在时才设置EX 30表示过期时间30秒。这是整个方案中我认为最重要的一个演进面试时能主动提到这一点说明你真的理解锁的安全边界。3. 用 Java 落地一把可用的分布式锁3.1 环境准备与依赖选择动手写代码之前先确定环境。Redis本身可以本机安装也可以直接拉官方镜像跑Docker容器这两条路都不复杂网上有大量安装教程这里不重复展开。我建议至少准备一个单节点的Redis版本最好在4.0以上方便使用后面提到的Lua脚本。Java客户端方面主流选择是Jedis和Spring Data Redis里的RedisTemplate。Jedis更轻量、更接近原生命令适合理解原理RedisTemplate封装度高在Spring Boot项目里几乎零配置就能用。我自己习惯在Spring Boot项目里用RedisTemplate但下面会把两种方式的差异说清楚。需要特别提醒的是序列化配置。分布式锁的key和value务必使用StringRedisSerializer不要用JDK默认序列化。JDK序列化会把value写成带有类型信息的二进制数据Lua脚本里比对字符串会直接失败这是一类非常隐蔽的线上故障后面第4章我会详细展开。3.2 加锁实现SET key uuid NX EX加锁的逻辑用RedisTemplate的setIfAbsent方法就能实现这个方法内部对应的就是SET key value NX EX语义。核心代码如下public class RedisLock { private StringRedisTemplate redisTemplate; private String lockKey; private String lockValue; private long expireSeconds; public boolean tryLock(String lockKey, long expireSeconds) { String uuid UUID.randomUUID().toString(); Boolean success redisTemplate.opsForValue() .setIfAbsent(lockKey, uuid, expireSeconds, TimeUnit.SECONDS); if (Boolean.TRUE.equals(success)) { this.lockKey lockKey; this.lockValue uuid; this.expireSeconds expireSeconds; return true; } return false; } }注意几个细节。第一setIfAbsent返回的是Boolean对象不要直接拿来当boolean用或者和true做比较要用Boolean.TRUE.equals(...)判空否则拆箱时可能出现NPE。第二UUID要在调用setIfAbsent之前就生成好并且保存到成员变量里释放锁时要拿它和Redis里的值做比对。第三加锁成功后把lockKey和lockValue保存下来这是为了让unlock方法知道要删哪个key、拿哪个UUID去比。有的场景需要等锁也就是拿不到锁的时候阻塞重试。这需要给tryLock加上等待时间和重试循环public boolean tryLockWithWait(String lockKey, long expireSeconds, long waitMillis) throws InterruptedException { long deadline System.currentTimeMillis() waitMillis; do { if (tryLock(lockKey, expireSeconds)) { return true; } Thread.sleep(50); } while (System.currentTimeMillis() deadline); return false; }这里的重试间隔我习惯取50毫秒既不会对Redis造成太大压力也能保证锁释放后很快被抢到。间隔太短比如1毫秒会让Redis在锁即将释放时承受极大的QPS冲击间隔太长比如500毫秒业务延迟又会变得明显。3.3 释放锁必须用 Lua 脚本保证原子性释放锁是整套方案里最容易写错的地方。很多人天然写成了三步先GET取出Redis里的value然后和当前UUID比较相等则DEL。这个思路没错但实现方式有隐患。问题还是出在原子性上。GET和DEL是两条独立的命令中间存在时间窗口。线程A执行完GET拿到UUID_A确认和Redis里的value相等就在这一瞬间锁由于超时过期了线程B立即加锁成功把value写成了UUID_B然后A再执行DEL把B的锁删掉了。误删问题换个马甲又回来了。正确做法是把“比较value”和“删除key”合并成一个原子操作Redis原生支持在服务端执行Lua脚本脚本运行时Redis不会执行其他命令天然保证原子性。标准脚本如下if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endJava侧调用public void unlock() { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); redisTemplate.execute(redisScript, List.of(lockKey), lockValue); }这里一个非常容易踩的坑是DefaultRedisScript的构造最好放在类加载时完成不要每次unlock都new一个。因为RedisTemplate执行脚本时每次都要把脚本内容发给Redis做EVAL如果脚本对象每次都重新创建会白白增加网络开销。实践上我都是把脚本定义成静态常量。另外还要强调一点释放锁的操作必须放在finally块里业务代码再怎么抛异常锁的释放不能被跳过。否则锁会一直持有到过期时间影响其他线程进入。3.4 锁的过期时间怎么定锁的过期时间是一个非常考验经验的参数。给得太短业务还没来得及执行完锁就过期了其他线程会趁虚而入给得太长一旦持有锁的节点真的崩溃了其他线程要白白等待很久。我见过不少团队拍脑袋设置成10秒、30秒结果线上偶发出现并发穿透。根本原因是没算过业务耗时。正确做法是先压测出临界区代码的正常耗时时长然后乘以3到5倍作为锁的过期时间。比如接口平均耗时200毫秒那么过期时间设置为1秒到2秒就足够了如果业务里有外部调用且响应不稳定保守起见可以设置成10秒甚至30秒。为什么是3到5倍而不是刚好等于平均耗时因为GC停顿、网络抖动、线程调度都可能导致耗时出现毛刺。锁的过期时间稍微富余一点能容忍这些正常波动又不会因为太长而影响故障恢复速度。3.5 可重入与续期进阶问题如何处理如果业务里出现了嵌套加锁的情况比如一个方法先加了锁内部调用另一个方法又对同一个key加锁那么当前的实现会让第二个加锁永远失败造成死锁。这就是分布式锁的可重入问题。要支持可重入方案有几种。最简单的是在加锁之前判断当前线程是否已经持有了这把这个锁如果持有只增加一个重入计数而不重新触发setnx。具体做法是用一个ThreadLocal记录当前线程的UUID和重入次数第一次加锁成功ThreadLocal里写入UUID计数置为1。再次加锁判断ThreadLocal里的UUID和当前线程要用的UUID一致直接计数加1返回成功。释放锁计数减1只有当计数归零时才真正执行上面的Lua删除脚本。另一个常见话题是续期。如果锁过期时间设定得比较保守但业务偶尔出现远超预期的慢请求锁还是会在业务完成前过期。Redisson的做法是启动一个“看门狗”线程在锁快过期时自动给租约续期。如果自己实现续期可以在加锁成功后启动一个定时任务每过期时间的1/3执行一次续期把key的过期时间重置同时要在释放锁时取消这个定时任务避免锁都释放了还在给别人的锁续期。这两个功能我建议在真正需要时再引入大多数业务场景下把过期时间设得合理一些就足够过早引入看门狗反而增加复杂度。4. 线上问题排查实录与避坑清单4.1 最经典的误删锁事故我在一家电商公司踩过最狠的一次坑就是误删锁。当时一个库存扣减接口用了setnx但value没有用UUID而是写死的字符串“lock”。上线当天没什么异常但大促期间突然出现了严重超卖。事后排查发现线程A的锁提前过期线程B加锁成功A在finally里执行删除操作时把B的锁删了接着线程C又加锁成功导致B和C同时进入扣减逻辑。问题代码长这样// 错误示例不要模仿 redisTemplate.opsForValue().setIfAbsent(stock_lock, lock); // 忘了解锁时判断value redisTemplate.delete(stock_lock);修复起来也不难就是把value替换成UUID并改用Lua脚本解锁。但我要提醒的是线上没有那么多重来的机会DEMO阶段就把UUID这套完整写进去别图省事。4.2 序列化配置不当导致的锁判断失效还有一个高频故障发生在RedisTemplate默认使用JDK序列化时。加锁时写入的value是UUID字符串但JDK序列化会在字符串前加上\xAC\xED\x00\x05t\x00\x15之类的二进制头Redis里存的实际value并不是纯UUID。等到解锁执行Lua脚本时redis.call(get, KEYS[1])拿回来的是带头的二进制串和ARGV[1]里的纯UUID比对不上于是返回0锁永远删不掉只能干等过期。判断方法有两种一是用RedisDesktopManager或者Another Redis Desktop Manager这类可视化客户端看锁value的原始内容如果看到一串乱码那就是序列化配置不对二是检查RedisTemplate是否配置了StringRedisSerializer。推荐直接用StringRedisTemplate它默认就是String序列化写锁相关代码时最省心。4.3 Redis连接超时与命令超时如果Redis所在的网络环境不太稳定或者连接池参数配置不当会出现类似RedisCommandTimeoutException的报错日志里通常长这样redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException这个报错的直接原因就是Redis命令在指定超时时间内没有返回。排查时先确认Redis本身的运行状况看机器CPU、内存、慢日志再看客户端连接池配置比如最大连接数是不是设得太小导致请求在池子里排队等连接。另外Lettuce客户端默认超时时间较短如果Redis响应本身偏慢可以适当调大超时时间但根本解法还是要找到慢的原因。4.4 主从切换与锁丢失的问题很多团队在Redis主从模式下部署分布式锁正常运行时没有问题但一旦主节点宕机从节点晋升为主节点就可能出现锁丢失。场景是这样的线程A在主节点上成功的设置了锁但这条写命令还没来得及同步到从节点主节点就挂了从节点被提升为新的主节点。此时新主节点上并没有这把锁线程B对新主节点执行setnx同样加锁成功。于是A和B同时持有了同一把锁互斥性被破坏。要彻底解决这个问题需要引入更强一致性的方案比如RedLock算法向多个独立节点同时申请锁过半成功才算加锁成功。但RedLock本身也有争议架构复杂很多资深工程师并不推荐在生产环境直接使用。更现实的思路是如果业务对一致性极其敏感例如账户余额扣减就不应该依赖锁来兜底而应该在数据层面做唯一约束或者幂等控制。把锁当成“尽力而为”的互斥手段配合业务侧兜底比追求一个完美的分布式锁更容易落地。4.5 高频问题速查表现象原因解决方案锁一直存在业务卡死加锁后崩溃未设置过期时间使用SET key value NX EX单命令原子设置其他线程提前进入临界区锁过期时间太短业务没跑完压测业务耗时设置3~5倍冗余时间自己删掉了别人的锁value固定值解锁不校验持有者value使用UUID解锁用Lua脚本比对删除删锁偶尔失效锁等到超时才释放序列化配置错误value被加了二进制头使用StringRedisTemplate两个线程同时持锁主从切换造成锁数据未同步业务侧兜底或评估引入RedLock拿锁重试导致Redis QPS飙升自旋间隔太小设置50毫秒以上的sleep间隔这张表基本覆盖了我这几年见过的大多数分布式锁故障。如果你按照这套setnx UUID方案去排查90%的问题都能在里面找到对应项。最后再分享一个我自己的使用心得。setnx UUID这个方案被很多人看轻觉得它不够高级不如Redisson、ZooKeeper这些重方案。但实际业务里绝大部分场景并不需要强烈一致性Redis在性能上的优势又非常明显。我最近几年在新项目里依然倾向先用它但会把过期时间、Lua脚本、序列化配置这些基础动作做扎实而不是只抄一段demo就上线。锁这个东西用好了是业务的保护神用不好就是下一个事故的引爆点。
返回列表