ARTICLE DETAIL

资讯详情

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

Redis分布式锁从单机到集群:演进、坑点与选型实战

Redis分布式锁从单机到集群:演进、坑点与选型实战 做后端这几年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 30NX表示只有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 一个能复现的锁丢失现场我构造过一个非常典型的故障重现过程步骤如下客户端A向主库执行SET lock_order A NX EX 30成功拿到锁。主库还没来得及把这条写命令同步给从库主库突然宕机。哨兵监控到主库不可用触发故障转移把某个从库提升为新的主库。因为锁key根本没同步到从库新主库上不存在lock_order这个key。客户端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的加锁流程大概是这样假设有5个独立Redis节点客户端生成一个唯一锁value。客户端按顺序向这5个节点都执行SET lock_key unique_value NX EX每个节点都设置一个较短的超时时间比如10毫秒避免某个节点不可用时一直阻塞。统计成功写入的节点数只有成功节点数超过N/2 1个5节点就是至少3个即达到多数派并且整体加锁时间小于锁的过期时间才算加锁成功。如果没达到多数派或者加锁过程中耗时太长就向所有已经写入成功的节点发送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 EXunlock对应那段判断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不给自己留侥幸空间。分布式锁这条路从单机到集群每走一步都在用代价换可靠性。你不需要掌握所有方案但一定要清楚自己当前这个方案在什么条件下会失效并提前做好兜底。能在面试时把这条演进逻辑顺下来同时说出每个环节的真实风险就已经超过了大多数人。
返回列表