
先聊个现实问题为什么单机synchronized不香了偏要自己手写一个 Redis 分布式锁原因很简单——当你的服务从单实例变成多实例部署或者微服务拆成了多个进程Java 自带的锁只对同一个 JVM 内的线程生效。A 机器的线程锁住了B 机器的线程根本看不见这把锁照样冲进临界区库存超卖、重复执行、状态错乱就全来了。这时候就需要一把大家都看得见的锁而 Redis 因为单线程执行命令、性能极高、部署普遍就成了实现分布式锁最常用的载体。网上关于手写 Redis 分布式锁的教程很多但大部分只给一个SETNX加EXPIRE的雏形看了能跑一上生产就翻车。这篇文章我把自己实际落地的一套分布式锁完整梳理一遍从最简单的版本逐步演进到生产可用包含原子性、误删、续期、可重入、集群容灾这些绕不开的坎适合那些准备自己封装锁组件的中级工程师也适合想彻底搞懂 Redis 分布式锁原理的面试选手。我会尽量说清楚每一步背后的为什么而不只是让你抄代码。1. 手写之前先搞清楚这把锁到底在解决什么问题1.1 从临界区说起单机锁和分布式锁的本质差异并发编程里有个非常基础的概念——临界区Critical Section。它指的是多个线程同时访问共享资源时需要被串行执行的那段代码。单机环境下我们用synchronized或ReentrantLock保证同一时刻只有一个线程进入临界区这是因为 JVM 内部有监视器锁Monitor机制线程间通过共享内存协调状态锁的获取和释放都发生在同一个进程内。但分布式环境下问题就变了多个服务实例部署在不同的机器上甚至在不同机房它们之间没有共享内存JVM 的锁机制完全失效。这时候需要一种所有实例都能访问到的公共存储来承担锁状态的协调者Redis 恰好满足这个条件——它是独立部署的服务任何实例都能通过网络读写它而且 Redis 是单线程处理命令的这就保证了多个客户端对同一个 key 的操作是原子性的。说得更直白一点单机锁靠内存分布式锁靠公共存储。经典实现方案有三种基于 Redis、基于 ZooKeeper、基于数据库。Redis 方案胜在性能极高、实现简单、无额外组件依赖是绝大多数互联网公司的首选。1.2 Redis 分布式锁的使用场景以及它为何优于裸 SETNX分布式锁的典型场景包括防止缓存击穿/缓存重建竞争多个实例同时发现缓存失效同时去数据库查询重建缓存需要保证只有一个实例去查库。分布式定时任务调度多个实例部署了同一套定时任务需要保证同一个任务在同一时刻只被一个实例执行。库存扣减/订单创建多实例同时处理用户请求避免超卖或重复下单。分布式事务的幂等控制防止消息重复消费导致的重复处理。很多人觉得锁嘛不就是 SETNX 一个 key 吗这种理解会吃大亏。裸 SETNX 存在三个致命问题没有过期时间宕机死锁、释放时可能误删别人的锁任务超时导致锁被别人拿走、加锁和设置过期时间不是原子操作中间崩溃导致死锁。这些问题我在后面的章节里逐一展开。1.3 为什么还需要自己手写直接引入 Redisson 不行吗你可能会问Redisson 不是已经封装了很成熟的分布式锁吗RLock lock redissonClient.getLock(orderLock)拿过来直接用不就行了我先说结论生产环境建议直接用 Redisson但强烈建议你自己动手写一遍。原因有三点理解原理才能用好框架Redisson 的看门狗机制、Lua 脚本、可重入特性如果你不理解底层原理遇到锁失效、锁误删、锁续期异常的排查会非常痛苦。Redisson 也不万能它默认的看门狗续期逻辑在某些极端场景GC 停顿过长、网络分区下也会存在锁丢失的可能你需要知道它的边界在哪。很多场景你需要定制比如自定义锁的获取超时策略、自定义业务标签、对接公司内部的监控告警体系这些都需要你理解锁的底层实现才能二次开发。所以这篇文章的核心目的不是让你抛弃 Redisson 去重复造轮子而是通过手写一遍彻底搞懂分布式锁的每一个细节。等你看完这套实现再回头去看 Redisson 源码会有一种原来如此的通透感。2. 从 SETNX 到 Lua 脚本锁的原子性是第一原则2.1 第一版代码为什么SETNX EXPIRE的组合是错的先来看大多数教程给的入门写法。加锁逻辑一般是这样public boolean lock(String lockKey, String requestId, int expireSeconds) { // 第一步尝试获取锁 Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId); if (Boolean.TRUE.equals(success)) { // 第二步设置过期时间 stringRedisTemplate.expire(lockKey, expireSeconds, TimeUnit.SECONDS); return true; } return false; }这段代码的问题非常隐蔽——但它恰恰是面试里最容易挖的坑SETNX 和 EXPIRE 是两条独立的 Redis 命令不是原子操作。如果第一步执行成功后进程突然崩溃比如机器宕机、JVM OOM、网络闪断第二步的 EXPIRE 还没来得及执行这个 key 就永远存在 Redis 里没有过期时间后续所有实例都无法获取锁形成全局死锁。这种两步操作中间崩溃导致的状态不一致在分布式系统里是极其危险的事故源。严谨的做法是让加锁和设置过期时间合成一条原子指令。2.2 SET 命令的统一与演进以及锁值必须唯一的原因Redis 从 2.6.12 版本开始SET命令支持了NX和EX参数可以一次性完成不存在才写入和设置过期时间两个操作。加锁代码升级为public boolean lock(String lockKey, String requestId, int expireSeconds) { // 一次性完成不存在才写入 设置过期时间原子操作 String result stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(result); }这里有一个非常关键的细节锁的 value 必须是唯一标识通常用 UUID 线程ID。为什么因为释放锁的时候需要校验这把锁是不是我加的。如果不校验直接 DEL会出现一个经典的误删场景A 实例获取锁设置 value 为A-request-id业务执行时间较长。锁的过期时间到了key 自动消失。B 实例获取锁设置 value 为B-request-id开始执行业务。A 实例终于执行完进入释放锁逻辑直接DEL lockKey。结果A 把 B 正在持有的锁删掉了。C 实例又获取到锁三个实例同时进入临界区分布式锁完全失效。所以释放锁的时候必须先比较 value 是否是自己的是自己的才删。这个比较 删除的过程也必须保证原子性如果你先GET再DEL中间还是有可能被其他线程干扰虽然概率极低但分布式系统追求的是不靠概率做事所以需要引入 Lua 脚本。2.3 手写解锁 Lua 脚本比较 删除的原子化Redis 从 2.6 开始支持 Lua 脚本可以在服务端原子地执行一段脚本中途不会插入其他命令这正好用来实现先校验再删除的完整逻辑。脚本如下-- KEYS[1]锁的 key -- ARGV[1]当前线程/请求的唯一标识 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end对应的 Java 代码private static final DefaultRedisScriptLong UNLOCK_SCRIPT new DefaultRedisScript( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end, Long.class ); public boolean unlock(String lockKey, String requestId) { Long result stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(lockKey), requestId ); return Long.valueOf(1).equals(result); }这里解释一下为什么 Lua 脚本能保证原子性Redis 是单线程执行命令的在执行一段 Lua 脚本时Redis 会阻塞其他客户端的命令请求直到脚本执行完毕。所以脚本内部的GET 判断和DEL 删除之间不可能有别的客户端插入操作。这在原理层面锁死了比较和删除不可分割这个约束。到这里一套最基本的、没有明显逻辑错误的分布式锁就成型了。它有四个要素原子加锁SET NX EX、唯一锁标识value 用 UUID、自动过期防止死锁、原子解锁Lua 脚本。这是分布式锁的第一原则——原子性。后续所有的复杂性都是在这个基础上打补丁。3. 完整实现一版生产可用的 Redis 分布式锁3.1 锁的骨架设计核心方法定义与全局异常处理策略在动手写完整代码前先想清楚一个锁组件对外暴露哪些能力。我这边设计的接口比较简洁核心就四个方法boolean tryLock(String key, long waitTime, long leaseTime, TimeUnit unit)尝试加锁支持设置获取锁的等待时间和锁的自动释放时间。void lock(String key, long leaseTime, TimeUnit unit)阻塞加锁拿不到锁就一直等。boolean unlock(String key)释放锁。boolean isHeldByCurrentThread(String key)判断当前线程是否持有锁用于监控和调试。先说一个很重要的设计决策加锁失败后要不要阻塞等待我的建议是——默认不要裸阻塞用带超时时间的tryLock。原因很实际分布式场景下持有锁的实例可能因为 Full GC、网络抖动等原因比你预期慢得多如果你无限等待请求线程会大量堆积反而拖垮整个服务。带超时等待可以让调用方快速失败走降级逻辑这是生产系统的基本素养。另一个设计决策是锁的粒度控制。锁的 key 不要设计得太粗比如整个订单服务一把锁那么所有订单操作全部串行化性能会非常差。应该按业务维度拆分比如lock:order:${orderId}、lock:user:${userId}、lock:sku:${skuId}。锁的粒度越细并发度越高但 key 数量也越多需要在二者之间权衡。我见过一个典型的生产事故某团队用lock:inventory当全局锁所有库存操作共享一把锁大促时 Redis 连接被打满就是因为锁粒度设计有问题。3.2 Java 实现带看门狗-续期的完整锁代码现在给出我在项目中实际验证过的一套完整实现。我基于 Spring Boot 的StringRedisTemplate编写方便大家直接嵌入自己的工程。import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; import java.time.Duration; import java.util.Collections; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.LockSupport; public class RedisDistributedLock { private static final String LOCK_PREFIX lock:; private static final long DEFAULT_LEASE_TIME_MILLIS 30000L; // 默认锁自动释放时间30秒 private static final long WATCHDOG_INTERVAL_MILLIS 10000L; // 看门狗续期间隔10秒 private final StringRedisTemplate redisTemplate; private final ThreadLocalString lockValueThreadLocal new ThreadLocal(); public RedisDistributedLock(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } // 加锁附带看门狗续期默认过期时间 30 秒 public boolean lock(String key, long waitTimeMillis) throws InterruptedException { return tryLock(key, waitTimeMillis, DEFAULT_LEASE_TIME_MILLIS, TimeUnit.MILLISECONDS); } public boolean tryLock(String key, long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { String lockKey LOCK_PREFIX key; String requestId UUID.randomUUID().toString() : Thread.currentThread().getId(); long leaseTimeMillis unit.toMillis(leaseTime); long deadline System.currentTimeMillis() waitTime; // 自旋尝试获取锁直到成功或超时 while (System.currentTimeMillis() deadline) { Boolean success redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMillis(leaseTimeMillis)); if (Boolean.TRUE.equals(success)) { lockValueThreadLocal.set(requestId); // 启动看门狗线程自动续期 startWatchdog(lockKey, requestId, leaseTimeMillis); return true; } // 避免自旋过密打爆 Redis短暂休眠 LockSupport.parkNanos(50 * 1000 * 1000L); // 50ms } return false; } // 释放锁 public boolean unlock(String key) { String lockKey LOCK_PREFIX key; String requestId lockValueThreadLocal.get(); if (requestId null) { return false; } Boolean released executeUnlockScript(lockKey, requestId); if (Boolean.TRUE.equals(released)) { lockValueThreadLocal.remove(); return true; } return false; } // 守护线程续期 private void startWatchdog(String lockKey, String requestId, long leaseTimeMillis) { Thread watchdog new Thread(() - { while (!Thread.currentThread().isInterrupted()) { try { Thread.sleep(WATCHDOG_INTERVAL_MILLIS); // 续期仅当锁的 value 仍是自己的才重新设置过期时间 String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(expire, KEYS[1], ARGV[2]) else return 0 end; RedisScriptLong script new DefaultRedisScript(lua, Long.class); redisTemplate.execute(script, Collections.singletonList(lockKey), requestId, leaseTimeMillis / 1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (Exception e) { // 续期失败不能贸然停止稍后重试。但需要告警监控 } } }, redis-lock-watchdog- lockKey); watchdog.setDaemon(true); watchdog.start(); } private Boolean executeUnlockScript(String lockKey, String requestId) { String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; RedisScriptLong script new DefaultRedisScript(lua, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(lockKey), requestId); return Long.valueOf(1).equals(result); } }这里有个容易踩坑的细节锁的 value 为什么是UUID 线程IDUUID 保证跨实例的唯一性线程ID 保证同一实例内不同线程的区分度。其实只靠 UUID 就够了加上线程ID 纯粹是为了排查问题时能一眼看出是哪个实例的哪个线程持有锁。再看续期逻辑也就是大家常说的看门狗机制。默认锁自动释放时间是 30 秒但业务可能执行超过 30 秒比如批量导入、复杂报表计算如果锁在业务执行期间就自动过期了其他线程就会趁虚而入。看门狗线程每 10 秒检查一次只要锁还在自己手上value 匹配就把过期时间重新设置为 30 秒。这样只要业务线程活着锁就一直不会过期业务线程挂了看门狗线程也会随之停止它是 daemon 线程会跟着业务线程所在 JVM 退出锁到点自动释放。3.3 自旋等待的细节频率控制、过期时间与业务超时的关系上面代码里的自旋等待有一个关键参数需要重点说明waitTime获取锁的等待时间和leaseTime锁的持有时间应该如何设置先说 waitTime。这个值取决于你的业务可以容忍的最长阻塞时间。比如一个用户请求的接口最长响应时间是 2 秒那你最多只能等 1.5 秒的锁超过这个时间直接返回失败让用户重试或走降级。如果设成 30 秒甚至 60 秒用户会等到超时而且线程池会被占满这是线上最常见的分布式锁滥用事故。再说 leaseTime。这个值需要你对自己业务的执行时间有准确评估。经验法是leaseTime 至少是业务最长执行时间的 2 倍。比如业务最长执行 5 秒leaseTime 设 10~15 秒比较稳妥留出一定的 GC 停顿和网络抖动余量。如果设得太短1 秒业务还没跑完锁就过期了后续线程进来造成并发冲突设得太长10 分钟如果持有锁的线程挂了其他线程要等 10 分钟才能拿到锁故障恢复时间太长。自旋的休眠间隔也有讲究。我见过有人用Thread.sleep(10)疯狂自旋Redis 的 QPS 直接被打到几百甚至上千——这完全没有必要。锁竞争通常发生在毫秒到秒级50ms 的轮询间隔足够感知锁的释放同时对 Redis 的压力非常小。如果你对响应时间极其敏感可以缩短到 10~20ms但一定要接好监控观察 Redis 的 OPS 增长情况。3.4 多实例部署时是否还需要 JVM 内存锁做一级缓存这里聊一个实战中容易忽略的高级话题分布式锁和本地锁的组合使用。如果你在tryLock的时候直接打 Redis而业务系统的 QPS 很高比如秒杀场景每秒几千个请求Redis 会被锁请求打满。一个常见的优化手段是两级锁思想先在本地用ReentrantLock做一次本地互斥只有拿到本地锁的线程才去竞争分布式锁。这样做有两个好处一是大大减少 Redis 的访问量二是本地自旋的延时远低于网络 RTT响应更快。但注意本地锁必须和分布式锁的粒度完全对齐。你对order:123加了本地锁那么所有访问order:123的线程都必须经过同一把本地锁否则本地锁形同虚设。在多实例场景下本地锁只负责降低对 Redis 的争抢真正的互斥边界依然要靠分布式锁保证。这套组合在大型高并发系统中非常实用也是我对上面代码做的最主要的性能优化。4. 锁的可用性从单机 Redis 到集群环境哪些问题必须考虑4.1 主从切换导致锁丢失的问题以及 RedLock 的争议与取舍到目前为止我们假设的都是单机 Redis 或主从架构下主节点正常的情况。但一旦 Redis 主节点发生故障切换failover分布式锁就会面临一个非常棘手的丢锁问题。场景是这样的A 实例在主节点master上成功写入锁 key。此时主节点还没把这条数据同步到从节点slave主节点就挂了。哨兵Sentinel检测到故障把某个从节点提升为新的主节点。但新主节点上没有 A 实例的锁数据B 实例趁机也写入了同一个锁 key成功加锁。于是 A 和 B 同时认为自己持有锁分布式锁的互斥性被彻底打破。这个问题有没有完美的解决方案业界争论很多。Redis 官方给出的建议是RedLock 算法——向多个独立的 Redis 节点通常是 5 个同时申请锁只有超过半数节点加锁成功才认为真正拿到了锁。但这个方案在分布式系统领域一直有争议以 Martin Kleppmann《设计数据密集型应用》作者为代表的观点认为 RedLock 在不可暂停的进程和网络分区场景下依然存在安全漏洞并不比单机版方案强多少。我的个人建议是分场景取舍普通的业务锁比如防重复提交、定时任务互斥单机 Redis 哨兵架构足够不必追求绝对的安全因为锁丢失最多导致并发重复执行一次业务侧通过幂等兜底即可。真正的强一致场景比如库存扣减、金融交易不要依赖任何基于 Redis 的方案请直接上 ZooKeeper 或 etcd 这类 CP 系统或者引入数据库行锁、乐观锁作为最终兜底。把钱相关的事交给真正可靠的系统。非要兼顾性能和可用性在业务层面做防重/幂等控制锁的丢失由更上层的机制兜底这是目前工业界最务实的做法。4.2 业务执行时间超过锁过期时间续期、快慢链路和优雅降级前面提到看门狗续期解决了业务没执行完锁就过期的问题但在真实项目中还会遇到一个非常尴尬的场景业务执行时间不仅超过 30 秒而且超过 5 分钟、10 分钟甚至更久。看门狗线程一直续期锁一直被同一线程占着其他线程永远等不到锁这同样是一场事故。怎么处理这种长耗时业务我有几个实战建议评估业务执行时间的分布。如果发现大量请求的锁持有时间超过 30 秒说明锁粒度或业务设计有问题需要拆锁或改异步化而不是一味拉长 leaseTime。设置最大占用时间告警。只要锁被同一线程持有的时间超过阈值比如 30 秒就上报监控人工介入分析是否出现死锁或慢 SQL。优雅降级。在tryLock超时后不要直接抛异常而是根据业务类型选择降级策略比如返回缓存数据、走消息队列异步处理、或者直接提示用户稍后重试。避免把分布式锁当事务用。分布式锁只解决互斥不解决一致性。如果你在锁保护的代码里操作多个数据源数据库、缓存、消息队列任何一个步骤失败都可能导致数据不一致。锁保护的是一个脆弱的时间窗口而不是一个可靠的事务边界。这里特别提醒锁内不要再调外部 HTTP 接口或 RPC。锁本身就限制了并发度如果持锁线程还在等一个超时的远程调用其他线程全部被堵死。我见过一个案例某个团队在锁内调用第三方支付接口第三方服务故障导致超时 10 秒线上请求全部堆积最后要靠手动删 key 恢复——这种设计一开始就不该出现。4.3 可重入锁要不要实现如何用 ThreadLocal 和计数器实现先解释一下什么叫可重入Reentrant同一个线程可以多次获取同一把锁而不会自己被自己挡住。单机ReentrantLock天然支持可重入Redis 分布式锁默认不支持——同一个线程第一次加锁成功后如果递归调用了加锁逻辑会因为 key 已存在而加锁失败造成自锁。要不要实现可重入我的建议是默认实现它理由很实际——业务代码往往会在一个大的锁保护区内调用其他方法那些方法内部可能也加了同一把锁。你不做可重入业务就得多写一堆是否已持有锁的判断逻辑非常容易被忽略而引发死锁。实现方案跟 JUC 里的思路一致在锁内维护计数器加锁时先检查 key 的 value 是否是当前线程的唯一标识。如果不是走正常加锁逻辑如果是说明是同一个线程在重入直接把计数器 1并重置过期时间。解锁时同理先把计数器 -1只有计数器归零时才真正删除 key。因为判断 value 是否属于自己 计数器增减需要原子性所以依然要用 Lua 脚本。我在项目中实现的版本大致长这样-- KEYS[1]锁 key -- ARGV[1]当前线程唯一标识 -- ARGV[2]过期时间秒 if redis.call(get, KEYS[1]) ARGV[1] then redis.call(incr, KEYS[1] .. :count) redis.call(expire, KEYS[1], ARGV[2]) return 1 else if redis.call(set, KEYS[1], ARGV[1], NX, EX, ARGV[2]) then redis.call(set, KEYS[1] .. :count, 1, EX, ARGV[2]) return 1 end return 0 end解锁脚本if redis.call(get, KEYS[1]) ARGV[1] then local count redis.call(get, KEYS[1] .. :count) if count and tonumber(count) 1 then redis.call(decr, KEYS[1] .. :count) return 1 else redis.call(del, KEYS[1]) redis.call(del, KEYS[1] .. :count) return 1 end else return 0 end这里有个很容易被忽视的 Bug 要提醒你计数器的count这个 key 也必须设置过期时间并且要和锁 key 的生命周期一致。否则业务代码里出现异常导致计数器和锁的删除逻辑没有成对执行Redis 里会残留大量的:count垃圾 key内存越积越多。4.4 关于可用性的个人取舍单机锁、哨兵、Cluster 与多写降级开发环境随便玩生产环境要认真考虑 Redis 的高可用架构。单机 Redis 一旦宕机锁服务就完全不可用所有业务请求直接透传或失败这是不可接受的情况。我见过很多团队在 Redis 上部署了哨兵架构就以为万事大吉实际上像上节提到的主从切换丢锁问题依然存在。所以基于锁的可用性和业务的强一致性之间的权衡我最终的实践是分层设计第一层Redis 分布式锁提供高性能的互斥能力应对绝大多数常规场景。第二层关键业务涉及资金、订单状态流转在数据库层面做唯一约束比如唯一索引这是最后的物理兜底即使分布式锁失效数据库也能挡住重复操作。第三层所有写操作都要实现幂等可以使用 Redis 的幂等 key 或数据库的幂等表锁只是第一道防线不是唯一防线。这个思路想明白之后你对分布式锁的定位就不会跑偏——它只是一个高性能的互斥协调器而不是数据一致性的最后一根稻草。依靠多级防御体系即使 Redis 抖动、锁丢失你的系统依然可以健壮运转。这是我在线上踩过无数次坑之后最想分享的一句话。5. 生产落地避坑指南我踩过的那些坑和验证方案5.1 坑位一锁 key 未设置过期时间导致的全局死锁完整复盘这个坑是我最早入行时踩的。当时团队做电商活动为了防止用户重复提交订单Redis 里存了submit_order:{userId}这个 key。代码逻辑很简单Boolean hasSubmitted redisTemplate.hasKey(submit_order: userId); if (Boolean.FALSE.equals(hasSubmitted)) { redisTemplate.opsForValue().set(submit_order: userId, 1); // 执行下单逻辑... }表面看没什么问题——用hasKey判断用set写标记。但由于代码里没有设置过期时间一旦判断和写入之间发生异常比如实例重启这个 key 就永远留在 Redis 里用户永远无法再次下单。更严重的是这个 key 没有设置过期时间它还不会被自动清理人工排查时还得先猜到具体是哪个 key 出了问题。这个案例虽然是幂等标识而不是严格的分布式锁但和SETNX加了 key 忘了EXPIRE属于同一种病根。任何写入 Redis 的互斥标记都必须带上过期时间没有例外。后来我推行的规则是所有加锁和幂等 key 的写入操作必须使用SET key value NX EX seconds这种原子命令禁止拆成两步写。5.2 坑位二锁的守护线程意外中断续期逻辑失效导致并发穿透第二个坑和看门狗续期有关。我最初实现续期时用的是ExecutorService里的一个固定线程池续期任务在线程池里跑。但有一次生产上线后我们发现有少量请求报错排查发现是线程池被打满、续期任务被拒绝执行锁提前过期并发穿透进了临界区。后来我把续期逻辑改成了每个锁独立的守护线程像前面代码中展示的那样。用daemon线程的好处是它会跟着持有锁的业务线程一起结束——业务线程执行完、释放锁时守护线程也会退出业务线程异常终止守护线程也会随着 JVM 退出而被回收。这比共享线程池的方式干净得多也避免了对全局线程池资源的争抢。当然守护线程方案也有短板如果业务持有大量锁比如上千个并发锁对应会创建上千个守护线程对 JVM 线程数有一定压力。我的权衡方式是锁的并发量控制在几百以内时用守护线程方案锁量大时改用批量续期定时任务——一个后台线程每 10 秒扫描所有仍被持有的锁逐一续期。两种方案各有优劣按场景取舍即可。5.3 坑位三不用 Lua 脚本的 GET DEL 解锁方式这个坑我前面章节隐约提到过但值得单独拿出来说因为这是面试中命中率最高的问题也是线上最容易复现的问题——你只要在两行代码之间加一条System.out.println()误删概率就会暴增。错误解锁代码public void unlock(String lockKey, String requestId) { String value redisTemplate.opsForValue().get(lockKey); if (requestId.equals(value)) { redisTemplate.delete(lockKey); } }这段代码的问题GET和DEL是两条独立命令中间存在时间窗口。虽然单线程场景下这个窗口极小但在高并发下完全可能发生。A 线程执行完GET校验还没执行DEL锁恰好过期了B 线程写入拿到锁A 线程的DEL紧接着执行直接删掉 B 的锁。即使是低概率事件在高流量系统里也会变成必然事件。这个问题的唯一正解就是 Lua 脚本。Redis 单线程执行 Lua 脚本的特性保证了校验和删除不可分割。用过一次 Lua 之后你会对命令之间没有原子性这句话有切肤的感受。5.4 验证方案用并发模拟脚本打一下自己的锁怎么判断锁是否生效代码写完了怎么验证它真的可靠我的建议是不要只靠眼睛看写一个简单的并发复现程序暴力压一下。下面是一个测试思路你可以照着做准备 10 个线程每个线程循环尝试获取同一把锁 100 次每次获取成功后模拟 10ms 的业务操作最后释放锁。在业务操作里定义一个静态计数器activeCount进入临界区时退出时--全程记录activeCount的最大值。当锁生效时activeCount的最大值必须是 1。如果出现大于 1 的情况说明锁失效并发穿过了临界区。用CountDownLatch让所有线程同时起跑用AtomicInteger记录maxActive。压测结果出现maxActive 1的那一刻你的锁一定有问题不用怀疑。我实际测试过用裸SETNX EXPIRE语法跑上述并发程序maxActive偶尔会飘到 2 或 3——虽然概率不高但只要发生了就意味着线上存在并发穿透的隐患。换成 Lua 脚本 唯一标识的完整版后maxActive稳定在 1。这个测试程序几十行代码就能写完建议你亲手跑一遍比看十篇理论文章都管用。5.5 监控和运维手动排查锁的时候该看什么最后说下运维层面。分布式锁在线上出了问题第一件事不是翻代码而是看 Redis 里锁 key 的状态。用redis-cli连接 Redis执行TTL lock:{业务key}看剩余过期时间。如果TTL返回 -1说明这个 key 没有过期时间八成是代码用了裸SETNX忘了设置过期时间。执行GET lock:{业务key}看 value 是不是某个实例的 UUID。如果 value 长时间不变可能是持有锁的线程已经挂了但看门狗没有正常续期比如看门狗自己也崩了。使用RedisInsight或Another Redis Desktop Manager这类可视化管理工具按 key 前缀搜索lock:*快速统计出当前有哪些锁、各锁的 TTL、value 分布定位热点锁和高频冲突的锁。还有一个非常实用的监控指标记录每次获取锁的等待时间tryLock的耗时如果平均等待时间持续升高说明锁冲突加剧需要排查锁粒度是否过粗、临界区逻辑是否过慢、是不是有人把分布式锁用成了串行化执行。这些指标可以丢进 Prometheus Grafana 里做趋势告警比等用户投诉快了不知道多少倍。6. 手写锁之外我对分布式锁的一些延伸思考6.1 分布式锁真的能保证绝对安全吗——几个反直觉的事实写了这么多年分布式锁我得诚实告诉你一个反直觉的事实没有任何基于 Redis 的分布式锁方案能保证 100% 的绝对安全。原因在于分布式系统的核心难题是网络分区 进程暂停的组合。A 实例持有锁后发生了长时间 GC 停顿Full GC 可能长达几十秒Redis 里的锁因为过期时间到了被自动清理。B 实例拿到锁开始工作这时候 A 实例的 GC 结束A 线程醒了过来继续执行临界区代码——它对锁的丢失毫不知情。此时 A 和 B 同时在执行临界区逻辑锁形同虚设。这个问题在 RedLock 方案里也没被彻底解决因为进程暂停可能发生在任何一个节点上。这也是为什么 Martin Kleppmann 和 Salvatore SanfilippoRedis 作者会为这个问题展开公开辩论。了解了这个边界之后你对分布式锁的预期就应该从绝对安全调整到尽力互斥 业务兜底。6.2 对比 ZooKeeper 锁方案以及为什么有些场景必须改用 CP 系统如果你的业务真的对锁的安全性有着近乎苛刻的要求比如金融级别的对账、资金操作我建议认真评估 ZooKeeper 方案。ZK 锁的核心思路是利用临时顺序节点 Watch 机制。加锁在 ZK 的锁目录下创建临时顺序节点然后判断自己是不是序号最小的节点如果是则加锁成功否则 Watch 前一个节点。解锁删除自己创建的节点。失效处理ZK 的临时节点会跟客户端会话绑定会话断开节点自动消失避免了持有者宕机导致死锁的问题。ZK 锁相比 Redis 锁优势在于它基于 ZAB 协议数据变更需要多数节点确认天然避免了主从切换丢锁问题。代价是性能低——ZK 的写操作要跨节点达成共识TPS 远不如 Redis而且客户端与 ZK 的会话管理有额外的网络开销。所以在并发量不高、但一致性要求极高的场景下ZK 是更合适的选择并发量高、允许极小概率锁失效的场景Redis 的性能优势无法替代。6.3 如果业务允许用乐观锁替代分布式锁也是一种好思路分布式锁悲观锁的实现思路是先锁后做而乐观锁的思路是先做后验——更新数据时带上版本号或时间戳一旦发现版本不匹配就放弃或重试。在数据库层面乐观锁的落地非常简单UPDATE t SET version version 1, ... WHERE id #{id} AND version #{version}通过受影响行数是否大于 0来判断是否更新成功。乐观锁的优势是完全没有锁等待并发能力更高实现也更简单。劣势是它只适合更新单行记录的场景对于跨多行、跨表、跨服务的复杂事务乐观锁就显得力不从心。我的经验是能用乐观锁解决的并发问题就不要引入分布式锁。比如库存扣减、余额变更这种单行更新操作乐观锁 重试机制是比 Redis 分布式锁更优雅的方案也不要给自己徒增系统复杂度。6.4 扩展思路从手写锁到分布式锁组件化手写锁的过程如果走完一遍你会发现它天然适合抽成一个独立的组件。我后来在公司就是这么做的把散落在各个项目里的分布式锁代码收拢到一个distributed-lock-spring-boot-starter里统一封装以下能力注解式声明DistributedLock(key #orderId, waitTime 500ms)AOP 拦截后自动加锁、执行业务、释放锁。锁类型扩展内置 Redis 实现预留 SPI 接口未来可以无缝切换 ZK 或数据库实现。监控指标内嵌每次加锁耗时、等待耗时、锁冲突次数、看门狗续期次数统一上报到监控平台。故障容错开关当 Redis 锁服务不可用时支持降级为本地锁 停机保护或直接放行 告警根据业务敏感性配置。这套组件上线后业务方只需要加一个注解再也不用手写加锁解锁的样板代码也大大减少了分布式锁用错的概率。如果你已经把锁的原理吃透了我强烈建议你也把自己的代码沉淀成组件这对团队和自己的成长都很有价值。7. 写在最后这套手写锁的边界、适用场景与最终建议7.1 适用于哪些场景哪些场景建议直接换方案经过前面的完整实现你现在应该对手写 Redis 分布式锁的能力边界有了清晰认知。它适合以下场景多个服务实例需要互斥执行某个任务定时任务、消息消费、缓存重建。需要防止用户重复提交、重复下单、重复消息。需要保护一段短时间的临界区代码秒杀扣库存、活动状态流转。团队已经有 Redis 基础设施不想额外引入 ZK 或数据库组件。它不适合以下场景强一致性的金融交易、账户操作——建议数据库锁 幂等约束兜底。跨多数据源的长事务——分布式锁不保证事务性请用分布式事务方案如 TCC、Saga。需要绝对可靠的互斥保证——建议 ZK/etcd 等 CP 系统。7.2 最后分享几条实战经验别重蹈我的覆辙经验一锁的 key 命名一定要包含业务前缀比如lock:order:123不要裸写123。否则 Redis 里一堆 key 混在一起排查问题时你会崩溃。经验二公司如果有规定统一的 Redis namespace锁 key 务必加上项目名或环境名防止多个环境共用 Redis 时 key 互相覆盖。经验三任何基于时间的设计过期时间、看门狗间隔、自旋间隔都要考虑分布式系统里的时钟不一定同步。不要在同一把锁上同时依赖多个节点的时间判断避免极端情况下的逻辑错乱。经验四条件允许的话把锁相关的 Redis 操作单独用一个 redis-cluster 实例承载不要和业务缓存混用一个实例。锁请求的高频写入和缓存的读多写少是两种完全不同的负载模型混在一起会互相干扰出问题时也很难隔离。手写 Redis 分布式锁这件事代码量其实不大披着简单的外壳内核却涉及原子性、超时控制、异常安全、可重入、集群容灾等一系列分布式系统的基础问题。这一趟走下来我对分布式锁的理解比单纯背面试题深刻太多。希望这篇实战拆解也能让你少走一些弯路如果你在实现过程中遇到别的坑欢迎带着具体场景来找我聊。