☰
Redis分布式锁从单机到集群:演进、坑点与选型实战
2026/9/26 18:15:38 网站建设 项目流程

做后端这几年,Redis分布式锁是我被问得最多、也踩坑最多的中间件话题之一。最近团队在把服务从单机部署演进到集群部署时,一个新的同事直接把单机版代码拿过去用,结果定时任务重复执行、库存被扣超,排查了半天才定位到锁失效的问题。借着这个契机,我把从单机到集群的分布式锁整个演进过程重新捋了一遍,写成这篇偏实战的整理,希望能帮你避开那些我已经踩过的坑。

这篇内容会按照分布式锁从最简单的单机Redis实现,到主从哨兵架构,再到集群环境下的RedLock方案这条主线展开,解释每一步为什么要演进、解决了什么问题、又带来了什么新问题。特别适合正在做分布式系统设计、准备面试,或者刚接手锁相关代码的开发者参考。

1. 为什么先要一张“锁”——分布式锁的起点

1.1 单机锁为什么不够用了

先聊一个基础但特别重要的问题:在单机JVM里,我们用synchronized或者ReentrantLock,为什么到了分布式环境里就失效了?

因为这些锁的本质是JVM进程内的一把锁,锁对象放在某个进程的堆内存里,由JVM的监视器机制管理。同一台机器上的多个线程抢同一把锁,自然没问题。但服务一旦横向扩容,变成多个实例部署,就完全是另一回事了:实例A和实例B是两个独立的JVM进程,它们的锁对象互不可见,A线程加的锁,B线程完全感知不到。

我拿一个最典型的场景举例:库存扣减。假设库存只有100件,两个实例同时收到下单请求。实例A的线程1读到库存是10,实例B的线程2也读到库存是10。如果没有分布式锁,两个线程都可能先把库存减成9,再写回数据库。在高并发下,这个"读-改-写"的竞态窗口会被无限放大,最终导致超卖。用生活化一点的类比来说,单机锁就像你自己家里的门锁,只要家里人就够了;分布式锁是整栋楼共用的规则,你得先确认其他楼层的人不会在同一时间冲进来。

1.2 什么样的业务真正需要分布式锁

不是所有并发场景都需要分布式锁。如果业务本身可以用数据库的乐观锁(版本号)、唯一索引或消息队列的幂等机制解决,就不要引入额外组件。真正需要分布式锁的,通常是这几类:

  • 定时任务调度:多个实例部署时,同一个定时任务不能每个实例都跑一遍,否则会重复发消息、重复结算、重复对账。
  • 读改写操作:库存、余额、积分这类先查询再计算的场景,需要锁住整个操作过程。
  • 幂等控制:防止重复提交订单、防止回调重入,用一个锁key标记处理中状态。
  • 分布式资源互斥:比如多个服务同时要上传同一个文件、操作同一个本地目录。

有一点要提前说清楚:Redis分布式锁适合大多数互联网业务,但它不是银弹。如果业务资金链路要求绝对强一致,我会建议考虑ZooKeeper或者etcd这类带强一致语义的组件,后面会专门展开讲。

2. 单机Redis锁:SETNX的辉煌与暗坑

2.1 第一版实现:SETNX加锁加DEL解锁

最早的Redis分布式锁写法非常简单,用SETNX命令,全称是SET if Not eXists,只在key不存在时才能设置成功。

127.0.0.1:6379> SETNX order_lock 1 (integer) 1 // 返回1,表示拿到锁 127.0.0.1:6379> SETNX order_lock 1 (integer) 0 // 返回0,表示锁已被别人持有

拿到锁之后执行业务,最后用DEL释放:

127.0.0.1:6379> DEL order_lock (integer) 1

这套逻辑在"一切正常"的情况下跑得通,但生产环境从来不按剧本来。最大的问题就是:进程拿到锁之后,如果业务逻辑抛异常了,或者服务器直接宕机,DEL压根没机会执行,锁key会永远留在Redis里,后续所有请求都会卡死在等待锁这一步。可以说,没有过期时间的锁,是一次性锁,这个说法一点不夸张。

2.2 过期时间引入,又带来了原子性难题

既然锁可能不被主动释放,那就在写锁的时候同时设置一个过期时间,让Redis在锁超时后自动清理。于是出现了第二种常见写法:

127.0.0.1:6379> SETNX lock_order 1 127.0.0.1:6379> EXPIRE lock_order 30

先执行SETNX,再执行EXPIRE。这两条命令之间有间隔,不是原子操作。如果SETNX执行成功之后,进程突然崩溃,EXPIRE还没来得及执行,锁依然会永久存在,问题根本没有真正解决。

Redis在2.6.12版本之后提供了组合命令,才真正意义上解决了这个原子性问题:

127.0.0.1:6379> SET lock_order 1 NX EX 30

NX表示只有key不存在时才写入,EX 30表示过期时间为30秒。一条命令搞定加锁和过期,这是目前能见到的最基础的教科书式写法。

这里有一个实际经验要分享:过期时间到底设多长,需要认真评估。设得太长,比如10分钟,万一持有锁的进程真的异常退出,其他进程要等很久才能重新抢到锁,业务会长时间阻塞;设得太短,比如2秒,业务逻辑稍微慢一点,锁就自动过期了,其他进程就能趁虚而入,直接导致锁失效。我的建议是,先统计一下业务逻辑的正常耗时峰值,再乘以2到3的系数,宁可略长,也不要频繁提前过期,配合看门狗机制(后面会讲)来做动态续期。

2.3 删除锁必须做身份校验,用Lua保证原子性

过期时间解决了"锁永久不释放"的问题,但又暴露了一个新问题:锁误删。

假设线程A拿到锁,执行时间超过了锁的过期时间,锁自动过期。线程B此时拿到锁开始执行业务。A线程终于结束,它执行DEL把锁删了,B线程的锁就这样被A误删了。之后第三个线程C又能拿到锁,A、B、C三个线程同时执行了本应该互斥的业务。

解决思路是:锁的value不要存一个固定值,而是存一个唯一标识,比如UUID或者客户端实例ID。删除锁之前,先判断value是不是自己的,确认是自己的锁才删除。

但是,判断value和删除锁,如果分两步执行,中间依然有竞态窗口。你判断完value等于自己的标识,还没来得及DEL,锁刚好过期了,B线程加了新锁,你的DEL把B的锁删了。所以判断和删除必须合并成一个原子操作。Redis官方推荐用Lua脚本:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

这段脚本的执行过程是原子性的,因为Redis服务器会单线程完整执行整个脚本,中间不会插入其他命令。到这里,单机Redis锁的基本形态才算真正成型:SET key value NX EX加锁,value存唯一标识,Lua脚本解锁,每个环节都闭环了。

3. 主从架构下的锁失效:从单机走向高可用

3.1 单点Redis的风险与主从引入

单机版锁能用,但Redis本身是单点,一旦宕机,所有依赖锁的业务全部停摆。为了高可用,常规做法是引入主从复制,主库负责写,从库负责同步数据,主库挂掉后可以通过哨兵机制将从库提升为新主库。

这里的核心问题是:Redis主从复制默认是异步的。主库收到写命令后,会先返回给客户端,然后再异步把数据同步给从库。这个"先返回再同步"的窗口,就是分布式锁的命门。

3.2 一个能复现的锁丢失现场

我构造过一个非常典型的故障重现过程,步骤如下:

  1. 客户端A向主库执行SET lock_order A NX EX 30,成功拿到锁。
  2. 主库还没来得及把这条写命令同步给从库,主库突然宕机。
  3. 哨兵监控到主库不可用,触发故障转移,把某个从库提升为新的主库。
  4. 因为锁key根本没同步到从库,新主库上不存在lock_order这个key。
  5. 客户端B向新主库执行SET lock_order B NX EX 30,直接成功。

最终结果:客户端A和客户端B同时都认为自己在持锁,分布式锁的互斥语义彻底失效。线上如果出现这种情况,两个定时任务会同时跑,两个扣库存请求会同时处理,产生数据不一致。

为什么会出现这个问题?根因在于Redis的高可用设计本质上偏向AP:它优先保证可用性和分区容错性,复制过程中不做强一致同步。理论上需要引入类似共识算法的复制机制,或者对锁数据做特殊处理才能在故障转移时保住锁语义,但原生Redis并没有内置这套逻辑。

3.3 哨兵模式改变不了的现实

网上很多人说,用了主从加哨兵就能保证分布式锁的可靠性,这个说法其实是不严谨的。哨兵只做两件事:监控Redis节点的健康状态,发生故障时自动执行主从切换。它完全不关心你存的数据是不是锁,更不会在切换时去补偿那些没来得及复制的锁key。

换句话说,普通主从架构只能提高锁组件的可用性,不能提高锁语义的一致性。如果业务可以接受小概率的双执行,可以继续用;如果业务要求严格互斥,就需要在锁的方案层面做升级了。

4. 集群时代的方案演进:RedLock与争议

4.1 RedLock的基本流程

既然单个Redis节点在主从切换时会丢锁,那能不能同时使用多个相互独立的Redis节点来做锁?这就是RedLock方案的出发点,Redis作者Salvatore Sanfilippo提出了这个思路。注意,这里的"集群"不是Redis Cluster模式,而是多个完全独立的Redis实例,互相之间没有任何主从、复制关系。

RedLock的加锁流程大概是这样:

  1. 假设有5个独立Redis节点,客户端生成一个唯一锁value。
  2. 客户端按顺序向这5个节点都执行SET lock_key unique_value NX EX,每个节点都设置一个较短的超时时间(比如10毫秒),避免某个节点不可用时一直阻塞。
  3. 统计成功写入的节点数,只有成功节点数超过N/2 + 1个(5节点就是至少3个),即达到多数派,并且整体加锁时间小于锁的过期时间,才算加锁成功。
  4. 如果没达到多数派,或者加锁过程中耗时太长,就向所有已经写入成功的节点发送Lua解锁脚本,回滚锁。

这里面的几个为什么值得多说一句。为什么需要多数派?多数派解决的是"两个客户端同时抢锁"的冲突问题。在5个节点中,两个客户端如果都尝试加锁,最坏情况下,能把锁成功写到同一个节点的概率极低。多数派的数学约束保证了任何时刻最多只有一个客户端能够拿到超过半数的节点锁,这个思路和Raft、Paxos这些共识算法里的多数派思想是一致的。

为什么加锁期间还要限制总耗时?因为如果客户端在加锁过程中网络很慢,5个节点逐个写下来已经花了很长时间,某些节点上的锁可能已经因为超时被Redis自动删除了。这时候即使你统计到3个节点写入成功,也不能代表你真正持有了锁,因为部分锁可能已经过期。所以RedLock要求"写入节点数超过半数且总耗时小于锁过期时间",两个条件缺一不可。

4.2 RedLock能防住什么

比起单机加主从的方案,RedLock至少解决了这几类问题:

  • 单个节点宕机:5个节点挂掉1个,还剩4个可用,只要写入成功3个就能拿到锁,不受影响。
  • 主从切换导致锁丢失:因为5个节点互相独立,不存在异步复制的关联,某个节点上数据丢了不代表其他节点也丢了。只要超过半数的节点锁还在,锁语义就还能维持。
  • 单节点网络隔离:即使某个节点不可达,不计算它的投票就行,多数派依然有可能成立。

在实际部署时有一点非常关键:这些独立节点必须做到真正的"物理隔离"。我见过有人把所谓的5个节点部署在同一台物理机的5个端口上,这没有任何意义,机器停电直接全挂,RedLock的容错能力瞬间归零。至少要分散到不同机柜、不同交换机,有条件就不同机房,否则就是在自欺欺人。

4.3 RedLock的争议:时钟、GC与锁过期

RedLock自提出以来,围绕它的争论就没停过。最有名的是Martin Kleppmann发布的一篇《How to do distributed locking》,核心质疑有这么几点:

第一,锁过期时间依赖系统时钟。Redis判断key是否过期是看服务器本地时钟的,如果服务器时钟发生跳跃或回拨,锁可能被提前释放或延迟释放。虽然可以使用相对时间参数来缓解,但"时间"这个变量本身就不可靠。

第二,客户端GC停顿。即使锁没过期,如果持有锁的进程发生了一次长达数秒的GC停摆,它自己感知不到时间流逝,等GC恢复后可能还在继续执行临界区代码。而这时锁在Redis那边早就过期了,被其他客户端拿到,于是两个客户端同时执行临界区,RedLock也挡不住这种情况。

第三,网络延迟。从客户端发出命令到Redis真正处理,中间有网络耗时,而锁的过期时间是从Redis处理命令那一刻开始计算的。极端情况下,客户端以为刚拿到锁,其实锁在传输途中已经消耗了大量时间,可能马上就会过期。

我对RedLock的态度一直比较务实:它比单节点锁可靠,但远没有到"绝对一致"的程度。如果业务对互斥性要求极其严格,比如资金结算、订单号生成,不要单纯依赖RedLock,必须在业务层做幂等兜底,或者直接换用ZooKeeper/etcd这类基于共识协议的锁服务。如果你的业务是秒杀、优惠券、库存扣减这类可以容忍极小概率重复执行的场景,RedLock在工程上投入产出比反而很高。

5. 生产环境选型与实战心得

5.1 锁的可靠性分级,先定位你的业务需求

我在实际项目里会把分布式锁按可靠性分成四个等级,先对照业务自身的一致性要求再选方案,这一步省了我很多熬夜排查的经历。

方案优点缺点适用场景
单节点Redis锁实现简单,性能高单点故障,Redis挂掉全部不可用测试环境、内部小系统、可接受停机
主从+哨兵Redis锁可用性高,故障自动切换切换窗口可能丢锁大多数互联网业务,重复执行可接受
RedLock多节点互备,容错更强部署成本高,仍有时间窗口风险跨机房、对一致性要求较高的业务
ZooKeeper/etcd锁强一致,锁语义可靠性能比Redis低,运维复杂资金链路、订单核心、不能容忍重复执行

这里有个容易搞混的地方:Redis Cluster(集群模式)并不原生提供分布式锁。Cluster主要解决数据分片与高可用问题,它在节点故障时也会发生主从切换,一样存在锁key丢失的风险。有些人以为部署了Cluster就能摆脱分布式锁的可靠性担忧,这是误解。

5.2 工程落地:锁接口、看门狗与Lua脚本

如果项目允许引入第三方库,我会首选Redisson,它内部已经实现了看门狗续期机制,不需要自己造轮子。但如果你的团队更偏好自研轻量分布式锁,或者想彻底理解原理,下面这套实现思路值得参考。

锁的抽象接口可以设计成三个方法:

public interface DistributedLock { boolean tryLock(String key, String clientId, long timeoutMillis); void unlock(String key, String clientId); boolean renew(String key, String clientId, long newExpireMillis); }

tryLock底层对应SET key clientId NX EX,unlock对应那段判断clientId再删除的Lua脚本。关键在看门狗逻辑:拿到锁之后,启动一个定时任务,每过期时间的1/3时长执行一次renew操作,把锁的过期时间往后推。举个例子,锁初始过期时间是30秒,定时器每10秒续一次期,只要业务线程还活着,锁就一直不会过期。业务结束后主动停掉续期任务并删除锁。这个机制能解决"业务执行时间长于锁过期时间"的经典问题。

还有一个细节是锁的可重入性。如果同一线程重复获取同一把锁,简单做法是加锁成功后返回成功,但要把获得锁的次数记录下来。Redisson用的是Hash结构,field存线程唯一标识,value存重入计数。这样做只是为了兼容重入场景,实际业务里我会尽量不让代码出现多层嵌套调锁的情况,因为可重入容易掩盖锁设计不合理的问题。

锁key的命名也要讲究。我见过很多线上事故,直接把业务名加一个固定的字符串当锁key,把所有用户、所有订单全部锁到一个key上,并发全部串行化,性能惨不忍睹。正确的做法是key包含业务维度的唯一标识,比如order:lock:{orderId},把锁粒度缩小到具体订单,并发能力会提升几个量级。

5.3 常见问题速查表与避坑经验

实际运维中遇到的问题,我整理成了一个速查表,基本上覆盖了90%的情况:

现象可能原因排查与解决思路
锁一直获取不到过期时间过长,持有锁的节点未正常释放检查是否忘了finally中释放锁;缩短过期时间,尽快释放
拿到锁后业务并发执行锁过期时间太短,业务未结束锁就消失了引入看门狗续期,或者设置合理的过期时间
主从切换后多个客户端同时持锁锁key未同步到新主库改用RedLock,或接受小概率并发并在业务做幂等
删除锁时报错或误删value未用唯一ID,或判断和删除未原子化统一用Lua脚本判断加删除
同一个key被大量请求排队锁粒度太粗,所有请求竞争同一把锁设计业务维度unique key,缩小锁范围
锁生效但数据还是不一致锁保护的范围不够,或者业务中嵌套了异步逻辑检查临界区代码,确认锁保护了完整的读改写过程

还有一个经常被忽略的坑:不要在finally块中无条件删除锁。如果业务代码执行过程中,锁已经因为超时被自动释放了,其他线程已经拿锁改了数据,你的finally里的Lua判断会发现value不是自己的而删除失败,这是好事。但如果你用了Redisson的看门狗,它会在finally中直接调用unlock,而忽略锁可能已经过期的事实。这就需要在删除前再判断一下自己是否还持有锁,Redisson其实内部做了isHeldByCurrentThread的判断,自己实现的时候不要漏掉。

另外,事务类操作尤其要小心。我建议不要让分布式锁跨数据库事务执行。锁的范围越大、持有时间越长,出现超时、续期失败、GC停顿的风险就越高。尽量把锁拆小,只锁必要的关键步骤,不要让锁成为系统的瓶颈。

6. 分布式锁面试高频考察点与扩展思考

6.1 面试官最喜欢问的几个问题

这些年我面试别人时,也常拿分布式锁当切入口,因为它能快速检验一个人对分布式系统的理解深度。高频问题基本围绕下面这几点展开:

  • 为什么synchronized不能用于分布式环境?答到"JVM进程内锁、跨进程无效"是及格,能顺带讲出"多实例部署、线程不共享内存"才是加分。
  • SETNX加锁有什么问题?答案是死锁风险,但能补充"无过期时间、崩溃后锁永久存在"会更完整。
  • 为什么两种命令组合要用Lua脚本?核心是原子性,还要提到"Redis单线程执行脚本"这个机制。
  • 主从切换时锁丢失的根因是什么?异步复制,重点在于讲清楚主库写入成功但数据未同步就发生切换的路径。
  • RedLock的多数派算法为什么是超过N/2?因为两个客户端不可能同时获得多数派,这和Raft的选举机制相似。
  • 业务执行时间超过锁过期时间怎么办?答案不是盲目调大过期时间,而是讲看门狗续期。
  • Redis锁和ZooKeeper锁的对比?Redis是AP倾向、性能高、主从切换可能丢锁;ZooKeeper是CP、强一致、性能相对低。

面试时如果能把每个问题后面的"为什么"讲透,而不是只背结论,基本就能过。比如光知道"用Lua脚本解锁"还不够,要能画出来为什么判断加删除两条命令有竞态窗口。

6.2 从Redis锁到共识系统锁

最后聊一下什么时候该放弃Redis锁。我的经验是:业务一旦涉及资金、账务、订单状态机这类绝对不能被重复执行的场景,就不要在Redis锁上做过多纠结了。ZooKeeper的临时顺序节点具备强一致特性,节点间通过Zab协议保持同步,加锁失败时通过监听机制等待,适合低频高可靠的场景。etcd则提供了带租约的分布式锁原语,租约可以续期,配合MVCC版本号,能实现更细粒度的控制。

但这不代表Redis锁没有价值。在大多数互联网业务里,Redis锁的性能优势非常明显,单次加锁耗时为微秒级,而且通过合理设计可以极大降低重复执行概率。关键在于业务层要做兜底设计,比如数据库唯一索引、版本号乐观锁、消息消费幂等,这样即使分布式锁出现了极小概率的失效,也不会造成数据灾难。分布式锁永远只是保障手段,不是最终防线。

最后分享一点个人体会

踩过几次坑之后,我现在的习惯是:拿到需求先问业务场景,再决定要不要上锁、用哪种锁。能通过幂等设计解决的,尽量不引入锁;必须要用锁的,优先选Redisson,配置好合理的过期时间和看门狗;涉及资金级别的强一致,直接考虑etcd或者ZooKeeper,不给自己留侥幸空间。分布式锁这条路,从单机到集群,每走一步都在用代价换可靠性。你不需要掌握所有方案,但一定要清楚自己当前这个方案在什么条件下会失效,并提前做好兜底。能在面试时把这条演进逻辑顺下来,同时说出每个环节的真实风险,就已经超过了大多数人。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询