SpringBoot+Redis实现分布式锁:从原理到实战

发布时间:2026/10/12 2:46:13
SpringBoot+Redis实现分布式锁:从原理到实战
做后端开发的同学迟早会遇到一个极其典型的场景多个服务实例同时处理同一笔订单或者定时任务在集群环境下被每个节点各执行了一遍导致数据被重复处理。我第一次遇到这个问题时第一反应是给代码加一个 synchronized测了半天发现完全没用——因为锁住的是单个 JVM 进程而请求会打到不同的服务节点上。后来查了一圈资料才明白这种场景需要的是分布式锁让多个进程之间也能做到互斥访问共享资源。那次之后我在项目里正式引入了SpringBoot Redis 实现分布式锁这套方案。Redis 本身就是绝大多数后端项目标配的组件不需要额外引入新的中间件用它的 SETNX 命令就能实现最基础的加锁语义再配合 Lua 脚本解决解锁的原子性问题一套麻雀虽小五脏俱全的分布式锁就成型了。这篇文章把我实际落地这套方案的全过程写出来包括设计思路、完整代码、参数选择依据还有我踩过的坑和排查经验。无论你是刚接触分布式概念的新人还是已经在用 Redisson 但想搞懂底层原理的开发者这篇文章应该都能给你一些参考。1. 分布式锁到底在解决什么问题1.1 三个最典型的并发冲突场景我在真实项目中碰到过三种情况每一种都是分布式锁的典型应用场景。第一种是定时任务的重复执行。我们有个对账任务每天凌晨两点跑一次结果系统上线初期部署了两个实例两个实例都配置了同一套定时任务于是凌晨两点两个节点同时开跑同一批账单被处理了两遍。虽然业务上做了幂等但对账结果文件的生成还是出现了数据错乱。后来排查时发现就是缺一把在多个进程之间互斥的锁。第二种是用户重复请求导致的资源竞争。比如用户下单时前端连续点了两次立即支付或者网络重试导致同一笔订单的支付请求同时到达了两个服务节点。两个请求同时去扣减库存、创建支付流水如果没有锁就会出现库存扣成负数或者同一笔订单生成两条支付流水的问题。第三种是缓存与数据库的一致性更新场景。多个线程同时发现缓存过期了同时去数据库加载最新的数据并写回缓存。虽然很多团队会用缓存空值 短过期时间来缓解但根本的互斥手段还是锁只允许一个线程去加载数据其他线程等待它的结果即可。这三种场景有一个共同点互斥的范围不在单个 JVM 内而是在整个服务集群内。这就是分布式锁的核心价值——让不同进程里的线程在访问同一个共享资源时能够做到只有一个线程能进来。1.2 为什么 synchronized 和 JVM 本地锁解决不了很多初学者会困惑我用 synchronized 或者 Lock 接口明明也能保证线程安全为什么非要用分布式锁这里的关键在于锁的作用范围。synchronized 是基于 JVM 的监视器锁实现的它的锁对象存在于某个 JVM 进程的内存里。假设你部署了两个服务实例 A 和 B请求 1 落在 A 上请求 2 落在 B 上。A 获得了锁B 的锁在 B 自己的 JVM 内存里两者互不感知所以 B 完全不会被阻塞。结果是两个请求同时进入了临界区锁形同虚设。分布式锁的语义就完全不同了。它把锁这个概念外置到了所有服务节点都能访问到的公共组件上也就是 Redis。A 节点加锁成功B 节点再去加锁就会失败或者等待因为大家在抢同一个 Redis key。这样一来互斥的范围才真正覆盖了整个服务集群。顺便说一句如果你只有一个服务实例而且将来确定不会水平扩容那 JVM 锁完全够用不用折腾分布式锁。分布式锁引入的是网络通信成本和 Redis 的可用性依赖没有多实例场景就没有必要背上这个复杂度。2. 基于 Redis 的分布式锁核心设计思路2.1 Redis 的 SETNX 为什么能成为加锁的基石Redis 里有个命令叫SETNX全称是 SET if Not eXists意思是只有当 key 不存在时才设置这个 key。这个命令天然符合加锁的语义多个客户端同时尝试写入同一个 keyRedis 是单线程处理命令的一定会有一个客户端成功其余客户端失败。成功的那个就相当于拿到了锁。但要注意老版本的 Redis 中SETNX和EXPIRE是两条命令你必须分开调用。这在分布式场景下是致命的隐患如果你SETNX成功之后还没来得及设置过期时间进程就崩溃了这个 key 就会永远留在 Redis 里锁永远无法被释放其他所有请求都会被卡死。这就是传说中的死锁。所以后来 Redis 官方给出了标准答案使用SET key value NX PX timeout这条组合命令。它把不存在才设置和设置过期时间合并成一个原子操作一次网络请求解决两个语义。这也是我在项目里真正使用的命令。这里不展开各种旧方案的优劣了记住一点就够了加锁必须用SET key value NX PX timeout不要分两步执行SETNX再EXPIRE。加锁命令长这个样子SET lock:order:1001 550e8400-e29b-41d4-a716-446655440000 NX PX 30000这个命令的含义是如果 keylock:order:1001不存在就设置它值为550e8400-e29b-41d4-a716-446655440000同时设置 30 秒的过期时间。如果 key 已存在则不做任何操作返回空结果。2.2 value 值为什么要用唯一标识很多第一次写分布式锁的人加锁时给 value 随便填了个 1 或者 locked用完之后直接DEL这个 key。这样写有几个问题最典型的就是误删别人持有的锁。我来画一个具体的场景线程 A 获得锁后因为业务执行得太慢锁超时自动过期了。这时候线程 B 来加锁很顺利地拿到了同一把锁。A 业务终于执行完了它执行DEL lock:order:1001把这个 key 删掉了。结果是什么B 手里的锁被 A 删掉了C 线程这时候也能加锁成功——B 和 C 同时进入了临界区。这个问题的根源在于你没有办法证明你删除的锁是你自己持有的锁。解决方式也不复杂给 value 设置一个全局唯一的标识比如UUID 线程ID释放锁之前先校验 value 是不是自己当初写入的那个值相同才删除。这样一来A 删除前会发现自己持有的标识和当前 key 的 value 不一致于是放弃删除B 的锁就安全了。我这里还额外加了一层保障释放锁的操作必须放在finally中执行保证无论业务代码是正常返回还是抛出异常锁都会被释放避免异常路径下锁被长期占用直到超时。2.3 解锁为什么必须用 Lua 脚本上一步先检查 value 再删除这个逻辑如果我用 Java 代码来写会分成两步第一步从 Redis 里 GET 这个 key第二步判断相等后执行 DEL。这里又出现了一个新的原子性问题——两步之间锁是有可能过期的。还是用刚才那个场景A 在 GET 时锁还是自己的但 GET 之后、DEL 之前锁恰好过期了B 加锁成功。接着 A 执行 DEL又把 B 的锁删了。你发现没有检查了 value 依然存在误删的可能只是概率窗口变小了而已。正确的解法是把检查 删除包装成一个原子操作。Redis 支持 Lua 脚本并且脚本的执行是原子性的不会被其他命令插入。所以释放锁的标准姿势是这样的if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本的逻辑非常简单先取到这个 key 的值如果等于我传入的唯一标识就删除否则不做任何操作。因为整个过程在 Redis 服务端原子的执行所以从检查到删除之间不会插入任何其他客户端的命令。这就是为什么我一直强调加锁用SET NX PX解锁用 Lua 脚本。这两个操作都是原子的合在一起才是一把可用的分布式锁。2.4 锁的过期时间到底应该设置多长锁过期时间的设置是这个方案里最需要拿捏的地方。设置短了业务还没执行完锁就没了其他线程趁虚而入设置长了万一持有锁的节点挂了其他线程要等很久才能抢到锁系统可用性下降。我个人的经验法则是先评估业务代码的最长执行时间然后在最长时间的基础上乘以一个系数一般 1.5 到 2 倍。比如一个支付回调处理逻辑正常情况下 200 毫秒内能完成极端情况下压测到 1 秒那锁的过期时间我会设置成 2 到 3 秒。这样既保证正常执行不会被锁过期打断也留出了足够的缓冲应对偶发的慢查询。但这里有个前提业务的最长执行时间必须是可以预估的。如果你的业务里有外部 RPC 调用、远程接口等待或者大文件处理这种时间不可控的操作固定过期时间就会很危险。这种情况下我建议要么把锁时间设置得足够长然后接受节点挂掉后锁被长时间占用的风险要么直接使用带续期机制的 Redisson后面详细讲。自研方案更适合执行时间相对可控的业务。3. 完整代码实现与项目配置3.1 SpringBoot 项目中的基础配置我假设你已经在使用 SpringBoot 2.x 及以上版本并且项目里已经引入了spring-boot-starter-data-redis。这个依赖会自动装配RedisTemplate和StringRedisTemplate我们直接注入使用即可。你可以先看一眼你的pom.xml是否包含以下依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency然后是application.yml里关于 Redis 的基础配置这里只列我在项目里用过的最小配置spring: data: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2其中timeout是连接超时时间我通常会设成 3 秒避免 Redis 异常时线程长时间阻塞。连接池的max-active需要根据接口的并发量来调整不要盲目设得过大每一个连接都会占用 Redis 的客户端资源。3.2 封装一个支持重试的分布式锁工具使用StringRedisTemplate来实现自研方案的代码非常简单核心就两个方法加锁和释放锁。Component public class RedisLockUtil { private static final String LOCK_PREFIX lock:; private final StringRedisTemplate stringRedisTemplate; public RedisLockUtil(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } /** * 尝试加锁不等待 * * param key 业务锁的 key例如 order:pay:1001 * param value 唯一标识例如 UUID * param expireMs 锁过期时间 * return 是否加锁成功 */ public boolean tryLock(String key, String value, long expireMs) { String realKey LOCK_PREFIX key; Boolean result stringRedisTemplate.opsForValue() .setIfAbsent(realKey, value, expireMs, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(result); } /** * 释放锁使用 Lua 脚本保证原子性 */ public void unlock(String key, String value) { String realKey LOCK_PREFIX key; String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); stringRedisTemplate.execute(redisScript, Collections.singletonList(realKey), value); } /** * 阻塞式加锁带超时等待 */ public boolean lock(String key, String value, long expireMs, long waitMs) throws InterruptedException { long start System.currentTimeMillis(); long remaining waitMs; while (remaining 0) { if (tryLock(key, value, expireMs)) { return true; } Thread.sleep(50); remaining waitMs - (System.currentTimeMillis() - start); } return false; } }这个方法在项目里可以直接抄作业但有几个细节需要你根据自己的场景调整。setIfAbsent对应的是 Redis 的SET NX PX命令Spring Data Redis 已经封装好了原子语义你不需要自己拼命令。第二个方法execute执行 Lua 脚本KEYS[1]是锁的 keyARGV[1]是传入的 value通过Collections.singletonList传入。lock方法是阻塞式的它会循环尝试加锁每次休眠 50 毫秒直到拿到锁或者等待时间耗尽。这里没有用 Redis 的订阅通知机制去优化自研方案里用轮询就足够了因为大多数业务的等待时间都在毫米级。3.3 在业务代码中使用分布式锁工具类写完之后业务层的调用方式决定了锁是否真正生效。这里我给一个订单扣减库存的示例这种场景最容易出并发问题。Service public class OrderService { private final RedisLockUtil redisLockUtil; public OrderService(RedisLockUtil redisLockUtil) { this.redisLockUtil redisLockUtil; } public void deductStock(Long skuId, Integer count) { String lockKey stock:deduct: skuId; String lockValue UUID.randomUUID() : Thread.currentThread().getId(); boolean locked false; try { locked redisLockUtil.tryLock(lockKey, lockValue, 5000); if (!locked) { throw new BizException(操作太频繁请稍后再试); } // 核心业务逻辑检查库存、扣减库存、记录流水 doDeductStock(skuId, count); } finally { if (locked) { redisLockUtil.unlock(lockKey, lockValue); } } } }这里有三点要特别说明。锁的 key 一定要按业务维度设计好。stock:deduct:10001表示商品 ID 为 10001 的扣减库存锁不同商品的锁互不干扰。如果我把锁的 key 设计成一个全局唯一的字符串那所有商品的扣减操作都会串行化性能瞬间掉一个量级。锁的粒度是分布式锁设计中最重要的一个决策点粒度越细并发度越高但代码也会更复杂。lockValue使用UUID 当前线程ID组合保证不同线程之间不可能出现相同标识。有些教程会直接用 UUID其实也够了加上线程 ID 纯粹是为了排查问题方便——拿到锁的 value 就能直接反查是哪个节点上的哪个线程。finally里的释放动作必须判断是否真的拿到了锁。tryLock返回 false 的情况说明根本没有持有锁这时候执行unlock反而会误删其他线程的锁所以必须用一个布尔变量标记加锁结果。3.4 配合自定义注解简化调用工具类的方案有一个问题如果项目里有很多地方要加锁每个业务方法里都要手写加锁-业务逻辑-解锁的三段式模板代码噪音很大。我在项目后期做了一次重构把加锁的通用逻辑抽取成一个自定义注解实现声明式分布式锁。核心思路是定义一个注解RedisLock包含key、expireMs、waitMs三个属性然后通过 Spring AOP 切面拦截带有该注解的方法。在切面中先执行加锁逻辑加锁成功才调用目标方法最终在finally中释放锁。这样业务代码只需要加一行注解不需要关心锁的具体实现。这个方案适合多业务复用的场景但要注意 Spring AOP 的生效范围是 Spring 管理的 Bean 方法同一个类内部调用注解方法不会走代理所以不要出现this.doXxx()这种内部调用。这是我重构时踩到的第一个坑。4. 业务场景实战与参数调优4.1 秒杀场景下的锁粒度规划先以秒杀场景为例。假设一个商品有 100 件库存海量用户同时抢购。如果你把整个秒杀活动的锁设计成一把全局锁比如lock:seckill:1001那么所有用户的操作都串行化了。这个方案在技术上是正确的但在性能上是灾难的每个请求都要排队拿锁拿到锁后才能执行扣库存逻辑请求的响应时间会被拉长到不可接受的程度。更合理的做法是把锁的粒度细分到这个商品维度而不是活动维度。比如锁的 key 设计成lock:seckill:1001:stock只有对这个商品库存的修改操作才互相竞争用户的预下单、校验等操作根本不需要加锁。更进一步如果你用 Redis 的 Lua 脚本直接在服务端完成判断库存-扣减库存两步操作连分布式锁都不需要因为脚本本身就是原子的。分布式锁在这种场景里其实更适合用在创建订单-锁定库存这种跨多个组件、需要保护整个业务事务的场景。所以我的判断标准是能靠 Redis 原子操作解决的优先用原子操作需要保护一段跨多个操作/多个资源的业务逻辑时才上分布式锁。4.2 定时任务集群执行场景的正确姿势我前面提到的定时任务重复执行问题其实有两种解法。一种是在定时任务内部手动加分布式锁锁的 key 用任务的名字抢到锁的节点才执行锁的过期时间设置成比任务最大运行时间略长即可。代码大致长这样Component public class ReconciliationTask { private final RedisLockUtil redisLockUtil; Scheduled(cron 0 0 2 * * ?) public void executeReconciliation() { String lockKey task:reconciliation; String lockValue UUID.randomUUID() : IPUtil.getLocalIp(); if (!redisLockUtil.tryLock(lockKey, lockValue, 120000)) { log.info(本次对账任务已在其他节点执行当前节点跳过); return; } try { // 对账核心逻辑 doReconcile(); } finally { redisLockUtil.unlock(lockKey, lockValue); } } }这种方式适合没用分布式调度框架的项目。如果项目里引入了 XXL-JOB 或者其他分布式调度中间件它们自身就提供了服务级别的分片和互斥能力完全可以把锁的控制权交给调度框架业务代码里就不需要再写分布式锁了。4.3 如何选择锁的超时时间与等待时间这两个参数的设置直接影响系统的吞吐量和稳定性。我给几个经验值作为参考。锁的过期时间最简单的方法是先做一轮压测看看业务方法在并发场景下的 P99 响应时间是多少然后取这个值的 2 到 3 倍。我之前处理过一个库存扣减的接口压测 P99 是 800 毫秒正常情况 200 毫秒内能完成所以过期时间设为 3000 毫秒既不会因为锁过期导致并发穿透也不会在节点宕机时让锁长时间占用。等待时间的设定则需要结合业务容忍度。对于用户交互类的接口等待时间一般不建议超过 1 到 2 秒超过就直接提示系统繁忙请稍后重试对于异步任务、消息消费这种非实时场景等待时间可以放宽到几十秒甚至无限等待因为阻塞不会影响用户体验。我在消息消费场景里就设置过一个 30 秒的等待时间宁可多等一会也要确保消息只被完整处理一次。如果你拿捏不准有一个取巧的办法把锁的过期时间默认设成一个较大的值比如 30 秒然后把等待时间设成 5 秒一边运行一边观察实际耗时再逐步把过期时间调小。先用正确性换稳定性再追求极致的响应速度。5. 常见问题与排查实录5.1 锁被误删为什么加锁成功了还是出问题我之前有一个真实案例线上突然出现一条数据被重复处理的告警查了半天发现是锁被误删导致的。当时业务代码里解锁的逻辑长这样stringRedisTemplate.delete(lockKey);看起来没什么问题加锁成功、业务执行完、释放锁。但问题出在锁过期时间太短。业务线程 A 执行耗时超过了锁的过期时间锁自动失效了线程 B 趁机加锁成功这时候 A 终于执行完了它不校验 value直接调用delete把键删了。B 的锁就这么被 A 删除线程 C 加锁成功B 和 C 同时执行数据就重复处理了。这个问题的根源有两个一是锁过期时间设置不合理二是不校验 value 就删除。当时我把过期时间从 2 秒调到了 5 秒同时把删除逻辑替换成带 value 校验的 Lua 脚本之后这个告警再也没有出现过。5.2 锁过期时间引发的业务未完成问题锁过期时间太短会引发误删那设置足够长是不是就万事大吉了也不一定。我见过一个文件导出的场景业务要查询大量数据并生成 Excel平时 3 秒能完成但有一次数据库发生了一次慢查询整个导出过程跑了 12 秒而锁的过期时间只有 5 秒。结果就是线程 A 持有锁还没执行完锁就过期了线程 B 也进来了两个线程同时导出同样的数据文件内容重复、用户收到两次下载链接。这个问题比误删更隐蔽因为从日志上看加锁和解锁都成功了业务也没有报错只能通过生成的文件内容出现重复这类业务现象去反推。要彻底解决这个问题要么给锁的过期时间留出极大的余量比如业务正常的 20 倍以上要么用 Redisson 的看门狗机制——它会在锁快过期的时候自动续期。如果你用的是自研方案我的建议是对执行时间不可控的业务务必评估最坏情况并设置足够长的过期时间之后再配合告警监测锁的占用时间一旦发现锁占用时间异常第一时间排查。5.3 并发量高时 Redis 主从切换导致的锁丢失还有一个我在项目压测时遇到的问题Redis 采用了主从架构主节点负责写从节点负责读。当主节点宕机触发故障转移时新的主节点可能并没有同步到旧的锁 key导致整个分布式锁在故障切换的瞬间集体失效。具体过程是这样的线程 A 在主节点上写入锁 keylock:order:1001成功了。主节点还未来得及把数据同步给从节点主节点宕机了。哨兵触发故障转移从节点晋升为主节点。线程 B 在新的主节点上加锁发现lock:order:1001并不存在加锁成功。此时线程 A 和线程 B 同时持有同一把锁业务冲突发生。这个问题的根因是 Redis 主从复制是异步的主节点写入成功后数据并没有同步到从节点。要多大的概率才会遇到确实是低概率但对于金融、支付这类强一致性的业务低概率事件一旦发生就是生产事故。业界对这个问题也有讨论Martin Kleppmann 和 Redis 作者 Salvatore Sanfilippo 之间有过著名的争论双方观点各有道理。我的实践是在大多数普通业务场景下Redis 主从切换的概率极低自研锁或者 Redisson 的标准模式就够用了但对于资金流水、账户余额这类绝对不能出错的场景要么用 Redisson 的RedissonMultiLock对多个独立 Redis 节点同时加锁要么考虑其他分布式锁中间件。这是一个架构权衡的问题没有绝对正确的答案。5.4 常见问题速查表为了方便排查我把自研这套分布式锁常见问题整理成一个速查表对应现象、原因、排查方法一目了然。现象可能原因排查方法接口偶发报操作太频繁锁过期时间设置过短或业务执行时间出现尖刺检查业务 P99 耗时和锁过期时间对比数据被重复处理锁误删value 未校验导致 A 删了 B 的锁加日志输出锁的 value确认是谁的锁被删除任务永远无法执行持有锁的节点宕机锁未设置过期时间检查 Redis 中锁 key 的 TTL改用 SET NX PX加锁后出现死锁解锁没有放在 finally 中异常路径未释放检查业务代码锁的释放是否覆盖所有异常分支锁的过期时间到了但业务还未完成业务执行耗时超过锁过期时间延长过期时间或改用自动续期方案6. 选型建议与经验总结6.1 自研 vs Redisson做技术选型时团队内部应该先搞清楚一个问题你需要的是一把最简可用的锁还是一把高可用、防患于未然的锁自研方案的优势在于轻量和可控。几十行代码就能实现核心功能没有额外引入依赖锁的 key 设计、过期时间策略完全由自己掌控。缺点也很明显没有自动续期没有可重入支持没有公平锁也没有完善的监控度量。如果业务形态比较固定、执行时间可控、Redis 集群稳定性良好自研方案是够用的。Redisson 是目前 Java 生态里最成熟的基于 Redis 的分布式锁框架在 SpringBoot 里接入也很简单Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer().setAddress(redis://127.0.0.1:6379); return Redisson.create(config); }使用方式也很简单RLock lock redissonClient.getLock(lock:order:1001); boolean locked lock.tryLock(0, 30, TimeUnit.SECONDS); try { // 业务逻辑 } finally { if (locked) { lock.unlock(); } }Redisson 自带看门狗续期如果你不手动指定锁的过期时间它会按照默认的 30 秒锁时长来加锁并在锁的使用期间每 10 秒钟自动为一个还处于持有状态的锁续期。这个过程对开发者完全透明可以有效避免业务还没执行完锁就过期的经典问题。它还提供可重入锁、公平锁、读写锁等多种实现接口类型丰富。我个人的经验是如果你的项目允许引入第三方框架Redisson 是更省心的选择如果项目规范严格限制依赖数量或者你只是想理解原理并不想背一个框架的 API那自研方案完全可以作为起步方案。毕竟核心的加锁和解锁也就两个方法理解了原理之后迁移到 Redisson 就是换一层 API 而已。6.2 从能用到好用的关键策略无论你选哪种方案分布式锁要好用还有几个重要的辅助策略。加锁业务 key 的规范要提前定义好。给锁的 key 加业务前缀比如lock:order:pay:1001、lock:stock:deduct:1001并加入订单号或用维度 ID。这样在 Redis 客户端里查看锁时能直接知道这把锁属于哪条业务线排查问题的难度会大幅下降。锁的处理要内置监控和告警。我一般会在加锁失败的地方打 WARN 日志记录 key 和失败的等待时间如果连续多次加锁失败触发异常告警。还会定期扫描 Redis 中过期时间特别长的锁 key判断是否存在锁泄漏的情况。没有监控的分布式锁到了出问题的时候会非常被动因为你完全不知道锁到底被谁持有、持有了多久。控制锁内部的业务操作时间。分布式锁保护的是并发共享的资源不是让你在锁内执行耗时操作。锁内部的业务逻辑应该尽量精简最好是纯内存操作加少量数据库操作任何耗时的远程调用都应该想办法移出锁的范围。如果某个场景确实需要长时间持锁那说明你的锁设计或者业务拆分还有优化的空间。6.3 我在实际项目中积累的几点体会我这里给你分享几个实战项目的具体写法这些都是在自研方案中验证过的地方。关于续期机制我提供了一个参考实现。实现方式是启动一个异步定时任务在锁即将过期前检查锁的 value 是否仍为自己持有是则续期业务结束后需要主动中断续期任务private void renewLock(String key, String value, long expireMs, long renewIntervalMs) { ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); stringRedisTemplate.execute(redisScript, Collections.singletonList(key), value, String.valueOf(expireMs)); }, expireMs / 3, renewIntervalMs, TimeUnit.MILLISECONDS); }关于可重入锁我在设计时没有采用 Redisson 那种在 Redis 数据结构中保存计数器的方式而是用一个ThreadLocal记录当前线程的持有次数。同一线程再次加锁时如果 ThreadLocal 中已经有值且未被释放直接计数加一即可解锁时计数减到零才真正删除 Redis 中的 key。这套方案在历次生产环境异常的处置中表现稳定虽然代码量不大但对于理解加锁–解锁的原子性、过期时间设计、多节点互斥这些底层概念非常有帮助。我更推荐你在二维信息之外多参与实际场景去验证概念。代码是用出来的不是看出来的。