1. 一次库存超卖事故:从单机锁到分布式锁的必然
1.1 事故现场还原
先说我亲自经历过的一件事。某个商品秒杀活动压测阶段,库存就1000件,订单服务起了8个实例,压测跑到一半,数据库里库存变成负数了。查日志时发现,同一时刻有七八个请求同时在数据库里读到剩余库存是5,然后各自往下执行减1,最后库存被扣成了负数。当时第一反应是"入口明明加了synchronized啊",但冷静下来一想就明白了:synchronized锁的是当前Java虚拟机进程内部的对象监视器。8个实例就是8个互相隔离的JVM,每个JVM里的锁对其他JVM完全不可见。请求被负载均衡分发到不同实例,人人都以为只有自己进了临界区,于是同一份共享数据被多个进程同时改写,超卖自然不可避免。
这个案例的本质,是把"多线程竞争同一份资源"升级成了"多进程竞争同一份资源"。单机锁的边界就是进程边界,一旦系统拆成多实例,锁就必须找一个所有实例都能触达的第三方节点来做裁决。数据库行锁能做,ZooKeeper能做,Redis也能做。实际项目里选Redis的居多,因为性能好、接入成本低、运维也简单,而且大多数项目本身就有Redis基础设施,直接复用就行。
1.2 为什么JVM内锁撑不住集群环境
JVM内锁失效的关键,在于它的作用域被限定在"进程"这一层。
synchronized、ReentrantLock这些我们常用的锁,本质上是靠Java对象头里的锁状态、AQS队列这些进程内存结构来实现互斥的。单实例部署时,所有线程共享同一个JVM内存,锁自然有效。可一旦横向扩容,进程与进程之间连内存都不共享,AQS队列也是各自维护各自的,线程A在实例1上拿了锁,线程B在实例2上根本感知不到。这不是代码写错了,而是架构变了之后,原来的工具已经超出了它能管辖的边界。
所以在分布式架构里,我们需要一个"跨进程的临界区协议":所有实例在操作共享资源之前,先到同一个协调者那里登记"我占用资源",协调者保证同一时刻只有一个人登记成功。这个协调者必须满足两个硬性条件:一是所有实例网络可达,二是它自身要能原子地处理"检查-写入"这种复合操作。Redis的单线程命令模型天然适合做这件事,这也是分布式锁普遍基于Redis实现的根本原因。
1.3 分布式锁的典型应用场景
我在不同项目里见过的分布式锁应用,基本可以归成下面几类:
| 场景 | 锁粒度 | 典型问题 |
|---|---|---|
| 库存扣减 / 秒杀 | 商品维度 | 超卖、重复扣减 |
| 定时任务分布式调度 | 任务维度 | 多实例同时执行同一任务 |
| 缓存击穿回源 | 缓存key维度 | 大量请求同时打到数据库 |
| 唯一流水号/订单号生成 | 号段或业务维度 | 重复编号 |
| 接口幂等控制 | 用户/请求维度 | 重复提交、重复支付 |
| 共享资源修改(导入导出、配置文件) | 资源维度 | 相互覆盖、状态错乱 |
拿定时任务来说,我们之前有个结算任务,每天凌晨跑一次。单实例时用Spring的@Scheduled没问题,后来扩到多实例,任务就被每个实例各跑一遍,数据重复处理。简单做法是给任务加一个分布式锁,抢到锁的实例才执行,其他实例直接跳过。这类场景和库存扣减相比,对锁的实时性要求低一些,但对"绝对不能重复执行"要求反而更高。缓存击穿则是另一种典型:某个热点key失效瞬间,几百个请求同时发现缓存为空,全部涌向数据库。用分布式锁可以只让一个请求去查库并回填缓存,其余请求等锁释放后再读缓存即可。
场景不同,对锁的实现要求也不同:有的需要等待锁、有的需要快速失败、有的需要可重入、有的需要读写分离。这也是为什么Redisson把锁拆成多种形态,而不是一把锁打天下。
2. Redisson是什么:为什么选择它而不是手写SETNX
2.1 Redisson的定位
Redisson是一个基于Redis的Java客户端,但它做得远比"客户端"三个字丰富。除了基础的连接管理之外,它把Redis包装成了各种开箱即用的分布式服务:分布式锁、分布式集合、分布式对象、分布式消息队列、分布式限流器、分布式原子数等等。我最早接触它就是在做一个需要分布式锁的项目,当时第一反应是"用Redis setnx自己写不就行了",后来深入了解才发现,自己写看起来就几十行代码,真要写得正确、健壮,坑非常多,Redisson把这些坑都填平了。
命名上多说一句:项目名是Redisson,两个s,中间是isson。有些文章写成Redission,属于笔误,大家认准官方拼写即可。
Redisson的锁体系里,最常用的几个类是:RLock(普通可重入锁)、RedissonReadWriteLock(读写锁)、RedissonFairLock(公平锁)、RedissonMultiLock(联锁)和RedissonRedLock(红锁)。标题提到的读锁、写锁就来自读写锁,而可重入锁则是一切锁形态的基础。
2.2 与手写SETNX方案的对比
手写一个基于Redis的分布式锁,最朴素的版本是这样:
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, token, 30, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑 } finally { if (token.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } }这个版本有几个明显问题。
第一,锁在业务还没执行完的时候就到期了。setIfAbsent的过期时间是一个固定值,你没法预知业务到底跑多久,设置短了锁提前失效,设置长了进程宕机后锁迟迟不释放。
第二,释放锁的"判断持有者+删除"两步不是原子的。如果业务超时锁已失效,另一个线程抢到了同一把锁,这时前一个线程执行finally里的判断,发现key的value已经是别人的token就跳过删除,看似没问题,但在这两步之间如果发生GC停顿或网络抖动,依然可能误删别人的锁。
第三,不可重入。同一个线程在持有锁的代码里再次调用加同一个锁的方法,直接死锁。要支持重入,还得自己再去设计HashMap<Thread, Integer>之类的计数逻辑。
Redisson把这些事情全做了,而且是通过Lua脚本一次性原子执行。它用Hash结构存储锁计数,用线程标识做持有者区分,用看门狗自动续期,释放锁时通过一段Lua同时完成"校验持有者-扣减计数-删除key-通知等待者"的完整流程。对比下来,手写方案需要自己解决的边角问题,Redisson全都内建了。
2.3 Spring Boot工程里接入Redisson的完整步骤
第一步,引入依赖。这里要注意Redisson和Spring Boot的版本兼容,具体版本号建议以官方维护的版本对应关系为准。我的项目用的Spring Boot 2.x,引入的是redisson-spring-boot-starter,这种方式会自动读取Spring的Redis配置,少写大量代码。
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.23.4</version> </dependency>第二步,如果你不想用starter的自动配置,也可以用编程方式创建RedissonClient:
@Configuration public class RedissonConfig { @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setDatabase(0) .setConnectionPoolSize(20); return Redisson.create(config); } }注意Bean的destroyMethod="shutdown"很关键,它保证应用关闭时Redisson的连接池、线程池能被正常回收,否则在容器重启时会看到一堆连接泄漏告警。
第三步,使用分布式锁。来看一段最基础的可重入锁用法:
@Autowired private RedissonClient redissonClient; public void deductStock(Long productId, int count) { RLock lock = redissonClient.getLock("stock:lock:" + productId); lock.lock(); try { // 执行库存扣减SQL } finally { lock.unlock(); } }这段代码能跑通一个最基本的互斥场景,但离生产级还有距离,比如锁等待、超时兜底这些都要考虑。这部分我在后面的避坑章节展开。
3. 可重入锁原理拆解:Hash计数、Lua原子性与看门狗
3.1 可重入到底是什么意思
可重入(Reentrant)指的是:同一个线程在已经持有某把锁的情况下,再次去获取同一把锁,能够立刻成功,而不是把自己挡住。
为什么需要可重入?最典型的是方法套方法。假设limiter方法里加了锁,然后它内部调用的validate方法也加了同一把锁。如果锁不支持重入,validate就会等limiter释放锁,而limiter要等validate返回才释放,两边互相等待,妥妥的死锁。
RLock lock = redissonClient.getLock("order:lock"); public void submitOrder(Order order) { lock.lock(); try { validateOrder(order); // 这里又会加同一把锁 } finally { lock.unlock(); } } private void validateOrder(Order order) { lock.lock(); try { // 校验逻辑 } finally { lock.unlock(); } }ReentrantLock在JVM里的实现是给每个锁绑定一个持有线程和一个计数器,同一线程重复加锁计数器加一,解锁计数器减一,减到零才算真正释放。Redisson的分布式锁在Redis里复刻了这套语义。
3.2 Redis里的Hash结构与线程标识
Redisson可重入锁在Redis里不是用一个简单的字符串key,而是使用Hash结构。
锁的key就是你在getLock时传入的锁名称,比如"stock:lock:1001";Hash里的field是锁持有者的标识,格式是"UUID:线程ID";Hash里的value是重入次数。
为什么field要用"UUID:线程ID"?因为UUID标识的是RedissonClient客户端实例,线程ID标识的是这个实例里的具体线程,组合在一起才能全局唯一。比如实例A里线程42持有锁,Redis里存的field就是"b3f2...:42",value是1。如果这个线程再次加锁,Lua脚本发现同一个field已经存在,就把value加1变成2。另一个实例上的线程,field的前半部分UUID不同,永远无法和已有field匹配,自然无法重入。
这里特别说明一点,可重入的判定是"持有者标识一致",而不是"线程名一致"。跨实例之后,即使是同一个业务线程(比如同一个用户请求经过网关转发),只要是在不同JVM进程里执行,它们就是两个不同的锁持有者,是不能重入的。这也是分布式锁和单机可重入锁一个很重要的心智差异。
3.3 加锁解锁的Lua脚本逐行看
Redisson的可重入锁加锁逻辑,核心是一段Lua脚本,我用简化版本来分析:
-- KEYS[1]:锁名称key -- ARGV[1]:锁过期时间(毫秒) -- ARGV[2]:持有者标识(UUID:线程ID) if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);逻辑很直白:锁不存在,创建Hash并把持有者计数置1,设置过期时间;锁存在且当前持有者就是自己,计数加1并刷新过期时间;否则返回剩余过期时间,表示没抢到。
这段脚本是整体提交给Redis执行的。Redis在执行Lua脚本时不会穿插其他命令,所以"判断是否存在-写入计数-设置过期"三步天然是原子的。这也是手写SETNX做不到的。
解锁脚本对应如下:
-- KEYS[1]:锁名称key -- KEYS[2]:发布订阅channel名称 -- ARGV[1]:解锁消息 -- ARGV[2]:锁过期时间 -- ARGV[3]:持有者标识 if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil; end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end;解锁时先校验持有者,不是自己的锁直接返回nil;是自己的就把计数减一,没减到零就刷新过期时间继续持有;减到零就删除key,并发布一个消息通知那些正在等待这把锁的线程。整个过程同样原子。
3.4 看门狗机制:锁为什么不会莫名其妙消失
细心的读者会发现,锁的过期时间是跟着每次加锁/重入刷新的。那业务逻辑执行时间超过过期时间怎么办?Redis的key到期了,锁就没了,后面的人就能抢到,但前面的人业务还没做完。这是所有基于Redis的分布式锁最头疼的问题。
Redisson的解法是看门狗(Watchdog)。当你调用无参lock()方法时,Redisson会启动一个后台定时任务,默认锁的leaseTime是30秒。看门狗每隔10秒(leaseTime的三分之一)检查一次,如果锁还被当前线程持有,就执行Lua脚本把过期时间重新设置为30秒。这样只要线程活着且持锁,锁就一直续期,业务跑多久锁就在多久;一旦JVM宕机或线程异常终止,看门狗也跟着终止,锁在最多30秒后自动释放,不会死锁。
这里有个很容易被忽略的点:如果你调用lock()时显式指定了leaseTime,比如lock(10, TimeUnit.SECONDS),Redisson认为你明确知道锁要持有多久,就会自动关闭看门狗。也就是说,显式指定了过期时间的锁,过期之后真的就没了,业务没跑完锁也会消失。我在项目里就因为这个踩过坑,具体放避坑章节说。
那没有抢到锁的线程在干什么?Redisson不会盲目地高频重试,而是通过Redis的发布订阅机制阻塞等待。抢锁失败时它拿到锁的剩余存活时间,然后订阅锁释放的channel,在锁被删除的消息到达后唤醒,再发起下一轮抢锁。这样既避免了大量线程空转打爆Redis,也保证了锁释放后等待者能及时被唤醒。
4. 读锁与写锁:把互斥粒度还给业务
4.1 为什么普通互斥锁不够用
可重入锁解决的是"同一把锁的重入"问题,但它对所有访问者一视同仁:无论你是读数据还是写数据,都必须互斥。这在很多场景下太浪费了。
举个例子,某个配置服务,大部分请求是读取配置,只有极少数请求在更新配置。如果都用同一把互斥锁,所有读请求全都串行化,性能直线下降。但仔细想想,多个读请求之间根本不冲突,它们都在读同一份数据,读和读为什么要互相等待?真正需要被挡住的只有"读-写"之间和"写-写"之间。这就是读写锁存在的意义。
Redis本身没有原生的分布式读写锁概念,但Redisson基于它的Hash结构和Lua能力,把Java里的ReadWriteLock语义搬到了分布式环境。我之前一直觉得分布式场景加读写锁挺少见,直到做了某个报表系统才发现,读写分离的锁需求在"缓存重建"和"配置热更新"这种场景里非常普遍。
4.2 Redisson读写锁的实现形态与代码示例
使用上和JUC的ReadWriteLock很接近:
@Autowired private RedissonClient redissonClient; public void handleConfig(String configId) { RReadWriteLock rwLock = redissonClient.getReadWriteLock("config:lock:" + configId); // 读操作 RLock readLock = rwLock.readLock(); readLock.lock(); try { // 读取并返回配置 } finally { readLock.unlock(); } // 写操作 RLock writeLock = rwLock.writeLock(); writeLock.lock(); try { // 更新配置 } finally { writeLock.unlock(); } }Redisson在实现读写锁时,底层实际上是把一个读写锁拆成了两个独立的Redis key。写锁使用"lockName:WRLOCK"作为key,读锁使用"lockName:RDLOCK"作为key。加写锁时,脚本会同时检查读锁key是否存在,只要任意一个持有者存在,写锁请求就失败;加读锁时,脚本记录读锁计数,同时检查写锁key是否被持有,写锁被持有则读锁失败。
用大白话说就是:写锁加锁前要确认"一个读的人都没有",读锁加锁前要确认"写的人已经走了"。读锁之间互不检查,所以可以多个线程同时持有读锁。而"读锁持有期间写锁无法获取"与"写锁持有期间读锁无法获取"这两个方向,由Redis的Lua脚本保证原子性判断,不会出现同时放行的情况。
需要注意的是,Redisson读写锁的"读读共享"是全局的,它不像数据库的行级锁那样区分数据行。它锁定的粒度就是你在getReadWriteLock里传的那个业务key。所以使用时应把业务维度拆分清楚,比如按配置ID、商品ID、活动ID来命名,而不是所有业务共用一把读写锁,否则锁的意义就变成了全局串行化,性能反而更差。
4.3 读写锁的典型应用与注意事项
三个我在实践中见过的典型场景。
第一个是缓存重建。一个热点数据的缓存过期了,回源查数据库代价高。如果只有一个请求回源,其他请求等缓存被重建后再读缓存,用互斥锁也能实现。但如果你有多个不同维度的数据需要同时回源,或者希望多个请求能够并发回源不同数据,对这些读操作加读锁、在完成回源后的写缓存加写锁,能更精细地控制并发。
第二个是配置热更新。分布式配置中心里,一个配置key被多个服务实例读取,更新频率低但一致性要求高。读取配置加读锁,保证读到的要么是旧值要么是新值,不会是更新过程中的中间状态;更新配置加写锁,保证同时只有一个实例在执行热更新,避免两个更新请求互相覆盖。
第三个是异步导出。导出的数据量大,多个用户同时发起同一批数据的导出会被重复计算。这时可以给"生成导出文件"的过程加写锁,同一条数据的导出任务只有一个实例在跑;而读取已生成的导出文件字段则加读锁,让用户并发查看导出进度。
使用读写锁时最需要警惕的是死锁顺序。如果业务里同时需要读锁和写锁,所有线程获取锁的顺序必须一致,否则可能出现线程A持有读锁等待写锁、线程B持有写锁等待读锁的循环等待。在Redisson里读写锁虽然共享同一个逻辑锁名,但底层是两个不同的key,等待关系依然是真实的,顺序混乱依然会死锁。
读写锁的正确性还依赖一个重要前提:读和写必须访问同一份受保护的数据。如果你的代码里读锁保护的是this.value,写锁保护的却是另一个字段,那锁形同虚设。这一点和单机读写锁完全一致,分布式只是放大了错误的后果。
5. 实战避坑:我在生产环境里踩过的Redisson五个坑
5.1 锁的key粒度:别拿全局常量当锁key
我第一次在某个项目里用Redisson时,图省事,所有库存扣减都用"stock:lock"这个key。上线后库存扣减倒是没超卖了,但整体吞吐惨不忍睹,因为任何商品之间的扣减都在互相阻塞。比如商品A的扣减请求和商品Z的扣减请求毫无关系,却要排队等同一把锁。
正确做法是把锁key设计到业务维度。库存扣减用商品ID,订单幂等用订单号或用户ID,配置更新用配置ID。锁粒度既要覆盖竞态条件的全部路径,又不能把不相关的操作绑在一起。判断粒度合不合理,可以问自己一句话:这两个请求如果都能进去,会不会出问题?不会,那就应该用更细的key。
5.2 显式指定leaseTime后的看门狗失效
这是我在一次线上故障里学到的。
当时某个服务在高峰时段频繁出现"查询不到数据"的告警,排查后发现是数据库更新任务还没完成,Redis里的锁已经过期被另一个线程拿到,两个线程同时更新同一批数据,其中一个把另一个刚写入的数据覆盖了。代码里写的是:
lock.tryLock(5, 30, TimeUnit.SECONDS);原意是"等5秒拿锁,拿到后最多持有30秒"。但实际上,显式设置leaseTime之后,Redisson默认不再启动看门狗。如果业务执行超过了30秒,锁就自动释放,后续竞争线程可以立刻拿到锁,而前一个线程依然在改数据,两个写操作就重叠了。
解决思路有两个方向:一是业务内预估好最大执行时间,把leaseTime放宽到明显超过业务耗时,比如几分钟;二是不要传leaseTime,用lock()配合看门狗,让Redisson按实际持有时长自动续期。我现在的建议是,确认业务执行时间基本稳定时用显式leaseTime更可控;业务逻辑复杂、执行时长不确定时,必须依赖看门狗,把超时兜底放到锁的外围逻辑里,比如数据库唯一约束和状态机校验。
5.3 解锁必须放在finally里,且要处理锁未获取的情况
这一条是老生常谈,但在分布式环境下翻车的代价比单机大得多。单机如果忘记finally解锁,锁对象被GC回收后锁会自动释放;分布式锁不释放,Redis里的Hash key会一直存在,最坏情况下所有其他实例都会阻塞到锁过期。
正确的写法分两种场景。第一种是lock()加锁,成功之后必然要在finally里unlock;第二种是tryLock,必须先判断拿到锁再执行,执行完毕同样在finally释放。
RLock lock = redissonClient.getLock("pay:lock:" + orderId); boolean acquired = false; try { acquired = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!acquired) { // 拿不到锁的兜底策略:快速失败、异步重试或者排队提示 throw new BizException("系统繁忙,请稍后重试"); } // 业务逻辑 } finally { if (acquired) { lock.unlock(); } }拿不到锁时的兜底策略值得提前设计。有的业务适合快速失败让用户重试,有的业务适合先写入待处理队列。无论如何,不要在拿不到锁的情况下继续执行业务代码,那等于裸奔。
另外,Redisson的unlock方法会校验当前线程是否为持有者。如果因为逻辑bug在未持锁时调用了unlock,会抛出IllegalMonitorStateException。这个异常往往比业务异常更难排查,因为代码路径已经乱了。建议在finally里也包一层try-catch,或者让自定义锁工具类统一管理加解锁,避免团队里每个人写出不一样的释放逻辑。
5.4 tryLock的等待时间别设太大:从锁竞争到资源耗尽
我曾经处理过一次故障:某个核心服务在锁竞争激烈时,大量线程都阻塞在tryLock的等待过程中,线程池被占满,导致无关请求也排队超时。问题出在tryLock(20, TimeUnit.SECONDS)这个参数上。20秒的等待意味着一个热点锁被持有时,可能积压大批线程在Redisson内部通道上等待,每个等待线程都占着一个线程池工作线程。
锁的等待时间要根据业务体量来设计。库存扣减这类高频操作,我一般设置2到3秒,拿不到就走快速失败;定时任务这类低频操作,等待时间可以放到5到10秒,过后做补偿。同时要监控"锁等待时间"和"锁竞争失败次数"这两个指标,如果持续升高,说明锁的粒度或业务负载已经不太健康,需要从架构层面调整,而不是无限加大等待时间。
还要注意,Redisson默认使用的连接池和线程数并不一定能支撑超大规模锁竞争。如果你们系统的锁请求量很大,建议单独给RedissonClient配置连接池参数,或者做锁服务的独立部署,避免锁Redis和应用主Redis相互影响。
5.5 主从切换与Redis集群下的锁可靠性
最后聊一个很多人会忽略的问题:我们用的Redis如果是主从架构,主节点挂了,从节点顶上,锁可能会丢。
场景是这样的:线程A在主节点上成功加锁,主节点还没来得及把数据同步到从节点就宕机了,此时从节点被提升为主节点,但它上面没有那把锁的数据,线程B就能在同一个业务key上加锁成功。这意味着两个线程同时进入临界区,分布式锁的互斥语义被打破了。
关于这个问题,理论界一直有争论。一种做法是红锁(RedLock),向多个独立的Redis节点同时加锁,超过半数成功才算加锁成功,牺牲部分可用性换取更高的安全性。但红锁本身也有争议,有的分布式系统专家认为它在某些极端场景下依然不能提供强一致性保证,而且实现复杂度高、故障模式多。
我的建议是分情况取舍:
| 部署形态 | 锁可靠性 | 适用场景 |
|---|---|---|
| 单机Redis | 中 | 锁的丢失影响可控的低频任务 |
| 主从/哨兵 | 中低 | 一般业务锁,需接受极端窗口下的并发风险 |
| 多节点+红锁 | 较高 | 资金类、库存强校验等高一致性场景 |
| 数据库/其他强一致存储 | 最高 | 无法容忍任何锁丢失的核心业务 |
以一个普通电商项目为例,秒杀库存扣减我会用主从+业务层的库存预扣校验兜底,因为单纯靠锁保证不超卖是不够的,数据库库存字段本身还应该有扣减时库存大于等于购买数量的条件更新。而支付回调幂等这种场景,我会要求极高的锁可靠性,宁可加慢一点,也不能让两个实例同时处理同一个回调。
最终你会发现,分布式锁从来不是银弹。Redisson能把锁的获取、释放、续期、重入这些底层细节做到位,但锁的key设计、粒度选择、超时策略、与业务数据的校验配合,仍然需要架构师亲自设计。这些能力,恰恰是引入任何分布式锁中间件之后,真正拉开团队水平差距的地方。