1. 从一把Redis锁说起:先搞清楚为什么需要分布式锁
1.1 哪些场景真的离不开分布式锁
我最早接触分布式锁,是在一个秒杀项目里。多台后端实例同时处理同一个商品的扣减请求,刚开始用synchronized,上线第二天就发现超卖,老板脸上阴云密布。后来调出日志一看,锁只锁住了单台机器的线程,另外两台服务器该抢照抢。那一刻我才真正理解,单机锁和分布式锁之间隔着的不是代码量,而是对"多节点共享资源互斥"的理解。
分布式锁解决的核心问题,就是让分布在多个进程、多台机器上的业务逻辑,在操作共享资源时保持互斥。典型场景包括:库存扣减、防止缓存击穿、分布式定时任务调度(多实例只允许一台执行)、幂等控制(防止用户重复提交)、跨服务转账等。判断一个场景是否真的需要分布式锁,我有个土办法:加锁的代码同一时刻是否会被多个JVM实例执行,是,就需要;只是单机并发,那就别为了"看起来高级"引入分布式锁,徒增运维负担。
很多读者刚接触分布式锁时,会不自觉把它和数据库唯一索引、消息队列顺序消费混为一谈。实际上它们解决的问题层次不同:唯一索引解决的是"写入不重复",消息队列顺序消费解决的是"同一个分片内有序",而分布式锁解决的是"临界区互斥访问"。搞清楚这句话,后续选型就不容易跑偏。
1.2 单节点锁与分布式锁的本质区别
单节点锁(比如JVM的ReentrantLock)依赖内存中的状态标记,同一个进程内的线程可以通过共享内存感知锁的持有与释放。但一旦跨进程,内存状态无法同步,必须借助一个所有节点都能访问的第三方组件来"备案",这个组件就是锁的协调者。常见的有Redis、ZooKeeper、etcd、数据库,各有各的脾气。
Redisson选择Redis做协调者,是因为Redis本身性能极高,单线程模型天然串行化命令,非常适合做互斥判断;再加上Redis的过期时间机制能够兜底死锁,让"锁自动释放"成为可能。相比ZooKeeper的临时节点(需要长连接和心跳维持),Redis的实现更轻量,在绝大多数并发不超过万级的业务场景里,性能表现已经绰绰有余。
不过,Redis分布式锁也有先天短板:如果Redis发生主从切换,锁信息可能丢失,导致锁失效。这也是后面红锁(RedLock)被讨论的根源。我平时在线上项目中,会根据业务容忍度决定是否要上多节点方案,而不是一味追求"最安全"。
1.3 一把合格的分布式锁必须满足的条件
我面试别人或者被面试的时候,经常被问到一个问题:什么是一把"好"的分布式锁?标准答案一般包含四点,我用自己的话复述一遍。
互斥性:任意时刻只有一个客户端能持有锁,这是底线。安全性:锁必须设过期时间,防止持锁者宕机导致死锁,也就是"锁不能永远不释放"。可用性:加锁和解锁要尽量快,不能因为锁服务本身的瓶颈拖垮业务。容错性:部分节点故障时,锁服务仍然能正常工作。
此外,我还会补充两个容易被忽视的细节:一是锁要支持"可重入",因为业务代码里经常出现方法嵌套调用,如果每次重入都重新抢锁,百分百死锁。Redisson的锁天然支持重入,这也是它比裸写Redis SETNX更省心的原因之一。二是加锁和解锁必须有客户端标识校验,防止A线程把B线程的锁误删,这其实是手写实现时最容易踩的坑,下文会细讲。
2. 手写Redis分布式锁:从踩坑到极限
2.1 SETNX加锁的正确姿势
很多人第一次实现分布式锁,都是从SETNX开始的。SETNX的作用是"如果key不存在才写入",完美契合"抢锁"语义。但早期的实现有一个经典错误:单独用SETNX加锁,再用EXPIRE设置过期时间,两步之间非原子。如果第二步失败,Redis里就会留下一个永远不过期的锁,直接击穿所有兜底机制。
正确做法是使用一条命令完成加锁和过期设置:
SET lock_key unique_value NX EX 30其中NX表示只有key不存在时才能写入,EX 30表示锁自动过期时间为30秒,unique_value是客户端生成唯一标识,用来在后面解锁时证明"锁是我的"。如果写成两条命令,一旦客户端在两步之间宕机,锁就变成了永久锁。这个优化只需要改一行命令,却能避开最大的死锁隐患,属于典型的"用知识换事故"。
2.2 过期时间与误删锁的经典陷阱
加锁解决了,解锁又是个坑。我先展示一段错误代码,读者可以自查有没有写过类似的:
// 错误示例 if (redis.call('exists', key) == 1) { redis.call('del', key); }这个实现有个致命漏洞:当锁到了过期时间自动释放后,另一个线程B成功抢到了锁。此时线程A业务还没执行完,它看到key存在,直接删除,等于把B的锁解了,B的业务接着跑,临界区瞬间变成两个线程同时进入。这就是误删锁问题。
解决办法是"先校验,后删除",而且两步必须原子执行。Redis中的Lua脚本是最佳载体:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end脚本先比较unique_value,确认锁的持有者是自己才删除。我把这个脚本封装成一个工具方法,工具类里还包含加锁和重试逻辑,整体稳定跑了大半年没出过问题。手写锁不是不能用,而是需要你足够细心,把原子性、阻塞等待、重入、续期、容错全部自己处理一遍。
2.3 Lua脚本与tryLock的重试机制
如果业务抢不到锁,我希望它等待而不是直接失败,这就需要重试机制。手写实现时,可以在循环里反复尝试加锁,同时设置最大等待时间,超时后返回失败。核心逻辑类似:
public boolean tryLock(String key, String value, long waitTime, long leaseTime) { long start = System.currentTimeMillis(); while ((System.currentTimeMillis() - start) < waitTime) { if (SET(key, value, "NX", "EX", leaseTime)) { return true; } Thread.sleep(50); } return false; }这个实现有几个隐蔽问题:sleep间隔太短Redis压力大,太长等待时间不可控;锁过期时间没法动态续期,业务处理超过leaseTime时锁已经自动释放,后面的线程会提前进入临界区。想到这里,我不禁感叹,这才是很多人转向Redisson的真正原因——不是手写不了,而是手写的锁在真实业务场景里,要么重试策略粗糙,要么生命周期管理不完善,始终差一口气。
3. Redisson正式入场:一个库搞定锁的续航和重入
3.1 Redisson到底是什么,为什么够格当主角
Redisson是一个基于Redis的Java客户端,但它又不止是客户端。它把分布式锁、分布式集合、消息队列、布隆过滤器、限流器等常用的分布式组件全部封装成了开箱即用的Java API。你可以把它理解成"Redis界的Spring Boot",把原本需要手写的大量样板代码,内化成一行API调用。
在分布式锁领域,Redisson最大的贡献是帮你解决了两个手写实现最容易翻车的问题。第一,可重入锁的计数管理,同一个线程重复获取锁时,锁的计数器自动加一,释放时减一,直到归零才真正删除key。第二,锁的自动续期,通过后台定时任务为未释放的锁持续刷新过期时间,避免业务没执行完、锁先到期的问题。
另外,Redisson还提供了多种锁类型:可重入锁、公平锁、读写锁、联锁、红锁、信号量、闭锁。这些锁对应不同业务场景,组合起来就是一个完整的分布式锁工具箱。我在下文会逐个拆解,并给出可以拿去用的代码片段和配置说明。
3.2 WatchDog续期机制:锁为什么不会提前失效
我刚用Redisson时,对它的"看门狗"机制特别感兴趣。默认情况下,调用lock()方法,Redisson会为锁设置一个30秒的leaseTime,但这不是写死30秒后必然释放。如果锁还没有释放,Redisson会通过一个后台定时任务,每过三分之一的锁有效期(也就是默认每10秒),就自动把锁的过期时间重置为30秒。这个后台任务就是"看门狗"(WatchDog)。
为什么需要续期?因为业务代码的执行时间是不可预测的。网购高峰期,一次库存扣减可能因为下游接口慢,从几百毫秒拖到几秒甚至几十秒。如果锁的过期时间写死,长尾请求会拿着一个已经失效的锁继续操作,互斥性就被破坏了。WatchDog解决的正是"锁生命周期跟不上业务生命周期"的问题。
需要注意,tryLock和lock的默认续期行为不同。lock()方法默认启用WatchDog续期,只要锁没显式解锁就一直续;而tryLock(long waitTime, long leaseTime, TimeUnit unit)如果传了leaseTime,则不会续期,到期自动释放。用tryLock传leaseTime的语义是"业务方自己预估最大执行时间,超时锁就释放",这是一个精细但很容易忽略的设计。我的习惯是:能够确认最大执行时间的场景,显式传leaseTime并关闭续期,避免后台线程空转;无法确认执行时间的场景,用默认的看门狗续期机制兜底。
3.3 可重入锁的正确用法
Redisson最常用的锁是可重入锁,对应接口RLock,它天然支持同一个线程的重复获取。下面是一个标准的用法,我把它作为团队内部的基础模板:
@Autowired private RedissonClient redissonClient; public void deductStock(Long productId, Integer count) { String lockKey = "stock:product:" + productId; RLock lock = redissonClient.getLock(lockKey); try { // 等待最多2秒,拿到锁后30秒自动过期(不续期) boolean locked = lock.tryLock(2, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException("系统繁忙,请稍后重试"); } // 业务操作:检查库存、扣减库存 doDeduct(productId, count); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException("抢锁过程被中断"); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这段代码有几个关键点:tryLock会阻塞等待最多2秒,获取锁失败时不至于直接报错;isHeldByCurrentThread用来防止业务未抢到锁却执行了unlock,避免解掉别人的锁;finally里释放锁是铁的纪律,无论业务成功还是异常,都必须释放。可重入性还意味着同一个线程在A方法里拿到锁后,调用B方法时可以再次获取同一个key的锁,计数器加一,不会死锁,这让服务链路中的嵌套调用非常从容。
4. 多样化锁逐个拆解:不同场景用不同的锁
4.1 公平锁:排队比抢购重要
Redisson的公平锁(FairLock)会保证多个客户端获取锁的顺序和请求顺序一致,类似于"先到先得"。默认的可重入锁是非公平的,多个线程抢锁时,谁先抢到谁先得,后来的线程可能出现"饿死"或长时间等待。需要严格控制顺序的场景,比如给用户发放稀缺权益、按工单顺序处理任务,使用公平锁更符合直觉。
使用方式和普通锁几乎没有区别:
RLock fairLock = redissonClient.getFairLock("queue:order:" + orderId); try { fairLock.lock(5, TimeUnit.SECONDS); processOrder(orderId); } finally { fairLock.unlock(); }公平锁的内部实现是基于Redis的ZSet(有序集合),请求线程会先写入一个等待队列,然后按顺序从队首取出锁。代价是性能比非公平锁要低,因为每次加锁解锁都要维护有序集合。我在实际项目里只在需要严格顺序的场景使用公平锁,绝大多数秒杀、扣库存场景用不上它,没必要为了"看起来公平"牺牲吞吐。
4.2 读写锁:读多写少场景的利器
读写锁(ReadWriteLock)提供了两把锁:读锁和写锁。读锁之间可以共享,写锁和任何锁都互斥。这种"多读单写"的模型非常适合缓存刷新、配置中心、热点数据的低频更新高频读取场景。
Redisson的RReadWriteLock用法和Java原生读写锁非常像:
RReadWriteLock rwLock = redissonClient.getReadWriteLock("cache:product:" + productId); RLock readLock = rwLock.readLock(); RLock writeLock = rwLock.writeLock(); // 读操作 readLock.lock(); try { cache.get(productId); } finally { readLock.unlock(); } // 写操作 writeLock.lock(); try { cache.put(productId, data); } finally { writeLock.unlock(); }这里需要强调一个语义:读写锁并不是"无脑提升并发",它的收益只体现在读多写少的场景。如果写操作频繁,读锁和写锁互相排斥,性能可能比单一可重入锁更差。我踩过的一个坑,系统里配置了读写锁,但是写操作占了一半比例,结果热点数据的吞吐反而下降明显。后来把写频率高的数据改成可重入锁,读多写少的配置缓存用读写锁,效果立刻正常了。锁的选型不是越复杂越好,永远从业务读写比例出发。
4.3 联锁与红锁:多节点协同的极限挑战
如果业务需要同时获取多把锁才能进入临界区,可以使用联锁(MultiLock)。Redisson的MultiLock允许一次锁定多个RLock,只有所有这些锁都获取成功,才算加锁成功。比如跨库存中心扣减多个仓库的库存,如果每个仓库有独立的Redis,联锁能保证操作的原子性。
红锁(RedLock)则更进一步,它要求客户端在多个独立的Redis节点上分别加锁,只有在大多数节点(比如五个节点中的至少三个)加锁成功,才认为锁已持有。这个方案能有效应对单个Redis节点宕机导致的锁丢失问题,代价是部署成本高、性能下降,而且实现复杂。Redisson提供了现成的RedissonMultiLock,可以组合多个节点的锁。
我用红锁的经验有限,因为它引入的运维复杂度不是所有团队都能承受。大多数场景下,单集群Redis加上合理的锁超时设置已经足够。如果真有极高的一致性要求,我更倾向于直接用ZooKeeper或者etcd的分布式锁,而不是在Redis上强行玩红锁——这不是技术选型偏好问题,而是"用合适工具解决合适问题"的基本判断。联锁和红锁的编码方式类似,构建多个锁对象后交给MultiLock统一管理即可,重点在于节点的独立性和部署配置,这里不展开裁开。
4.4 信号量、闭锁与单机版的一个真实使用案例
Redisson不只有"互斥锁",还有信号量(Semaphore)和闭锁(CountDownLatch),这两个特别适合做流量控制和多节点任务编排。
信号量用来限制访问某个资源的并发数量,比如限制某个接口的瞬时并发为100。它和线程池里的信号量的区别在于,这里的计数是全局的,多台机器共享同一个最大许可数。高并发场景下,可以用来做"平滑限流"的兜底:
RSemaphore semaphore = redissonClient.getSemaphore("gate:promotion"); semaphore.trySetPermits(100); if (semaphore.tryAcquire()) { try { handleRequest(); } finally { semaphore.release(); } }闭锁RCountDownLatch则常用于等待多个分布式任务全部完成后再执行后续逻辑。典型场景是大数据量批量处理后,各分片节点处理完自己的数据,计数减一,主节点直到计数归零才继续。它和Java的CountDownLatch思路一致,只是把等待逻辑从单机内存扩展到了多个实例。
我印象最深的真实案例是一个活动页,多个服务分别负责生成不同的活动数据,全部完成后才允许用户进入页面。用RCountDownLatch等待三个数据源就绪,超时时间为5秒,实测下来非常顺畅。分布式锁的价值不止"互斥",还体现在"协调多节点完成整体任务"上,这也是"多样化锁"这个标题想强调的核心。
5. 实战中踩过的坑,整理成问题排查速查表
5.1 锁和事务一起吃,锁为何突然失效
很多人在Spring项目里同时使用@Transactional和Redis锁,发现锁并不能有效阻止并发问题。原因在于事务的提交时机和锁的释放时机不一致。如果一个方法带有@Transactional,方法执行完不代表事务提交完成,而是等事务代理提交后,数据库操作才真正落盘。假如锁先释放,另一个线程立刻进入,它读到的可能是尚未提交的旧数据,脏读、超卖随之而来。
正确的做法是,把锁的范围扩大到事务提交之后,或者把数据库操作拆成两个方法,让锁在事务外层加,提交完再解锁。我在团队里定的规范是:加锁的逻辑和事务逻辑分离,锁放在Controller或者Service入口层,事务放在内部独立方法,保证"锁的释放晚于事务提交"。这个坑极其隐蔽,线上排查时,日志里锁都正常加、正常释放,但数据就是错了,新手往往无从下手,最后发现是事务边界在搞鬼。
5.2 锁的粒度、超时时间和WatchDog的取舍
锁粒度过大是性能杀手。比如把同一个订单下的所有子商品都用一把锁保护,互斥范围被过度放大,本来没有竞争关系的操作也被串行化,吞吐自然上不去。我的做法是尽量按业务维度拆分key,比如商品库存按skuId加锁,订单状态按orderId加锁,用户资产按userId加锁,让锁尽量贴近被保护的资源。
超时时间的设置也要结合业务实际。锁的过期时间太长,宕机后恢复时间长;太短,在高峰期业务没执行完锁就自动释放,临界区被打开。我的经验值通常设置为基础业务耗时的3到5倍,并在压测环境中验证。使用看门狗续期时,则要小心后台任务本身的开销,频繁刷新过期时间会产生大量Redis写命令,尤其是锁数量大的时候,需要关注Redis的CPU和网络指标。
加锁失败的策略同样重要。直接抛异常虽然简单,但用户体验差;无限等待又会拖垮调用方。我一般设置等待时间waitTime为1到3秒,抢锁失败返回"系统繁忙",同时记录WARN日志,靠报警平台监测失败率。分布式锁不是"不让你失败",而是"让失败快速、可控、可观测"。
5.3 Redis主从切换和锁丢失的争议
这部分想聊一个绕不开的话题:Redis发生主从切换时,刚刚写入的锁key可能还没同步到从节点,主节点故障后从节点顶上,锁信息丢失,另一个客户端趁虚而入成功加锁,原有锁的互斥性被打破。
严格意义上,这是Redis分布式锁的先天缺陷。Redisson提供了RedLock来解决,但代价巨大。我的建议是:先把业务对锁丢失的容忍度量化。如果涉及资金安全或强一致场景(比如支付、转账),我的态度是宁可使用etcd或ZooKeeper,也不装红锁自欺欺人;如果是典型的互联网业务如秒杀、活动领券,锁丢失造成的损失只是偶发超量发放,通过事后对账和补偿可以兜住,Redis锁完全够用,还要什么自行车。
这里送读者一句我认为最重要的判断标准:分布式锁是"以可用性换一致性"还是"以一致性换可用性",要根据业务对数据错误的可修复程度来定。没有银弹,只有取舍。
5.4 高频面试题视角:如何把锁讲清楚
因为"分布式锁面试题"经常被搜,我顺带整理几个高频问题的回答思路。
面试官问"Redis分布式锁为什么用Lua脚本",本质是考你原子性意识。加锁的SET NX EX是原子命令,解锁的"校验+删除"必须依靠Lua脚本的原子执行,否则就有误删锁和超时删除的问题。
问"Redisson的看门狗原理是什么",重点是要说出默认leaseTime 30秒、每10秒续期一次的逻辑,并引申到业务执行时间和锁生命周期的匹配问题。
问"你为什么不直接用SETNX"。参考答案是:手写SETNX要自行处理可重入、续期、重试、死锁恢复、公平排队等问题,而Redisson把这些成熟方案都封装好,经过大量生产环境验证。面试时能顺手补一个自己实际踩过的坑,比如误删锁和事务边界导致锁失效,会比背概念更有说服力。
我见过不少候选人把分布式锁的实现细节背得滚瓜烂熟,但问到他"锁丢失怎么办""如何看待主从切换"时就开始含糊。其实面试官真正想听的不是结论,而是你权衡方案的过程。能把利弊讲清楚,比给出一个绝对答案重要得多。
6. 我从这些实践中留下的个人心得
回头再看这个标题,"多样化锁实现"真正吸引人的不是API列表,而是每一把锁背后的适用场景和代价。Redisson带来的不仅是几分钟上手的便捷,更是一套经过设计的分布式协作思想:可重入锁保护临界区,公平锁维护顺序,读写锁优化读多写少,信号量控制并发额度,联锁和红锁处理多节点复杂性。理解这些,才算理解锁的本质。
我现在接手一个新系统,第一件事就是梳理哪些资源需要保护、竞争激烈程度如何、容忍容忍度多高,再去选锁类型。分布式锁不是越多越好,也不是越复杂越安全,它在大多数时候是"最后一道防线",而不是"第一件武器"。真正的稳妥,是把加锁范围做小、把超时做合理、把失败做优雅、把数据不一致的修复预案做扎实。好锁帮你不乱,但救不了烂的业务设计。
另外我还想多说一句,无论技术选型多成熟,都要保留足够的监控和可观测性。锁的等待时间、获取次数、释放是否及时、Redis内存是否异常增长,这些都是分布式锁健康度的核心指标。我在线上会给每类锁加上独立的metrics,一旦某个锁的等待耗时突然上升,就能提前发现热点和死锁苗头。技术方案落地之后,真正的功夫都藏在运维细节里。