以前在做一个电商项目的时候遇到过这么一个问题上线前压测一切正常可一上生产库存偶尔会变成负数。代码逻辑翻来覆去看了好几遍里面明明加了synchronized为什么会超卖问题就出在服务部署了三个实例synchronized锁住的只是一个JVM里的对象另外两个JVM根本不认这把锁。后来我把锁换成了Redisson不少人会把它拼写成Redission官方项目名其实是Redisson在SpringBoot项目里用它实现分布式锁库存问题才真正解决。这次想把自己从入门到踩坑的过程完整梳理一遍内容包括为什么单机锁不行、为什么选Redisson而不是自己拿Redis写一把锁、如何在SpringBoot里集成、可重入锁的底层原理以及读锁和写锁的正确姿势。不管你是正在做微服务改造还是在准备分布式锁相关的面试题这篇应该都能帮上忙。1. 先搞清楚单机锁为什么管不住多实例服务1.1 一次库存超卖让我重新认识了锁当时的业务场景很简单一个商品库存100件用户下单扣减库存。代码大概是先查库存如果大于0就扣减否则提示库存不足。单实例部署的时候给核心方法加上synchronized就够了因为所有请求都进同一个JVMJVM的Monitor能保证同一时刻只有一个线程进入临界区。但服务一旦扩到三个实例事情就变了。三个实例各自维护一套MonitorA实例的线程进入synchronized块时B实例、C实例的线程根本不受影响。假设库存还剩1件三个实例同时收到两个请求两个线程同时读到库存大于0同时执行扣减最终库存变成了-1。这个场景压测最容易复现也是网上所有分布式锁文章都会提到的经典超卖问题。要解决这个问题靠语言内置的锁已经不行了需要一个所有实例都能访问到的公共锁。Redis是现成的公共组件天然适合做这个角色后面讲的Redisson本质上就是把Redis当成了这把锁的存储和协调中心。1.2 分布式锁必须满足的四个条件分布式锁不是简单的加锁/解锁我在选型时最关注四个硬性条件互斥性任意时刻只有一台机器上的一个线程能持有锁。防死锁持有锁的线程崩溃了锁必须能自动释放不能变成永远解不开的死锁。高可用锁服务本身不能成为单点Redis挂了不能影响正常业务。可重入同一个线程再次获取同一把锁时不会被自己阻塞。第四条容易被忽略。实际项目中方法调用往往嵌套比如外层方法已经拿到订单锁内部又调用了另一个也要获取同一把订单锁的公共方法。如果锁不支持可重入就直接把自己锁死了。1.3 分布式锁最典型的几个应用场景我整理了一下自己做过的项目里真正用上分布式锁的场景库存扣减、秒杀防止多个实例同时扣减同一商品库存导致超卖。防止重复提交用户连续点击下单按钮同一订单只能创建一次。定时任务多实例互斥多个实例同时跑Scheduled但任务本身只允许一个实例执行比如每天凌晨的报表汇总。分布式事务补偿多个服务协作处理同一笔业务时用锁保证对同一资源的串行操作。幂等控制同一请求经过重试多次到达下游用锁保证只处理一次。这些场景的共同点是人多、资源少、并发高必须把并发压到串行但又不能串行太久否则吞吐量就崩了。2. 为什么选Redisson而不是自己拿Redis写一把锁2.1 手写Redis锁的四个经典坑网上很多教程会教你用SETNX实现分布式锁代码看起来很简单但真往生产放就知道坑有多深。第一个坑SETNX和EXPIRE不是原子的。先执行SETNX加锁再执行EXPIRE设过期时间。如果SETNX刚成功线程就崩溃了EXPIRE没执行到这把锁没有任何过期时间其他线程永远拿不到锁。第二个坑释放锁时不检查持有者。线程A加锁后遇到阻塞锁超时自动释放了线程B拿到锁开始处理业务。结果线程A恢复过来直接执行DEL把锁删了——删的是线程B的锁。此时线程C又拿到锁三个线程同时进入临界区。第三个坑锁刚释放又被别人拿到业务会穿插。锁超时时间设短了业务还没执行完锁先没了。后面进来的线程读到不完整的数据整个缓存重建逻辑错乱。第四个坑没有阻塞和等待语义。业务想的是拿不到锁就等一会儿再试手写方案只能自己写死循环重试处理不好还会造成大量Redis请求打满网络。你可以通过Lua脚本把这些都修好但写到最后会发现这不就是重新发明了Redisson吗2.2 WatchdogRedisson最值钱的设计Redisson默认给锁设置了一个30秒的有效时间。如果你加锁时没有显式指定leaseTimeRedisson会启动一个后台定时任务每隔10秒检查一次当前线程是否还持有锁如果还持有就把锁的过期时间重新刷新成30秒。这个机制就是网上常说的看门狗。Watchdog解决了两件手写方案非常难解决的事情业务执行时间超过锁过期时间时锁会自动续期不会因为锁提前释放导致多个线程同时进入临界区。线程所在JVM进程崩溃时后台定时任务也会跟着消失锁会在最多30秒后被Redis自动清理不会死锁。类比一下Watchdog就像一个专门负责续约的助理主人在开会助理定个闹钟每10分钟去看一次主人还在开会就把会议室的预约时间往后延主人要是出事倒下了助理自然也就不再续约会议室最终会被释放掉。2.3 除了Redis锁数据库锁和ZooKeeper锁呢我不是说Redisson在所有场景都是最优解实际选型要看团队基础组件情况。我当时做了一份简单的对比方案优点缺点适合场景Redis手写SETNX实现简单无额外依赖易踩坑无续期无等待语义学习演示、非关键业务Redisson功能全有看门狗续期支持读写锁/公平锁/可重入依赖Redis高可用极端情况锁可能丢失绝大多数SpringBoot业务ZooKeeper强一致性锁不会丢需要额外部署ZooKeeper性能低于Redis对一致性要求极高的配置中心类场景数据库悲观锁不用引入中间件性能差对数据库压力大可能出现锁表低频、对性能不敏感的管理后台我最终选Redisson不是因为数据库锁和ZK不行而是因为我们本来就有Redis集群引入Redisson几乎没有额外运维成本而且它的API覆盖了大部分业务锁需求。3. SpringBoot项目集成Redisson从配置到第一次加锁3.1 引入依赖和最简单的客户端配置如果用官方提供的starterSpringBoot项目里加Redisson非常快。Maven坐标dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.24.3/version /dependency加完依赖后我习惯自己写一个配置类不盲目依赖自动装配。这样能清楚知道自己连的是哪个Redis地址、用的是什么模式。Configuration public class RedissonConfig { Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionMinimumIdleSize(10) .setConnectionPoolSize(50); return Redisson.create(config); } }如果你的Redis是哨兵模式或集群模式配置对象跟着换就行config.useSentinelServers() .setMasterName(mymaster) .addSentinelAddress(redis://127.0.0.1:6379, redis://127.0.0.1:6380); config.useClusterServers() .addNodeAddress(redis://127.0.0.1:7000, redis://127.0.0.1:7001);在这个Bean上我特别写了destroyMethod shutdown否则应用关闭时Redisson的连接池可能不会自动清理造成连接泄漏。这个细节在长跑的应用上特别重要。3.2 第一次加锁RLock的lock和tryLock拿到RedissonClient之后加锁的核心API就一个getLock方法。下面这段是我在订单创建接口里防重复提交的最小实现Autowired private RedissonClient redissonClient; public boolean createOrder(String userId, String orderId) { RLock lock redissonClient.getLock(order:submit: userId); boolean locked false; try { locked lock.tryLock(2, 10, TimeUnit.SECONDS); if (!locked) { log.warn(获取锁失败 userId{}, 请勿重复提交, userId); return false; } // 业务校验、创建订单 } finally { if (locked) { lock.unlock(); } } return false; }这里有个很重要的点tryLock返回的是布尔值表示这次到底有没有拿到锁。如果没拿到finally里千万不能unlock所以用了一个locked变量做标记。lock()是阻塞获取拿不到锁就一直在本地等待tryLock(waitTime, leaseTime, TimeUnit)是尝试获取等待waitTime时间后还没拿到就返回false。业务里我几乎都用tryLock因为无脑阻塞等待很可能把线程池占满最后拖垮整个服务。3.3 正确释放锁最容易踩的三个雷先说最简单的所有unlock都要放在finally里。有些人把unlock放在方法末尾中间业务抛异常就直接跳过unlock锁会一直等到过期才释放。如果过期时间设得又长系统等于多堵了30秒。第二个雷只有持有锁的线程才有资格释放锁。你在线程A里加锁在线程B里调unlockRedisson会抛IllegalMonitorStateException。这是因为解锁前Redisson会检查当前线程ID是否和锁记录里的线程ID一致不一致就拒绝释放。第三个雷是事务和锁的执行顺序问题。如果方法是Transactional事务代理默认包裹整个方法。锁在方法内部释放时事务可能还没提交其他线程马上获取到锁进来读到的是还没有提交的数据。我的建议是先加锁再走事务提交逻辑事务提交完成后才释放锁说白了就是锁的边界一定要比事务边界大。4. 可重入锁原理一个Hash字段背后的加减持锁逻辑4.1 可重入解决的是同一线程再拿同一把锁Java里ReentrantLock的可重入大家都很熟同一个线程持有锁期间再次调用lock()不会被阻塞只是把加锁次数加1。分布式锁也一样需要这个能力。举一个很常见的例子一个公共服务方法deductStock(code)内部要先获取锁再扣库存另一个方法createOrderAndDeduct(code)在处理订单时调用了它而createOrderAndDeduct自己又提前获取了同一把库存锁。如果不支持可重入第二次获取锁时线程会被自己持有的锁阻塞直接死锁。Redisson的RLock天然支持可重入这也是我敢把锁用在嵌套业务里的原因。4.2 底层结构不是String而是一个Hash很多人以为Redis锁就是一条简单的StringSETNX一个key就完事了。Redisson不一样它用Redis的Hash类型存储锁。锁的key就是锁名比如order:submit:12345Hash里的field是UUID:线程IDHash里的value是加锁次数。Redisson加锁的Lua脚本逻辑简化后大概是这样的-- KEYS[1] 锁名 -- ARGV[1] UUID:线程ID -- ARGV[2] 锁过期时间毫秒 if redis.call(exists, KEYS[1]) 0 then -- 锁不存在直接创建计数为1 redis.call(hset, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return nil end if redis.call(hexists, KEYS[1], ARGV[1]) 1 then -- 存在但field是自己的重入计数1并刷新过期时间 redis.call(hincrby, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return nil end -- 锁被其他线程持有返回锁剩余过期时间 return redis.call(pttl, KEYS[1])注意加锁和设过期时间在同一个Lua脚本里执行Redis是单线程执行Lua的所以这一步天然原子不存在SETNX和EXPIRE中间崩溃的死锁问题。4.3 解锁并不是简单DEL解锁脚本的流程也很有意思它不直接删key而是先给计数减1-- 如果这把锁不存在或者field不是当前线程返回空 if redis.call(hexists, KEYS[1], ARGV[1]) 0 then return nil end local count redis.call(hincrby, KEYS[1], ARGV[1], -1) if count 0 then -- 还有重入次数说明只是退出一层嵌套刷新过期时间 redis.call(pexpire, KEYS[1], ARGV[2]) return 0 end -- 计数已经是0真正删除锁 redis.call(del, KEYS[1]) return 1理解了Hash和计数你就能看懂为什么锁能够支持可重入重入一次value加1退出一次value减1只有减到0才是真正释放锁。这也是为什么同一个线程不会把自己锁死而不同线程之间的field不同想删都删不了别人的锁。4.4 Watchdog续期是本地定时任务Redis刷新的配合前面说的看门狗也是通过Lua脚本完成的。Redisson客户端会在当前线程第一次加锁成功后创建一个后台定时任务任务周期是lockWatchdogTimeout / 3默认约10秒。每次执行时它会用一段Lua脚本检查当前线程是否还持有锁如果还持有就重新执行pexpire把锁的过期时间刷新到30秒。这套设计妙在把崩溃检测放到了客户端业务进程活着锁就会一直续期业务进程死了定时任务也没了Redis里的锁最多30秒后自动过期。不需要额外引入一个保活服务成本很低。5. 读写锁读多写少场景的正确答案5.1 读写锁和普通互斥锁的区别普通互斥锁是大家排队一个一个进但很多业务场景其实是读多写少比如商品详情页的缓存配置、支付渠道参数、活动规则配置。这些数据大部分时间是读偶尔才有人改一下。如果所有读和写都走普通互斥锁几千个并发读请求会被强制串行白白损失吞吐量。读写锁的语义是多个读锁之间可以并发获取。写锁是独占的只能有一个线程持有。写锁和读锁互相排斥持有写锁时其他线程不能读持有读锁时其他线程不能写。这个规则和数据库的读写锁一致核心目的是保证读线程永远看不到一个写了一半的数据。5.2 一个配置缓存场景的读写锁代码Redisson的读写锁用起来也很简单先拿到RReadWriteLock再分别取它的readLock()和writeLock()。RReadWriteLock rwLock redissonClient.getReadWriteLock(cache:product: productId); RLock readLock rwLock.readLock(); RLock writeLock rwLock.writeLock(); // 读操作 readLock.lock(); try { Product product localCache.get(productId); if (product null) { // 缓存未命中这里需要加载数据。但注意不要在持读锁时去拿写锁 } } finally { readLock.unlock(); } // 写操作刷新缓存 writeLock.lock(); try { Product product loadFromDb(productId); localCache.put(productId, product); } finally { writeLock.unlock(); }读写锁里的读锁与写锁是同一个锁名下的两种模式所以读锁和写锁之间是互相感知的和getLock得到的普通互斥锁不是一回事。5.3 读写锁的三个致命细节第一个是读锁升级写锁会死锁。如果线程已经持有读锁还在锁内部尝试获取写锁它需要等所有读锁释放但自己正占着一个读锁永远等不到自己释放直接死锁。这个错误在业务代码里非常隐蔽我以前就在缓存双检锁里踩过一次线上服务完全hang住。第二个是写锁降级读锁虽然允许但释放顺序必须注意。同一个线程持写锁时可以再获取读锁然后释放写锁此时当前线程还握着读锁。如果释放读锁的顺序弄错可能还没读完数据锁就全部放掉了后面一个写请求直接进来改数据。第三个是读写锁没有想象中那么便宜。读锁的获取和释放还是要操作Redis Hash结构而且读锁和写锁的模式切换需要额外的字段维护。如果数据本身允许短时间不一致用无锁方案加版本号可能是更好的选择毕竟分布式锁的代价就是一次网络往返。6. 实际项目中高频踩坑与我的排查经验6.1 锁KEY的粒度怎么设计才合理锁的KEY是分布式锁最容易出设计问题的点。我希望锁尽量细这样并发度才高但锁太细又可能保护不到需要互斥的资源。我的命名规范是模块:资源类型:资源ID比如order:lock:12345按订单维度锁。account:lock:10001按账户维度锁。stock:lock:CODE001按商品编码锁。分布式锁的两个极端反例都遇见过有人把所有用户的锁都定义成order:submit:global导致一个用户下单慢全平台下单都卡住也有人用order:lock:就没拼上ID结果还是全球一把锁。所以拿到需求第一步不是写代码而是先确认并发修改的到底是哪一个资源。6.2 锁超时时间定多少Watchdog什么时候生效这个问题的标准答案是看你的业务最长执行时间。如果你用lock()或tryLock(waitTime, TimeUnit)没有显式传leaseTimeRedisson会用Watchdog自动续期默认30秒。如果你用tryLock(waitTime, leaseTime, TimeUnit)显式指定了锁有效期Watchdog就不会启动到期锁自动释放。所以这里有一个容易误伤的点很多人以为传了leaseTime更安全结果随手设成30秒业务却要跑50秒后半段等于没加锁。我现在的做法是简单操作显式设置leaseTime根据压测得到的P99耗时乘上两倍给足余量。复杂连锁操作依赖Watchdog让Redisson自动续期然后做好监控防止线程卡住但锁一直续期的情况。6.3 压测时看到的现象锁对Redis的开销有多大我的压测结论是Redisson加锁和解锁大概各需要1到2次Redis命令普通业务量下这点开销可以忽略但高并发下Redis的QPS会明显上涨。毕竟所有实例都在同一个Redis上抢锁Redis又是单线程模型锁请求过多会挤占正常缓存读写的带宽。我还在压测里发现过一个问题热点商品用一把全局锁时并发能力非常低一个秒杀接口的TPS只有几百。后来把库存拆成多个分片每个分片各自加锁整体TPS一下子上来了。锁不是不能拆关键是拆分后要保证每个分片独立且不要破坏总体约束。6.4 极端情况下锁仍然会失效要心里有数分布式锁不是绝对锁极端场景下仍然可能失效Redis主从异步复制时主节点刚写入锁就宕机从节点还没同步到数据就变成了主节点新主节点上这把锁不存在其他线程可以再次获取同一把锁。应用发生长时间GC停顿Watchdog定时任务可能没法及时续期锁被Redis提前释放。Redis本身如果不可用获取锁会抛异常你要提前做好降级处理不能因为锁组件故障把业务一起带崩。这不是劝退而是提醒锁是降低并发冲突概率的手段不是保证绝对一致性的银弹。对于资金、库存这类强一致场景我会同时做三件事第一合理的锁粒度第二数据库乐观锁或唯一键兜底第三业务幂等校验保证最坏情况下数据仍然不会花。我个人在实际项目里更习惯在获取锁和释放锁的地方都埋上指标比如锁等待时间、持有时间。某个接口忽然变慢第一时间看的就是是不是某一把锁的持有时间超出预期这比漫无目的地查日志高效得多。分布式锁这东西用好了是保命工具用错了就是性能杀手真正理解它之后再做设计会让你少走很多弯路。