用 Redis + Redisson 实现分布式锁:从踩坑到生产可用
2026/7/13 21:45:40
网站开发
用 Redis Redisson 实现分布式锁:从踩坑到生产可用单机部署时,一个synchronized就能锁住临界区。但服务一旦扩到多实例,JVM 级别的锁就完全失效了——两个实例各自持有自己的锁,谁也拦不住谁,超卖、重复下单立刻就来了。这时候就需要一把所有实例都认的「分布式锁」。Redis 是最常见的实现载体。很多人上手直接写SETNX,结果在生产上锁死、锁被别人释放、锁提前过期各种问题轮番上演。这篇我们从最朴素的写法一路踩坑,最后落到用 Redisson 的生产级方案。朴素写法:SETNX 为什么不够先看最容易想到的写法:// 反例:问题一大堆,别在生产用publicbooleantryLock(Stringkey,Stringvalue){// setIfAbsent 就是 SETNXBooleanokredisTemplate.opsForValue().setIfAbsent(key,value);returnBoolean.TRUE.equals(ok);}publicvoidunlock(Stringkey){redisTemplate.delete(key);}这段代码至少有三个致命问题:没有过期时间:如果加锁的实例在释放前崩溃了,这个 key 永远留在 Redis 里,后面所有人都拿不到锁——死锁。加锁和设过期不是原子的:就算你补一句expire,setIfAbsent成功后、expire执行前进程挂掉,还是死锁。误删别人的锁:A 的锁因为业务超时自动过期了,B 拿到了锁;这时 A 的业务执行完调unlock,把 B 的锁删掉了。修正第一步:原子加锁 校验持有者前两个问题好解决,SET key value NX PX一条命令原子完成加锁和设过期:publicbooleantryLock(Stringkey,Stringvalue,longexpireMs){// NX 过期时间一步到位,不会出现加锁成功但没设过期的窗口BooleanokredisTemplate.opsForValue().setIfAbsent(key,value,Duration.ofMillis(expireMs));returnBoolean.TRUE.equals(ok);}第三个问题(误删)要靠「value 存唯一标识 释放时校验」。但校验和删除必须原子,否则「判断是我的锁」和「删除」之间锁又过期了,还是会误删。用 Lua 脚本保证原子性:privatestaticfinalStringUNLOCK_LUAif redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end;publicbooleanunlock(Stringkey,Stringvalue){DefaultRedisScriptLongscriptnewDefaultRedisScript(UNLOCK_LUA,Long.class);LongresredisTemplate.execute(script,Collections.singletonList(key),value);returnLong.valueOf(1).equals(res);}value用UUID 线程 ID之类的唯一值。现在锁基本能用了,但还有个绕不开的坑:锁过期时间怎么定?设短了,业务没跑完锁就没了;设长了,实例崩溃后别人要等很久。这就是「锁续期」问题——手写很难做好,该上轮子了。生产方案:RedissonRedisson 把上面这些坑全填了,还自带看门狗(watchdog)自动续期:只要业务没结束,后台线程每隔 1/3 过期时间就把锁续到满,业务跑多久锁就撑多久;一旦持锁进程挂掉,续期停止,锁到期自动释放。引入依赖:dependencygroupIdorg.redisson/groupIdartifactIdredisson-spring-boot-starter/artifactIdversion3.27.2/version/dependency配置一个客户端:ConfigurationpublicclassRedissonConfig{Bean(destroyMethodshutdown)publicRedissonClientredissonClient(){ConfigconfignewConfig();config.useSingleServer().setAddress(redis://127.0.0.1:6379).setDatabase(0);returnRedisson.create(config);}}用起来就非常干净了:ServicepublicclassOrderService{AutowiredprivateRedissonClientredisson;publicvoiddeductStock(longskuId){RLocklockredisson.getLock(stock:lock:skuId);// 不传租约时间 - 启用看门狗自动续期(默认 30s,每 10s 续一次)// waitTime5s:拿不到锁最多等 5 秒try{booleanlockedlock.tryLock(5,TimeUnit.SECONDS);if(!locked){thrownewRuntimeException(系统繁忙,请重试);}// ---- 临界区:真正的扣减库存逻辑 ----doDeduct(skuId);}catch(InterruptedExceptione){Thread.currentThread().interrupt();thrownewRuntimeException(获取锁被中断,e);}finally{// isHeldByCurrentThread 防止误释放别的线程/续期后的锁if(lock.isHeldByCurrentThread()){lock.unlock();}}}}几个关键点讲清楚:tryLock(waitTime, unit)不传 leaseTime,才会启用看门狗。如果你写成tryLock(5, 30, TimeUnit.SECONDS)(带 leaseTime),看门狗不生效,30 秒后锁硬过期,长事务会中招。这是最常见的误用。finally里判断isHeldByCurrentThread():避免锁已因异常提前释放,又 unlock 一次报错。Redisson 的锁是可重入的,底层用 Hash 存重入次数,同一线程多次lock不会自锁死。一个容易忽略的坑:主从切换丢锁单机 Redis 用上面的方案已经很稳。但如果是主从架构,存在一个理论风险:客户端在 master 上加锁成功,数据还没同步到 slave,master 就宕机了,slave 提升为新 master——这把锁就凭空消失了,另一个客户端能再次加锁,互斥被打破。对一致性要求极高的场景(如金融扣款),Redisson 提供RedLock思路的联锁,或直接换用getFairLock配合多独立 Redis 节点。但 RedLock 本身在业界有争议(Martin Kleppmann 与 antirez 的著名辩论),复杂度也高。多数业务场景下,单机/哨兵 Redisson 看门狗已经够用,不要为极小概率的边界过度设计。如果确实要求强一致,更稳妥的其实是用支持共识的存储(如 ZooKeeper、etcd)做锁,而不是硬磕 Redis 的边界。小结SETNX手写锁的三大坑:无过期(死锁)、加锁与过期非原子、释放误删别人的锁。修正方向:SET NX PX原子加锁 value 存唯一标识 Lua 脚本原子校验删除。锁续期手写很难,生产直接上Redisson:tryLock(waitTime)不带 leaseTime 触发看门狗自动续期,业务跑多久锁撑多久,进程挂了自动释放。finally里必须isHeldByCurrentThread()再unlock;别误用带 leaseTime 的重载把看门狗关掉。主从切换丢锁是理论风险,一般业务不必过度设计;强一致场景考虑 etcd/ZooKeeper。一句话记忆:手写锁重点是「原子」,生产用锁重点是「续期」——前者靠 Lua,后者靠 Redisson 看门狗。