Redis分布式锁:从setnx到Redisson的原理、坑与实战 1. 从setnx到分布式锁一次想当然的入门即踩坑1.1 为什么Redis会成为分布式锁的首选如果你也在背分布式锁面试题一定绕不开Redis。我第40天傍晚埋在项目书里脑子却不断回放白天背过的setnx和Redisson说句实话这套组合拳是绝大多数团队落地分布式锁的默认路径。原因很简单Redis快、部署广、心智负担低。大部分公司Redis早就作为缓存组件跑在生产环境了复用同一套基础设施做锁不用引入ZooKeeper、etcd这类额外组件运维和成本的增量几乎为零。但能用来做锁和适合做锁是两码事。Redis做分布式锁的优势在于单线程执行命令天然串行化配合原子操作可以实现同一时刻只有一个客户端能拿到锁劣势也很明显Redis不是为强一致设计的主从异步复制、网络分区、GC停顿都会让锁的语义出现裂缝。这就是为什么setnx看起来已经给出了标准答案实际生产中却有一大堆用着用着就出事故的案例。我那天在项目书里刚好要设计一个库存扣减模块分布式锁是绕不开的技术选型。写文档前我照着记忆里最常见的方案列了一份伪代码一跑脑子里的推演就发现漏洞比预想的多得多。这不怪setnx本身怪的是对它抱有加个锁就万事大吉的幻想。1.2 setnx加锁的完整推导过程先从最朴素的想法说起。要保证多进程互斥访问一个共享资源最简单粗暴的方式就是利用Redis的原子命令SET lock_key unique_value NX PX 30000这条命令包含三个关键约束缺一不可。NX表示只有键不存在时才设置成功保证同一时刻只有一个客户端能加上锁PX设置过期时间防止客户端拿到锁后崩溃导致死锁unique_value是每个客户端生成的唯一标识通常是UUID或者客户端ID线程ID用于释放锁时校验归属。如果不用这条命令而是拆成两步——先SETNX再EXPIRE——就会出现经典竞态SETNX成功之后客户端在设置过期时间之前宕机这把锁永远不会释放其他所有客户端永久阻塞。当年不少老项目踩的就是这个坑所以面试里问setnx为什么不是安全的分布式锁第一步就是检查这条命令是否原子。释放锁同样不能直接DEL。正确做法是先比对value再删除if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end为什么不能直接把DEL发过去因为可能出现一种可怕的时间线客户端A拿到锁后执行了很长时间锁的过期时间到了自动释放客户端B此时加锁成功开始执行自己的临界区代码A终于执行完了随手一个DEL——它会把B的锁删掉。于是C又加锁成功三个客户端同时进入临界区分布式互斥彻底失效。用Lua脚本把get比对value del删除包成一个原子操作就是为了确保只能删除自己持有的锁。我在项目书的技术方案里专门画了一张时序图来说明这个流程写到这里时明显感到平时背八股被忽略的细节一旦要写进设计文档全都要回到它本来的逻辑链上。setnx这套方案本身能工作前提是你严格做到了原子加锁、过期时间、唯一标识、Lua释放。缺任何一个锁就从分布式互斥退化成了分布式表演。1.3 真实生产环境里setnx的致命缺陷即使上面四个步骤全做对了setnx依然不是生产环境的终点。我踩过、也见过别人踩的坑主要有三个。第一个是锁过期引发的临界区重叠。设置30秒过期但业务在临界区里可能要跑50秒比如批量导入、复杂报表计算、调用外部服务等待响应。锁在30秒被Redis清掉另一个客户端立刻加锁成功进入临界区此时两个线程同时在跑同一段代码。数据不一致就这么来的。解决办法要么把过期时间加到足够大要么引入自动续期机制——Redisson的watch dog就是干这个的后面细说。第二个是主从切换导致锁丢失。生产环境Redis几乎不会单节点部署主节点挂了会切到从节点。如果客户端A在主节点上加锁成功这条SET命令还没来得及同步到从节点主节点宕机从节点被提升为主节点新主节点上根本没有这把锁客户端B就能再次加锁成功。Redis官方后来提出RedLock算法想解决这个问题但RedLock本身也有争议实际落地效果见仁见智后面我也会聊几句。第三个是GC停顿。Java服务在临界区里发生Full GCStop The World可能几十秒甚至更久。这段时间内锁早就过期了等GC结束、线程恢复执行它完全不知道自己已经失锁依然在跑临界区代码。这类问题在客户端侧几乎无解只能靠业务幂等设计和锁粒度的合理规划来兜底。所以我的总结是setnx适合用在内部系统、低并发、容忍极小概率数据冲突、对一致性要求不苛刻的场景一旦涉及资金、库存、订单强一致要么引入Redisson要么老老实实上ZooKeeper/etcd这类带强一致语义的协调组件。2. 项目书写了整整一天我到底在写什么2.1 从一句话需求到可交付项目书的推演过程那天白天我花了大量时间写项目书从做一个库存扣减系统这种一句话需求开始硬生生写到一版能拿去评审的文档。整个过程回看一遍最有价值的不是最终文档本身而是把模糊想法拆成可执行方案的思考路径。项目书在我这儿至少包含六个模块背景与目标、用户场景、技术方案、接口与数据结构设计、排期与里程碑、风险清单。第一版最容易犯的错误是把背景写得又长又虚技术方案反而草草带过——这完全搞反了。好项目书的重心一定是技术方案背景两段话讲清楚为什么做就够了评审专家真正关心的是怎么做、有什么风险、工作量多少。我那天从需求出发先给库存扣减定义了两个核心场景秒杀场景下的超卖防护以及日常订单下的库存预占与释放。再往下一层拆技术选型就比较自然了库存数据放Redis做预扣减分布式锁保护预扣减和DB落库之间的一致性。到这里setnx还是Redisson这个选择题就不再是背八股而是变成了有业务前提的工程决策。2.2 写项目书时最容易被忽略的三个环节第一是接口设计。很多人写项目书只写采用RESTful接口然后就没有然后了。我在文档里补上了具体的接口定义包括请求路径、方法、参数表、返回码、幂等策略。比如扣减库存的接口必须支持幂等键因为网络超时后客户端重试是常态没有幂等保护的接口在高并发下会重复扣减。第二是数据库表结构设计。即使这个项目的核心操作在Redis最终一致性的落点仍然是数据库。我在表结构里设计了预占记录表、库存流水表还加了版本号字段用于乐观锁更新。写到这里时我意识到项目书的好处是逼你把每个环节都落到具体字段和约束上空谈高性能高可用是写不下去的。第三是风险清单。这一项很多项目书根本没有或者应付式写两三条。我至少列了五条硬风险Redis锁失效导致超卖、库存流水与预占记录不一致、热点商品导致Redis单key热点、接口重试引发重复扣减、缓存与DB不一致。每条风险都写了触发条件、影响范围、兜底方案。评审专家对风险清单的关注度往往超乎预期因为它直接反映写文档的人有没有做过生产系统。2.3 一天写完一版项目书的节奏实战一整天看起来很长实际能深入思考的时间只有上午的专注时段。我的节奏是这样的上午大脑最清醒先啃技术方案和接口设计这两个模块最烧脑下午精力下降适合画表格、补数据字典、填排期和风险清单傍晚做整体检查重点看逻辑是否闭环、术语是否统一。有个实操技巧非常管用正文之前先画一张系统架构图或者至少是模块关系图。哪怕用最简陋的框线图画清楚接入层、业务层、Redis层、DB层的调用关系后面的文档结构会自动顺着架构图长出来。我第一天写项目书时上来就写文字写到一半卡住不知道下一章该写什么后来改成先画图后写字效率翻倍。另一个技巧是项目书要能按图索骥。评审人拿到项目书应该能顺着目录直接找到任何一个技术决策对应的理由。我在技术选型那一节专门列了一张对比表setnx自研锁、Redisson、ZooKeeper锁、数据库乐观锁从一致性、性能、复杂度、运维成本四个维度打分。不是非要选最优项而是要让读者看到你是在权衡之后做选择不是拍脑袋定方案。3. Redisson的实现机制为什么它敢接setnx的盘3.1 watch dog自动续期解决锁过期的第一痛点setnx最致命的痛点是锁过期后业务还没跑完。Redisson给出的方案是watch dog看门狗官方默认不指定leaseTime时Redisson会启动一个后台定时任务每10秒检查一次锁的剩余有效期如果锁还没释放就自动把过期时间续到30秒。注意这里是续到30秒不是每次续10秒。这个设计的精妙之处在于它把锁的有效期和业务执行时间解耦了。业务跑多久锁就续多久业务结束主动释放锁watch dog也跟着撤销。我见过有人质疑万一业务死循环了锁不就永远不释放了吗这里有个容易忽略的细节watch dog的续期是跟着锁的持有线程走的如果持有锁的线程已经终止或异常退出未释放的锁还是会靠过期时间兜底回收。也就是说watch dog只在持有者还活着且临界区逻辑还在执行时才发挥作用。底层实现是Netty的时间轮工具HashedWheelTimer不是简单的ScheduledExecutorService。原因很简单时间轮适合大量定时任务的高效管理每个锁对应一个续期任务几十上百个锁同时续期普通定时器的开销会明显高于时间轮。这类高性能细节平时背八股可能一带而过真正读源码才看得进去。我自己的建议是生产环境不要轻易关掉watch dog更不要为了省事直接把leaseTime设为极大值。锁过期是兜底手段不是用来假保险的。真正完美的做法是依赖watch dog自动续期同时在业务代码里保证临界区尽量短双保险。3.2 可重入与公平锁Redisson覆盖的更多场景setnx方案默认不支持可重入。同一个线程先加锁成功再调用一个同样需要加锁的方法第二次加锁会失败因为key已经存在。Redis本身没有线程上下文的概念setnx根本无从判断第二次拿锁的其实是同一个持有者。Redisson用Hash结构解决这个问题key依然是锁的名称field是持有者标识客户端ID线程IDvalue是重入计数。加锁时执行Lua脚本如果field已存在且属于当前线程就把计数加1释放锁时计数减1减到0才真正删除key。这个设计简洁且正确也是面试里常被追问的Redisson为什么是可重入锁的核心答案。还值得一提的是公平锁模式。Redisson的Fair Lock会把等待获取锁的客户端排成一个队列按请求顺序依次授予锁这与默认的非公平锁不同非公平锁可能出现后来的客户端先拿到锁的饥饿问题。公平锁思路很纯粹用Redis的List结构存储等待队列在队列头部等待的客户端才有资格尝试加锁。代价是吞吐量下降所以生产环境默认仍是非公平模式。只有真有点单场景比如极少数的对账任务、定时任务互斥才会换公平锁。3.3 红锁RedLock的争议与我的取舍聊到Redisson不可避免要提RedLock——Redis官方提出的多节点分布式锁算法。它的思路是在N个独立的Redis节点上依次加锁如果超过一半节点加锁成功并且总耗时小于锁的过期时间就认为加锁成功。理论上这能解决单点故障和主从异步复制带来的锁丢失问题。但RedLock在分布式系统领域是有争议的。最著名的质疑来自Martin Kleppmann他写过一篇专门的分析文章指出RedLock既不能保证安全性也引入了一个新的问题分布式系统各节点时间或者网络分区场景下客户端可能在加锁期间被暂停比如GC等它恢复时锁已经被其他客户端获取但原客户端依然认为自己持有锁。RedLock并没有从根本上解决这个问题只是让概率变小了。我的个人取舍是如果一致性要求高到需要RedLock那不如直接使用ZooKeeper或etcd它们的线性一致性语义更清晰实现可靠性边界也更明确。如果一致性要求没那么极端单节点Redis Redisson已经可以覆盖绝大多数场景。RedLock夹在中间适用面其实很窄。分布式锁的核心不是用了什么算法而是你清楚你的锁在什么失效场景下会破防并且为这些场景准备了业务兜底。3.4 使用Redisson的实战注意点Redisson的API封装得很友好上手几乎没有成本RLock lock redissonClient.getLock(inventory:deduct:1001); boolean acquired lock.tryLock(3, 30, TimeUnit.SECONDS);tryLock的前两个参数分别代表等待时间和锁的持有时间。这里有个坑如果你显式指定了leaseTime比如30秒watch dog的自动续期机制就不会生效了。Redisson的规则是只有不传leaseTime才启动watch dog一旦你传了锁在指定时间后无条件释放业务没跑完就会出问题。所以除非你明确知道业务执行时间的上限否则建议直接用默认的自动续期。另一个注意点是锁的名称和粒度。锁的key不能设计得太粗比如整张订单表用一把锁并发一上来就是排队跟不用锁没什么区别。合理的方式是按业务ID取模分片比如库存扣减按SKU维度加锁同一SKU才互斥不同SKU之间完全无影响。锁粒度细化后并发上限能提升好几个数量级。我在项目书里就采用了SKU维度的锁设计每个商品一把锁锁key形如stock:lock:{skuId}。相比之下如果做成stock:lock:global全局锁秒杀场景基本是自嗨——看似有锁保护实际把系统压成了串行执行等于把Redis的性能优势全丢了。这段思路写起来不算长但确实是实际项目里最容易被忽视的一个细节。4. 回溯算法没有魔法靠的是撤销两个字4.1 单词搜索这道题到底在考什么白天一边写项目书一边还抽空刷了一道回溯算法的题就是LeetCode 79题单词搜索。题目说人话就是给定一个二维字母板判断能不能沿着相邻格子上下左右走出一条路径拼出给定的单词同一个格子不能重复使用。这道题是教科书级的回溯入门题原因在于它同时考察三个基础能力DFS遍历图结构、状态标记与恢复、方向枚举。很多新手容易犯的错误是把回溯和DFS混为一谈觉得只要能递归就算会了其实回溯的核心并不是递归去搜而是搜完以后把现场还原让其他分支也能继续搜。通俗一点说就是走迷宫的时候走过的地方要画个标记但这道题不允许重用格子所以等你尝试完一条死路必须把标记擦掉否则另一条路径就没法使用这块格子。之所以题面给的是字母矩阵而不是图结构就是为了考察你能否把二维坐标问题抽象成图遍历问题。每个格子的上下左右就是它的邻居节点能否继续递归就看邻居的字母是否匹配单词的下一个字符。搞定这一层抽象后续所有方向数组、visited数组的写法都会水到渠成。4.2 状态怎么选、方向怎么走写这道题的第一步是定义递归函数的参数。我的习惯是维护四个东西当前位置的横纵坐标、当前匹配到单词的第几个字符、字符矩阵本身、visited二维布尔数组。方向遍历的写法很固定用方向数组预处理int[] dx {-1, 1, 0, 0}; int[] dy {0, 0, -1, 1};然后for循环枚举四个方向计算新坐标检查新坐标是否越界、是否已访问、字符是否匹配三个条件都满足才继续递归。这里有个性能小优化剪枝。如果当前位置的字符和word.charAt(index)对不上立刻返回false连四个方向都不用看直接回溯到上一层。剪枝放在递归入口的第一行效果立竿见影。visited数组的撤销时机必须放在递归返回之后。如果递归返回true说明整条路径找通了直接向上传递true不需要撤销如果四个方向都失败了恢复visited为false然后返回false。这个先标记、递归、再复原的三段式结构是回溯算法的标准骨架几乎所有回溯题都长这样。4.3 复杂度分析与常见误区单词搜索的时间复杂度不是随随便便能拍脑袋定的。设矩阵大小为M×N单词长度为L最坏情况下以每个格子为起点做DFS每次DFS最多往四个方向走L步所以总复杂度是O(M×N×4^L)。4的L次方看起来吓人但它就是暴力搜索的真实上界。实际数据量下矩阵规模通常不会太大单词长度也有限所以这道题本身不是性能地狱而是在考察基础代码能力。还有一个很多人会栽进去的误区以为只要用visited数组防止回头就够了忽略了当前格子是否访问过必须在递归函数内部、以参数形式传递并恢复。如果不能正确撤销就会出现明明这格已经被别的路径标记了但图里其实没有其他路径用过它的错乱。调试这种Bug非常痛苦因为在递归树深处visited数组的状态是不可见的。所以我的建议是宁可多花五分钟把撤销逻辑写清楚也不要靠打印日志去猜。4.4 完整的回溯解法实现下面是我当天默认能直接敲出来的参考实现Java版一个主函数加一个回溯辅助函数public boolean exist(char[][] board, String word) { int m board.length; int n board[0].length; boolean[][] visited new boolean[m][n]; for (int i 0; i m; i) { for (int j 0; j n; j) { if (dfs(board, word, i, j, 0, visited)) { return true; } } } return false; } private boolean dfs(char[][] board, String word, int i, int j, int index, boolean[][] visited) { if (index word.length()) { return true; } if (i 0 || i board.length || j 0 || j board[0].length) { return false; } if (visited[i][j] || board[i][j] ! word.charAt(index)) { return false; } visited[i][j] true; int[] dx {-1, 1, 0, 0}; int[] dy {0, 0, -1, 1}; for (int k 0; k 4; k) { if (dfs(board, word, i dx[k], j dy[k], index 1, visited)) { return true; } } visited[i][j] false; return false; }唯一可以再优化的点是剪枝顺序。部分实现会在入口处先判断当前格子的字母是否匹配如果不匹配再返回false这样确实能让逻辑更早退出。当单词第一个字符的匹配概率较低时效果明显当字母表很小、匹配概率很高时这个剪枝的收益就不大。所以这道题完全不用追求极端优化把代码写对、撤销逻辑写清楚就已经超过大部分提交了。我刷这道题时的体会是回溯算法的核心不在于怎么递归而在于什么时候撤销、撤销到哪一层。把这个想明白了全排列、N皇后、组合总和、岛屿数量这些题都会迎刃而解。项目书写累了换换脑子刷一道回溯题反而让半天脑力劳动后的注意力重新聚起来那天我甚至觉得回溯比写文档更有提神效果。5. 背八股的高效姿势别背推演5.1 从背八股到推演八股白天花了不少时间背分布式锁的八股但我的体感是光背效率极低。后来我换了个方法把八股改成推演。拿到一个概念不是去记结论而是先问自己三个问题它解决了什么问题不这么设计会怎样它自己又引入了什么新问题setnx这条线完全可以推演出来它解决的是分布式环境下互斥所以必须有原子加锁和过期时间为了防止误删他人锁所以要有唯一标识和Lua释放为了延长有效期所以有了watch dog为了让锁更可靠所以Redisson做了可重入、公平锁这些扩展。这一串推下来面试官追着问任何一个环节你都有话可讲而不是背到Redisson用hash存储重入计数就断片。背八股最大的坑是背了结论但不知道边界。比如很多人知道setnx加锁却不知道锁过期会导致两个客户端同时进临界区知道Redisson有watch dog却不知道显式传leaseTime会禁用续期。这些东西恰恰是面试里最容易拉开差距的点也是实际生产最容易踩的地雷。5.2 怎么把八股变成自己的东西我现在的习惯是建立一个问题域卡片。每个技术主题一张卡片正面写问题场景背面写技术方案。比如分布式锁卡片正面写多个服务实例同时扣库存如何互斥背面写Redis SETNX 过期时间 Lua释放进阶版是Redisson watch dog自动续期。下次面试前翻卡片先看正面回忆背面回忆不出来的再翻看比从头到尾通读一遍效率高很多。推演也是写项目书的好工具。当天我在技术方案里写分布式锁时几乎不用翻资料因为白天刚把setnx到Redisson这条逻辑线推演了一遍。八股和工程实践在这里不再是割裂的两件事而是一体的八股是骨架项目书是血肉刷题是验证手感。趁着第40天的记忆还热乎我把这套方法记下来也分享给同样在每天赶进度、写文档、刷算法题的朋友。第40天不算什么里程碑但它让我彻底告别了背完就忘、写完就丢的学习模式这可能是这一天最大的收获。