ARTICLE DETAIL

资讯详情

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

Redis分布式锁三种Java实现详解:SETNX、Redisson与Lua脚本

Redis分布式锁三种Java实现详解:SETNX、Redisson与Lua脚本 先说个场景你负责的一个订单系统里有个定时任务每天晚上要扫描超时订单并自动关单。原本单机部署的时候用synchronized或者Scheduled自带的锁就够了但系统一上多实例问题就来了——凌晨12点整三台机器同时扫到同一批订单咔咔咔连发三次关单通知用户那边短信都要炸了。这种“同一时刻只允许一个实例执行”的需求就是典型的分布式锁场景。后面去面试这题也是高频八股基本都会问“Redis分布式锁怎么实现”“RedisTemplate和Redisson有什么区别”。我见过不少同事嘴上能背出SETNX三条命令真到写代码时要么锁没设过期时间要么释放锁时把别人的锁删了。所以这篇就把我用过的三种Java实现方式完整复盘一遍最基础的SETNX 过期时间写法、生产环境直接能用的 Redisson、以及用来兜底和定制化的 Lua 脚本方案。每种都会讲清楚原理、完整可跑的代码、踩过的坑和使用场景基于我自己的实践经验来写适合准备面试的人也适合正在给项目引入分布式锁的开发者参考。1. 先想清楚分布式锁到底锁的是什么1.1 多实例下的竞态问题到底长什么样分布式锁解决的并不是“synchronized不好用”或者“代码写错了”的问题而是当多个 JVM 进程同时去操作同一份共享资源时JVM 内部的锁已经锁不住别的进程了。你加synchronized只能保证同一台机器上的线程互斥但另一台机器上的另一个 JVM压根看不见你加的锁。我用一个更直白的例子来解释。运营后台有一个“手动触发报表重算”的按钮用户误点了一下前端没做防抖请求发到了网关网关轮询转发给后端两台机器 A 和 B。A 机器先收到请求开始重算B 机器也收到请求同时开始重算。两份线程操作同一批订单数据先不论最终结果会不会覆盖光是数据库里的锁等待、重复消费消息、重复发送通知就已经够让人头疼的了。这类问题的共同点有三个一是操作必须在多个进程之间互斥二是锁必须有超时机制防止持有者宕机三是释放锁时必须只能释放自己持有的那把锁。1.2 实现分布式锁的三个必要条件做任何分布式锁不管是用 Redis、ZooKeeper 还是数据库都要满足下面三个条件互斥性同一时刻只能有一个客户端持有锁。死锁防护持有锁的客户端崩溃了锁也要能被自动释放不能永远卡住其他客户端。锁归属校验客户端 A 只能释放自己持有的锁不能把客户端 B 的锁给删了。除了这三个基本条件实际生产里往往还会追加两个要求可重入性和锁续期能力。可重入性就是同一个线程如果再次进入加锁代码能直接拿到锁而不产生死锁锁续期就是业务执行时间很长锁快过期了但业务还没跑完需要自动续期。很多人在基础写法上栽跟头就是因为只做到了前三点忽略了后两点。1.3 为什么首选 Redis而不是数据库或 ZooKeeper做分布式锁的中间件不止 Redis 一种数据库唯一索引、ZooKeeper 临时顺序节点也都能实现。但实际生产里Redis 往往是大多数团队的默认选择原因很直接Redis 本身已经是绝大多数后端项目的基础设施了不需要额外引入 ZooKeeper 这样的新组件运维成本、学习成本都更低性能上 Redis 单线程模型处理简单加锁命令的吞吐量也足够高。数据库锁的问题在于性能一般频繁加锁解锁会在数据库连接池上产生压力而且数据库锁相对笨重实现可重入比较绕。ZooKeeper 实现分布式锁确实在“锁的可靠性”上比 Redis 强比如临时顺序节点天然支持监听锁释放、没有过期时间问题但 ZooKeeper 的部署复杂度和延迟都比 Redis 高。所以绝大多数业务场景下Redis 分布式锁是性能和复杂度之间的平衡点这也是面试官最爱从这个方向问下去的原因。2. 方式一SETNX 过期时间 value 校验最基础的 Redis 分布式锁2.1 为什么必须一次性完成加锁和设置过期时间最早很多人写分布式锁用的是两步操作先SETNX再EXPIRE设置过期时间。这套写法的问题非常致命——如果SETNX执行成功但EXPIRE执行前进程突然宕机锁就永远没有过期时间了其他客户端永远拿不到锁直接造成死锁。所以从 Redis 2.6.12 开始官方推荐用一条命令完成加锁和设置过期时间SET key value NX EX seconds。NX 表示只有当 key 不存在时才能设置成功EX 表示过期时间这条命令是原子性的要么同时生效要么同时失败。我用 RedisTemplate 写一个最基础的版本给你看这个版本是可以直接跑起来的Component public class SimpleRedisLock { Autowired private StringRedisTemplate redisTemplate; private static final String LOCK_PREFIX lock:; private static final long DEFAULT_EXPIRE_SECONDS 30; /** * 尝试加锁 * * param lockKey 锁的业务标识 * param requestId 请求唯一标识用于释放锁时校验归属 * return 是否加锁成功 */ public boolean tryLock(String lockKey, String requestId) { return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent( LOCK_PREFIX lockKey, requestId, Duration.ofSeconds(DEFAULT_EXPIRE_SECONDS))); } /** * 释放锁暂时先展示错误写法 */ public void unlock(String lockKey, String requestId) { // 注意这样直接删除是有问题的下面会讲 redisTemplate.delete(LOCK_PREFIX lockKey); } }如果你项目里用的是 Jedis 而不是 StringRedisTemplate等价写法是jedis.set(lockKey, requestId, SetParams.setParams().nx().ex(seconds))。本质都是一样的。这段代码的加锁部分是合格的问题出在释放锁上——直接delete会有误删别人锁的风险。2.2 释放锁时为什么要校验 value而不是直接 delete来看一个非常典型的误删场景。线程 A 拿到了锁因为 GC 停顿或者业务执行太久锁 30 秒后自动过期了。此时线程 B 获取到了同一把锁正在执行自己的业务。A 的业务终于跑完了它执行delete(lockKey)结果把 B 的锁给删了。B 还在执行业务此时线程 C 又成功拿到锁开始执行互斥性瞬间失效。解决方案是释放锁之前先判断 value 是不是自己设置的只有是自己的才删除。这个判断和删除如果分成两步做中间还是会有竞态问题public void unlock(String lockKey, String requestId) { // 错误示范get 和 delete 之间锁可能过期别的线程设置了新值 if (requestId.equals(redisTemplate.opsForValue().get(LOCK_PREFIX lockKey))) { redisTemplate.delete(LOCK_PREFIX lockKey); } }例如 A 判断 value 是自己的还没执行 delete锁正好过期了B 加锁成功此时 A 再来 delete还是把 B 的锁删了。所以“判断删除”必须保证要么都成功、要么都失败也就是要用 Lua 脚本进去原子执行。这一点我会在第三种方式里给出完整方案。2.3 最基础方案的核心限制这个方案好用也好讲但短板非常明显不可重入同一个线程再次调用tryLock会因为 key 已经存在而失败。如果业务代码里锁内嵌套调用另一个加锁方法就会死锁。没有自动续期锁过期时间设成 30 秒业务执行超过 30 秒被中断其他线程拿到锁后可能读到不一致的数据。这种问题排查起来极其隐蔽你根本看不出来代码哪里有 “Bug”。主从切换可能导致锁丢失在 Redis 主从架构下主节点宕机后从节点晋升为主节点如果锁还没同步到从节点新主节点上没有锁记录其他线程就能加锁成功。所以第一种方式我的定位是“理解原理、应对面试、写简单脚本任务”。真放生产环境尤其是核心交易链路我会直接切换到 Redisson。3. 方式二Redisson RLock自带看门狗的生产级方案3.1 Redisson 到底解决了什么Redisson 是 Java 生态里非常成熟的 Redis 客户端框架它提供的分布式锁不是简单封装SETNX而是基于 Lua 脚本实现了一套完整的锁机制同时解决了“可重入”“自动续期”“等待锁”这些基础方案的痛点。我刚从那组“RedisTemplate Lua 手写锁”里面爬出来的时候第一反应是原来换个库这么多破事都有人替你想好了。Redisson 的RLock是 JDKjava.util.concurrent.locks.Lock接口的实现也就是说你从synchronized切到分布式锁代码风格几乎可以无缝迁移。这也是它在生产环境普及率很高的原因之一。3.2 Maven 依赖与完整代码示例引入依赖我用的是 3.x 版本dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.5/version /dependency然后配置 RedissonClientConfiguration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setPassword(null) .setConnectionPoolSize(10) .setConnectionMinimumIdleSize(2); return Redisson.create(config); } }业务代码里使用锁Service public class OrderCloseService { Autowired private RedissonClient redissonClient; public void closeTimeoutOrder(Long orderId) { String lockKey lock:order:close: orderId; RLock lock redissonClient.getLock(lockKey); boolean isLocked false; try { // 尝试加锁最多等待 3 秒加锁后 10 秒自动释放 isLocked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!isLocked) { log.warn(获取订单关闭锁失败, orderId{}, orderId); return; } // 业务逻辑 doCloseOrder(orderId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(获取分布式锁被中断, orderId{}, orderId, e); } finally { if (isLocked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这里有个细节要提醒一下tryLock(3, 10, TimeUnit.SECONDS)第一个参数是等待时间第二个是锁过期时间。如果你设置了第二个参数Redisson 不会启动看门狗续期锁到期后就直接释放了。如果没有传第二个参数看门狗才生效默认续期到 30 秒一轮。3.3 看门狗 Watch Dog 是怎么工作的看门狗机制是 Redisson 分布式锁的核心亮点我把它的工作流程拆开来说。当调用lock()方法且没有指定锁过期时间时Redisson 会向 Redis 发送一条 Lua 脚本完成加锁并把哈希结构中lock:key:threadId的过期时间设置为默认 30 秒。与此同时在客户端本地启动一个定时任务每 10 秒执行一次续期逻辑判断锁是否还持有如果持有就把过期时间重置为 30 秒。这样业务执行多久锁就能续期多久从机制上解决了“锁过期了但业务没跑完”的问题。等业务执行完客户端主动删除锁看门狗任务也会被取消。底层对应的 Lua 脚本长这样核心命令是hincrby加一个可重入计数-- KEYS[1]: 锁名称 -- ARGV[1]: 锁过期时间 -- ARGV[2]: 客户端唯一标识UUID threadId if (redis.call(exists, KEYS[1]) 0) then redis.call(hset, 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]);从脚本里能看到它用的是哈希结构而不是普通字符串同一个客户端标识加锁一次就hincrby 1释放一次就hincrby -1减到 0 才执行del。这就是可重入锁的底层实现。所以说“Redisson 可重入”不是框架文档里喊口号而是 Lua 脚本里实打实写着的。3.4 用 Redisson 还要注意什么Redisson 虽好但也不是没有坑我列几个生产中真正遇到过的点业务代码过长默认看门狗可能带来资源泄漏如果业务线程被长期阻塞且没有中断看门狗会一直续期可能拖垮业务。需要设置合理的lockWatchdogTimeout比如调小到 15 秒。锁粒度太粗不要偷懒用同一个 key 锁住所有订单操作尽量把粒度细化到订单维度。有些系统上线后性能下降最大的原因就是锁粒度被放大到了全局。Redis 主从切换场景仍然存在锁丢失风险Redisson 的单机模式、主从模式解决不了这个问题但 Redisson 也提供了 RedLock 多节点方案只是性能会降低而且业界对 RedLock 本身也有争议。真正的核心链路光靠 Redis 锁很多时候是不够的还需要对账兜底。4. 方式三RedisTemplate Lua 脚本手写可定制分布式锁4.1 为什么已经有了 Redisson还要自己写 Lua有人会问Redisson 这么成熟直接拿来用不就行了为什么还要自己拿 Lua 写一遍我的答案很实在一是有些团队根本不想引入 Redisson 这个额外依赖项目里已经用 RedisTemplate 了那就用最原始的方式解决问题二是自己写的锁可以按业务做定制比如在锁里额外存操作人、业务批次号、锁的类型这些元信息 Redisson 的哈希结构虽然能存但扩展起来不如自己定制那么顺手三是为了面试——面试官就爱问 Lua 脚本怎么实现原子加锁、原子释放你手写过一遍和只在八股文里背过聊起来完全两个深度。我之前在一个定时任务系统里就是自己用 Lua 写了一套锁专门用来解决“同一个批次的任务多个节点不能重复执行”的问题。代码结构清晰维护也方便。4.2 加锁和解锁的 Lua 脚本怎么写加锁阶段我们完全可以实现得比SETNX更精细。比如锁的 value 里存的是 JSON 字符串{requestId:550e8400-e29b-41d4-a716-446655440000,taskBatchNo:BA20240611001,timestamp:1718092800000}。这样排障时我们能直接通过 Redis 客户端看到是谁持有了锁、锁住了哪个批次。-- lock.lua -- KEYS[1]: 锁 key -- ARGV[1]: 锁 value唯一标识 业务信息 -- ARGV[2]: 过期时间单位毫秒 if redis.call(set, KEYS[1], ARGV[1], NX, PX, ARGV[2]) then return 1 else return 0 end解锁阶段的 Lua 脚本核心是“比较 value 相等才删除”-- unlock.lua -- KEYS[1]: 锁 key -- ARGV[1]: 锁 value if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本很多人看第一眼觉得没营养但它在并发下是绝对安全的。我特意对比过分成两步做的版本先 get 判断再 delete在大流量下用 JMeter 压测 1000 个并发线程误删率能达到 10% 左右。而 Lua 版本零误删。原因很简单Redis 的 Lua 脚本是原子执行的整个脚本执行期间不会被其他命令插入。4.3 在 Spring Boot 中把 Lua 脚本跑起来首先定义脚本对象和常量Component public class LuaRedisLock { Autowired private StringRedisTemplate stringRedisTemplate; private static final DefaultRedisScriptLong LOCK_SCRIPT new DefaultRedisScript(); private static final DefaultRedisScriptLong UNLOCK_SCRIPT new DefaultRedisScript(); private static final long DEFAULT_EXPIRE_MILLIS 30000; static { // 加载加锁脚本 LOCK_SCRIPT.setLocation(new ClassPathResource(lock.lua)); LOCK_SCRIPT.setResultType(Long.class); // 加载解锁脚本 UNLOCK_SCRIPT.setLocation(new ClassPathResource(unlock.lua)); UNLOCK_SCRIPT.setResultType(Long.class); } /** * 加锁 */ public boolean tryLock(String lockKey, String lockValue) { Long result stringRedisTemplate.execute( LOCK_SCRIPT, Collections.singletonList(lockKey), lockValue, String.valueOf(DEFAULT_EXPIRE_MILLIS) ); return result ! null result 1L; } /** * 解锁 */ public boolean unlock(String lockKey, String lockValue) { Long result stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(lockKey), lockValue ); return result ! null result 1L; } }如果你的 Lua 脚本文件不方便放 classpath也可以直接在代码里拼字符串这取决于团队习惯。我一般放到resources/scripts/lua/目录下方便 DBA 和运维同学审查脚本有没有问题。4.4 业务层怎么调用这套自定义锁调用的时候value 的生成要保证全局唯一最简单的办法是UUID.randomUUID()拼上当前线程 IDService public class TaskExecuteService { Autowired private LuaRedisLock luaRedisLock; public void executeBatchTask(String batchNo) { String lockKey lock:task:batch: batchNo; String lockValue UUID.randomUUID().toString() : Thread.currentThread().getId(); boolean locked false; try { locked luaRedisLock.tryLock(lockKey, lockValue); if (!locked) { log.info(任务批次已被其他节点执行, batchNo{}, batchNo); return; } // 执行批量任务的核心逻辑 doExecute(batchNo); } finally { if (locked) { luaRedisLock.unlock(lockKey, lockValue); } } } }这里finally里释放锁的时候传入的lockValue必须是加锁时同一个值。如果lockValue在上下文传递中弄丢了或者重新生成了一个 UUID解锁一定会失败。这也是我在代码评审中经常发现的问题——有同事在方法里重新调用了一个generateLockValue()。这个坑要记住。4.5 自定义锁的进阶扩展基于 Lua 脚本其实可以非常轻松地扩展出一些生产里需要的功能。比如自动续期在加锁后启动一个线程每隔一定时间执行一次续期 Lua 脚本-- renew.lua -- KEYS[1]: 锁 key -- ARGV[1]: 锁 value -- ARGV[2]: 新的过期时间毫秒 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) else return 0 end这个续期线程要记得做成守护线程setDaemon(true)并且每次续期前重新判断锁是否还归自己持有。续期线程必须在业务最后释放锁后主动中断否则线程会一直空转。再比如批量加锁。有的业务场景需要一次性锁多个 key比如转账要同时锁转出账户和转入账户避免 A 给 B 转账、B 给 A 转账同时执行时出现交叉锁死。用 Lua 循环执行即可-- multiLock.lua for i, key in ipairs(KEYS) do if not redis.call(set, key, ARGV[1], NX, PX, ARGV[2]) then -- 如果中间某个 key 加锁失败回滚前面已经加锁的 key for j 1, i - 1 do if redis.call(get, KEYS[j]) ARGV[1] then redis.call(del, KEYS[j]) end end return 0 end end return 1注意批量加锁时要按固定顺序加锁比如先按 key 排序再执行 Lua这样能最大程度避免死锁。5. 三种方式怎么选我用一个对比表说清楚5.1 三种方案的横向对比对比维度SETNX 过期时间Redisson RLockRedisTemplate Lua 脚本可重入不支持支持看脚本实现可扩展自动续期不支持支持默认看门狗 30 秒一轮需自行实现续期线程等待锁不支持直接返回失败支持 tryLock 等待不支持需自行扩展误删风险需配合 Lua 才能避免低底层脚本已处理低脚本已处理依赖成本低基础依赖即可需要引入 Redisson低单据 RedisTemplate定制能力弱中可扩展但内部封装较多强完全可控生产推荐度非核心脚本任务核心业务首选定制化场景优选从这个表你能看到Redisson 在功能完整性和可靠性上确实最强但如果你的团队就是要轻量、可控、不想多引依赖那第三种方式也完全够用。第一种方式在我的项目里基本只用来做“防重提交”这种场景比如用户短时间内重复点击提交按钮那种业务对可靠性要求没那么高能挡住绝大多数重复请求就足够了。5.2 谈谈主从切换和 RedLock 这个争议话题围绕 Redis 分布式锁面试官通常还会抛出一个“灵魂拷问”Redis 主从切换时锁丢失怎么办这个问题没有标准美答案我们得结合场景谈。首先明确默认情况下 Redis 主从是异步复制的。主节点写入锁后宕机锁还没来得及同步到从节点从节点被提升为主节点锁就丢了其他客户端能再次成功拿到锁互斥性被破坏。Redisson 以 RedLock 方案应对这类问题。核心思路是向多个独立的 Redis 节点依次加锁超过一半节点加锁成功才算持有锁释放锁时向全部节点发送释放命令。这个方案的优点是确实降低了锁丢失概率缺点也很明显需要多套独立 Redis 实例成本高、性能下降、维护复杂而且业界对 RedLock 在逻辑时钟跳跃、GC 停顿下是否真的安全一直有争议连 Redis 作者和分布式系统专家都公开辩论过。所以我个人在实际项目里的态度是普通业务场景用 Redis 锁就能满足需求但真到了资金类、订单关单这种绝对不允许重复操作的场景除了分布式锁一定要再加一层业务层面的防重手段比如数据库唯一索引、状态机流转校验、对账补偿任务。锁的作用是降低并发冲突的概率而不是替业务兜底。6. 常见问题与面试真题实录6.1 加锁成功但业务没执行完锁过期了怎么办这是我在面试里最喜欢问的实战问题因为能区分背八股和真正做过的人。在基础方案SETNX方式里答案是把过期时间设得足够长长到超过业务最大执行时间比如通常业务最慢 5 秒就把锁设成 30 秒容忍一定冗余。但这种方式说到底是在赌不适合长时间任务。在 Redisson 方式里答案就是看门狗自动续期不需要业务关心超时问题只要不指定锁的 leaseTime就没有“业务没执行完锁先过期”的烦恼。在手写 Lua 方式里你需要自己实现续期机制也就是我在 4.5 节写的那段renew.lua。实现续期时要特别注意一点续期线程的循环周期必须是锁过期时间的 1/3 左右。比如锁 30 秒过期就每 10 秒续一次保证在网络抖动时也有足够的余量在锁过期前完成续期。6.2 锁误删是怎么发生的怎么彻底避免这个问题在前面已经详细讲过误删的本质是未校验锁的归属。基础方案的错误写法是if (get(key).equals(value)) delete(key)正确写法是把 get 和 delete 放到一条 Lua 脚本里原子执行。在 Redisson 中底层已经这样实现了所以不用操心。在手写 Lua 方案中我给的unlock.lua就是标准解法。还有一个容易忽略的问题是Redis 客户端的 value 序列化方式不同可能导致 value 比对不上。比如你用 GenericJackson2JsonRedisSerializer 存字符串值和用 StringRedisSerializer 存取出来的类型可能一个是带引号的 JSON 字符串一个是纯字符串。我建议做分布式锁的所有相关操作都统一使用 StringRedisSerializer能避免大量奇奇怪怪的类型问题。6.3 锁重入怎么判断自旋等待又怎么实现面试里问到可重入其实是想考察你是不是只背了 Redisson 的 API还是真懂底层结构。Redisson 用哈希结构存储锁field 是客户端 ID 加线程 IDvalue 是重入计数。每次加锁对同一个 field 做加一每次释放做减一减到零才删除整个哈希 key。这套逻辑写进 Lua 脚本就是可重入锁。至于自旋等待基础方案想实现“拿不到锁就等待一会儿再重试”可以这样做public boolean tryLockWithWait(String lockKey, String requestId, long maxWaitMillis) { long start System.currentTimeMillis(); while (System.currentTimeMillis() - start maxWaitMillis) { if (tryLock(lockKey, requestId)) { return true; } // 短暂休眠避免过度请求 Redis try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } return false; }这段代码的问题在于Thread.sleep的间隔是固定的如果并发量高Redis 会被频繁请求我建议改为带随机退避的等待时间比如 50 到 200 毫秒之间随机能有效降低 Redis 的压力。Java 的ThreadLocalRandom.current().nextLong(50, 200)可以做到。6.4 线上锁一直加不上怎么排查这种情况我遇到太多次了。第一个排查点看 Redis 里锁 key 是否还存在如果存在就get lock:xxx查看 value确认是哪个实例哪个线程持有的锁。如果 value 里包含了宿主机 IP、应用名、线程名排障体验会好很多。这也是我为什么在自定义锁里推荐把业务信息编码进 value 的原因。第二个排查点看持锁线程是不是已经卡死。如果持锁线程因为数据库连接池耗尽、死锁等原因阻塞了即使看门狗一直在续期锁也永远释放不了。这时候要反过来排查业务线程状态用jstack导出线程栈看看持锁线程卡在哪个调用点上。第三个排查点看 Redis 的命令执行时间。Redis 的 Lua 脚本如果写得不好比如循环体里有大量KEYS查找会导致 Redis 长时间阻塞其他命令排队等待。Redis 官方建议 Lua 脚本执行时间控制在几十毫秒内超时要及时优化。6.5 一份常见问题速查表问题现象可能原因解决方案锁偶尔失效多个实例同时进入value 未校验误删他人锁使用 Lua 脚本原子释放锁业务执行时间长锁频繁提前释放没有续期机制使用 Redisson 看门狗或自研续期线程同一个线程锁内调用锁方法死锁锁不可重入换 Redisson或哈希结构自实现重入计数Redis 主从切换后锁丢失锁未同步到从节点评估 RedLock或增加数据库防重Redis 连接被占满加锁超时锁粒度太粗或等待策略太密细化锁粒度等待加随机退避锁释放时抛异常导致死锁解锁与业务在同一 try 未 catch用 finally 释放锁或 try-with-resources这些问题的核心其实都是“加锁、释放、过期、重入”这几个要素的组合。把这几个要素想透了分布式锁这道题就算吃透了。7. 几种方式我都跑过说点我自己的偏好写了这么多年 JavaRedis 分布式锁从最早的synchronized到SETNX各版本踩过来我自己的感受是不要为了用分布式锁而用分布式锁。先确认业务是不是真的需要跨 JVM 互斥再用最简单且可控的方案。单机就能搞定的事情别给自己和团队增加无谓的复杂度。如果是新项目团队已经用上了 Spring Boot我一般直接选 Redisson省心、可靠、社区活跃可重入和续期都解决了。如果是老项目依赖不能随便加那我会用手写 Lua 脚本方案把锁相关的逻辑收敛到一个类里方便测试和审查。至于纯SETNX方案适合小脚本、小任务、定时任务防重这种级别再往上走都是给生产埋雷。最后再分享一个实际经验无论用哪种方案请在锁的业务代码里加一条精度较高的耗时日志。锁这个东西一旦加了所有竞争锁的请求都会变慢系统吞吐量一定会有损耗。日志能够帮你随时发现锁竞争激烈的热点 key及时优化锁粒度或者引入其他降级方案。这不是一个能“跑起来就行”的话题分布式锁就像项目里的一颗螺丝钉小但真掉链子的时候能把整个系统卡死。希望我踩过这些坑的详细复盘能帮你少走几条弯路。
返回列表