去年年中我们一个优惠券发放接口出了事故整点秒杀一开始同一张券在多个实例上被重复发放数据库里出现了一堆重复订单。代码里明明加了 synchronized为什么还是失控排查下来问题出在服务做了多实例部署单机的 synchronized 只能锁住自己所在的那个进程其他实例照样并发往里冲。从那以后我彻底把 Redis 分布式锁这件事系统地过了一遍。这篇内容我不打算灌理论直接把我的实战过程、代码、踩坑记录都放出来。项目本身围绕“Redis 分布式锁”展开适合正在做微服务、需要控制并发访问共享资源的后端开发者尤其是用过 Redis 但没深入做过分布式锁的朋友。文章里包含锁的选型、代码实现、坑点排查、面试高频考点大家可以根据自己的进度挑着看。1. 项目背景什么时候真的需要分布式锁1.1 从超卖到重复转账分布式锁解决什么问题分布式锁的本质是多个进程之间互斥地访问某个共享资源。最典型的场景是库存扣减用户下单后要扣库存如果库存表只有一条记录两个订单同时扣就会把库存扣成负数。数据库层面可以用乐观锁、悲观锁解决但很多业务场景不一定方便改数据库或者性能上扛不住这时一个独立的分布式锁服务就很有价值。再比如重复转账。用户连续点了两次提交两个请求落到不同实例上都去操作同一个账户余额。如果没有分布式锁余额就可能被扣两次。这种场景下锁的作用不是提高性能而是保证唯一性和安全性。从使用场景上看分布式锁通常解决三类问题防止重复操作重复下单、重复支付、重复发放。控制临界资源库存、余额、积分、配额。避免缓存击穿或热点重建多个请求同时发现缓存为空都去查询数据库并重建缓存。在这些场景里分布式锁不是可有可无的优化而是保证业务不出错的底线。1.2 单机锁为什么管不住多实例我先说一下当时为什么代码里有 synchronized 还会出问题。synchronized 是 JVM 层面的锁它锁的是当前进程内部的对象监视器。服务部署了四个实例每个实例都有自己的 JVM四个进程之间互相感知不到对方持锁状态。所以同一个时刻四个实例都能进入 synchronized 保护的代码块。有人可能会说那把同步改为数据库行锁比如 select ... for update是不是就行了行锁确实可以在多实例下生效但它也有代价锁会一直持有到事务提交如果事务里做了耗时的远程调用数据库连接会被长期占用在高并发下连接池很快就满了。而且有些读多写少的场景用行锁过于重量级。Redis 分布式锁之所以被广泛使用是因为它利用了 Redis 单线程执行命令的特性通过一条原子命令就能保证“同时只有一个客户端能拿到锁”。相比数据库锁它更轻量、更灵活还能设置自动过期避免进程挂掉导致死锁。1.3 分布式锁的四个硬性要求实战做多了我总结下来一个合格的分布式锁至少要满足四个条件互斥性任意时刻只有一个客户端能持有锁。安全性锁必须设置过期时间防止持锁客户端宕机后锁永远不释放造成死锁。可用性加锁和解锁的过程不能因为某个节点故障而全部不可用。容错性持有锁的客户端在释放锁时只能删除自己加的锁不能误删别的客户端持有的锁。这四点看起来简单但要把每一点做扎实都要处理不少细节。后面我会结合代码一步步说。2. 技术选型原生 Redis 命令还是 Redisson2.1 最朴素的实现SET NX EX最早我实现分布式锁的时候用的就是 Redis 的一条命令SET lock_key unique_value NX EX 30这条命令拆开来看NX只有当 key 不存在时才能设置成功保证了互斥。EX 30设置过期时间 30 秒防止死锁。unique_value一个全局唯一的客户端标识用于释放锁时校验。加锁的核心逻辑就一行非常简单。但要注意这里必须用SET ... NX EX一条命令完成不能拆成SETNX再EXPIRE两条命令否则存在原子性问题客户端刚SETNX成功还没设置过期时间就崩溃了key 永远不会过期锁就变成了死锁。在最初写这个项目的时候我在很多博客上看到过“先用 SETNX再 EXPIRE”的写法这种写法在低并发下也许侥幸没问题但一旦发生进程崩溃或者网络超时后果非常严重。生产中一定要用原子的 SET 命令。2.2 解锁必须用 Lua为什么不能先 GET 再 DEL解锁看上去很简单把锁删掉就行。但这里有个很隐蔽的坑。假设我的解锁逻辑是if (redis.get(lock_key).equals(uniqueValue)) { redis.del(lock_key); }这段代码在并发场景下是错的。线程 A 持锁业务执行完后准备删锁。此时锁因为超时自动过期了线程 B 立即获取到锁往 lock_key 写了新值。线程 A 的 GET 请求先执行完发现值还是自己的 uniqueValue然后准备执行 DEL。就在这个瞬间线程 B 的锁还在保护自己的业务。线程 A 的 DEL 一执行把线程 B 的锁删掉了。接下来线程 C 又拿到锁三个线程同时进入临界区。正确的解锁必须保证“判断值”和“删除 key”是原子操作。Redis 实现原子操作的常用手段是 Lua 脚本把两个操作打包成一个脚本执行if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这样判断和删除在同一次 Redis 命令执行中完成不会被其他客户端插队。2.3 Redisson 为什么是生产首选如果你只想要一个简单锁用 SET NX EX 加 Lua 解锁就够用了。但把它放到复杂业务里问题就会越来越多。举个例子我设置的锁超时时间是 30 秒但业务执行时间不稳定有时耗时 5 秒有时耗时 40 秒。如果业务超过 30 秒还没执行完锁已经自动释放了其他线程就会进来。怎么办有人会说把超时时间设长一点比如 5 分钟。但这样如果持锁进程真的挂了其他线程要等 5 分钟才能拿到锁故障恢复时间太长了。Redisson 给出的方案是看门狗机制加锁成功之后启动一个定时任务每隔一段时间默认是锁租约时间的三分之一自动给锁续期。只要业务还在执行锁就不会过期业务执行完毕主动释放锁后台任务也跟着取消。这样就同时解决了“业务超时锁提前释放”和“进程崩溃锁死锁”两个问题。Redisson 还封装了可重入锁、公平锁、读写锁、红锁等实现接口使用方式和 JDK 的 Lock 接口非常像学习成本很低。所以我后续的实战项目里只要是 Java 应用基本都会选择 Redisson而不是自己造轮子。2.4 不同 Redis 部署形态对锁的影响除了客户端选型Redis 本身的部署方式也会影响分布式锁的可靠性。单机 Redis实现简单性能好但存在单点问题。如果 Redis 挂了所有依赖分布式锁的服务都会受影响。主从模式解决了单点故障但在主从切换瞬间有锁丢失的风险。比如客户端 A 在主节点写入锁主节点还没有把数据同步到从节点就宕机了从节点升级为主节点后锁数据丢了。此时客户端 B 可以拿到同一把锁互斥性被破坏。哨兵模式和主从模式本质类似只是在故障转移时多了一个哨兵来做监控和切换锁丢失的窗口依然存在。集群模式通过分片把数据分散到多个节点同样存在主从复制导致锁丢失的可能。如果你对锁的可靠性要求极高Redisson 提供了 RedLock 算法向多个独立的 Redis 节点依次加锁只有超过半数节点加锁成功才认为加锁成功。但 RedLock 也不是银弹它依赖时钟假设工程上实现复杂业务价值有限。我在实际项目里通常优先保证 Redis 高可用并结合业务兜底来接受极低概率的锁丢失而不是盲目上 RedLock。3. 核心代码实现从简单到可靠3.1 环境准备与依赖这个实战项目我用的环境是JDK 8Spring Boot 2.xRedis 5.x 及以上建议 6.xMaven 依赖redisson-spring-boot-starter在pom.xml里加入 Redisson 依赖dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.20.1/version /dependency然后在application.yml里配置 Redis 连接信息spring: redis: host: 127.0.0.1 port: 6379 password: database: 0如果暂时不想整合 Spring Boot也可以直接用 Redisson 的独立客户端Config config new Config(); config.useSingleServer().setAddress(redis://127.0.0.1:6379); RedissonClient redisson Redisson.create(config);我在本机验证环境时用的是 Docker 起的一个 Redis 6 容器docker run -d --name redis-lock -p 6379:6379 redis:6.2-alpine起好后直接用 Redis Desktop Manager 连接查看锁的 key 和过期时间排查问题时很方便。3.2 基于 Redisson 的分布式锁示例Redisson 的使用方式非常直观。下面这段代码是分布式锁最基础也最标准的用法Autowired private RedissonClient redissonClient; public void deductInventory(String productId, int count) { String lockKey lock:product:stock: productId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { // 尝试加锁最多等待 5 秒加锁成功后 30 秒自动释放 locked lock.tryLock(5, 30, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException(系统繁忙请稍后重试); } // 业务逻辑扣减库存 int stock getStock(productId); if (stock count) { throw new RuntimeException(库存不足); } setStock(productId, stock - count); // 模拟耗时业务 Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (locked) { lock.unlock(); } } }这里有几个细节值得说明一下。lock.tryLock(5, 30, TimeUnit.SECONDS)的含义是尝试获取锁如果锁被其他线程持有最多等待 5 秒等待过程中如果拿到了锁则设置锁租约时间为 30 秒。如果在等待时间内没拿到锁返回 false。但注意如果传入的是带租约时间的tryLockRedisson 会使用这个租约时间而不会启用看门狗续期。如果不传租约时间比如lock.tryLock(5, TimeUnit.SECONDS)那么 Redisson 会启用看门狗默认租约时间 30 秒并自动续期。我在项目中通常的做法是如果业务时间可控且较短就传入租约时间避免锁无限续期带来隐患如果业务时间波动大就不传租约时间利用看门狗自动续期。3.3 关键参数等待时间、租约时间、看门狗这三个参数直接影响分布式锁的行为和业务体验我单独拿出来说。等待时间waitTime线程尝试获取锁时最多等待多久。设置太短容易出现“获取锁失败”的提示用户体验差设置太长大量线程阻塞Redis 连接和线程资源被占用。通常建议根据业务接口的可用性要求来定比如 2 到 5 秒比较常见。租约时间leaseTime锁的自动过期时间。如果不做自动续期租约时间必须大于业务最坏耗时否则业务没执行完锁就释放了。但租约时间太长持锁客户端崩溃后其他线程要等很久才能恢复。所以“锁超时时间”和“业务最长执行时间”之间的平衡是分布式锁调优的核心。看门狗WatchdogRedisson 的自动续期机制。默认锁租约时间是 30 秒每 10 秒检查一次锁是否仍然持有如果是就把过期时间重置为 30 秒。我用一个实际案例说明它的价值一个报表生成任务平时只要几秒遇到数据量大时可能要跑 40 多秒。如果手工设置锁超时 30 秒必然会出现锁提前释放的问题如果设置 60 秒又怕进程崩溃后其他线程等待太久。用看门狗锁的超时时间根据实际执行时间动态延长完全没有这个烦恼。如果你要在 Spring Boot 中测试看门狗只需要调用无租约时间的lock.lock()即可。3.4 封装一个简单的锁工具类为了避免在多个业务类里重复编写加锁解锁逻辑我封装了一个简单的工具类对外提供“带返回值”和“不带返回值”两个方法Component public class RedisLockUtil { Autowired private RedissonClient redissonClient; public T T executeWithLock(String lockKey, long waitTime, SupplierT supplier) { RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(waitTime, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException(获取分布式锁失败, key lockKey); } return supplier.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(获取分布式锁被中断, e); } finally { if (locked) { lock.unlock(); } } } public void executeWithLock(String lockKey, long waitTime, Runnable runnable) { executeWithLock(lockKey, waitTime, () - { runnable.run(); return null; }); } }使用时只需要redisLockUtil.executeWithLock(lock:order:pay: orderId, 5, () - { doPay(orderId); return null; });这样的封装让核心业务代码非常干净锁的获取和释放逻辑统一收敛到工具类里排查问题也方便。4. 进阶场景可重入、阻塞等待与公平性4.1 可重入锁怎么实现可重入的意思是同一个线程在已经持有锁的情况下可以再次获取同一把锁。比如一个方法 A 加了锁在方法 A 内部调用了方法 B方法 B 也加了同一把锁如果锁不可重入这里就会死锁。Redis 层面没有线程的概念所以要实现可重入必须在锁的 value 里记录持有者信息和重入次数。Redisson 的可重入锁实现是基于 Lua 脚本完成的脚本里通过一个 hash 结构记录持有锁的线程 ID 和重入计数。我自己在项目中遇到可重入的场景主要是上层接口做分布式锁里面调用的内部服务也做了一个分布式锁两个锁 key 恰好一样。如果没有可重入能力就会互相等待造成死锁。使用 Redisson 时可重入是默认支持的不需要额外配置。比如RLock lock redissonClient.getLock(lock:order:1); lock.lock(); try { processA(); // processA 内部也获取同一把锁 } finally { lock.unlock(); }同一个线程可以连续调用lock()两次只要对应的unlock()次数匹配即可。底层 hash 的 value 会从 1 变成 2释放时再递减减到 0 才真正删除 key。4.2 阻塞等待和公平锁什么时候用默认的tryLock(waitTime)属于“有界阻塞等待”在 waitTime 内会不断尝试获取锁超过时间就放弃。如果业务上希望拿不到锁就快速失败并提示用户稍后重试可以把 waitTime 设为 0只尝试一次。如果希望严格等待可以使用lock.lock()它会一直阻塞直到获取到锁。但我不建议在 Web 请求线程里使用无限阻塞等待因为一旦 Redis 服务出现波动大量线程会同时阻塞拖垮整个应用。Redisson 还提供了公平锁FairLock。它内部基于 Redis 的 List 和 ZSet 结构实现了一个等待队列确保多个线程按照申请顺序获取锁。我当时在一个定时任务抢单项目里试过公平锁业务要求先到先得不能允许后来的线程插队。虽然公平锁能保证顺序但它比普通锁多维护一个队列性能损耗更高。如果业务对顺序没有硬性要求还是用普通锁更划算。4.3 读写锁与业务场景的匹配Redis 分布式锁中还有一种读写锁ReadWriteLock它允许读锁之间共享但写锁和其他锁互斥。使用场景是读多写少的资源配置比如系统配置项、热点商品信息多个线程可以同时读但写的时候必须独占。Redisson 的使用方式很直接RReadWriteLock rwLock redissonClient.getReadWriteLock(lock:config:1); RLock readLock rwLock.readLock(); RLock writeLock rwLock.writeLock(); // 读操作 readLock.lock(); try { getConfig(); } finally { readLock.unlock(); } // 写操作 writeLock.lock(); try { updateConfig(); } finally { writeLock.unlock(); }但需要注意读写锁并不能解决“缓存和数据库的一致性”问题。如果写操作更新了数据库而读操作仍然在读旧的缓存读写锁只保证锁内的操作互斥不保证 Java 对象在不同线程间的可见性。它更适合语义上明确要求读写互斥的业务而不是万能的同步工具。5. 常见问题排查与避坑实录5.1 锁超时后业务还没执行完怎么办这是分布式锁最经典的问题。我在早期一次促销活动中就遇到过库存扣减逻辑里包含了一个第三方接口调用平时 100 毫秒高峰期因为网络波动拖到 45 秒而锁超时时间是 30 秒。结果锁自动释放了第二个线程进来也扣了一次库存超卖问题再次出现。解决方案有两个方向方案一使用 Redisson 的看门狗自动续期这是我最推荐的方式。它保证业务不结束锁不释放。方案二预估业务最坏耗时设置足够大的租约时间。这种方式适合耗时相对稳定的场景但需要经验值支撑设置太大又会影响故障恢复速度。另外要注意开启看门狗不代表完全没有风险。如果 Redis 和客户端之间的网络长时间不通续期请求发不出去锁照样会过期。所以在极端场景下业务层最好还有幂等机制作为最后兜底分布式锁只是概率性保证互斥不能作为唯一防线。5.2 主从切换导致锁丢失怎么处理这个坑是我在一个活动项目的压测环境中发现的。当时 Redis 是主从模式主节点发生故障哨兵把从节点切换为主节点。切换完成后原本已经成功写入的锁 key 在新主节点上不存在导致后续请求全部拿到了锁。要解决这个问题需要预先设计好 Redis 的高可用架构并接受一定的损失概率如果是非关键业务锁丢失后允许极小概率的并发冲突用业务幂等来兜底。如果是关键业务考虑引入 RedLock向多个独立 Redis 节点同时加锁超过半数成功才算成功。或者使用 ZooKeeper / etcd 这类强一致性的分布式协调组件来实现锁数据一致性和可用性更强。我个人的经验是不要迷信 RedLock。很多团队实现了 RedLock但忽略了多个 Redis 节点必须相互独立、部署在不共享故障域的物理机上否则一旦机房整体故障RedLock 一样会失效。倒不如把精力放在业务幂等和最终一致性上。5.3 误删别人的锁value 校验不能省这个坑我在前面的 Lua 解锁里已经提到过一次但实际排查中还是常见。尤其是一些同学手写了解锁逻辑没有校验 value直接执行 DEL。假设线程 A 持有锁 keyvalue 是 uuid1。锁超时后自动释放线程 B 获取锁value 是 uuid2。线程 A 业务执行完后直接 DEL lock_key线程 B 的锁被删掉线程 C 趁机获取锁。此时 B 和 C 都认为自己持锁成功业务就乱了。正确做法是在释放锁之前必须校验 value 是否还属于当前线程。使用 Redisson 时这个校验逻辑已经内嵌在 Lua 脚本里我们不需要额外处理。但如果手写 SET NX EX一定要用 Lua 解锁不能偷懒。另外value 的生成要足够随机避免两个实例的 value 重复。通常使用 UUID、当前线程 ID 加时间戳等方式拼接确保全局唯一。5.4 锁粒度太大导致性能瓶颈分布式锁使用不当很容易把高并发系统变成一个串行系统。比如我之前接过一个订单模块同事把整个“创建订单 支付 通知库存”流程全放在同一个锁里锁的 key 还是个公共的全局锁。用户下单一多所有请求全在等待锁吞吐量掉得惨不忍睹。锁粒度设计有几个原则锁的 key 要能区分不同的资源。比如按商品维度加锁而不是所有商品共用一把锁。锁的代码块要尽量小。只锁临界资源操作不要把耗时的远程调用、大量计算都放进锁内。尽量使用读写锁来区分读多写少的场景减少锁竞争。我曾经把一个全局锁改成用户维度锁后QPS 从 800 提升到了 4000 多效果立竿见影。5.5 排查工具与排查思路整理遇到分布式锁问题时我一般按以下步骤排查查看 Redis 中的锁 key使用 Redis Desktop Manager 或者命令行redis-cli确认锁 key 是否存在、剩余过期时间是多少。查看锁的 value 特征如果是 Redisson锁的 value 通常是一串 Redis 的 Locker 标识如果是手写 SET NX EXvalue 就是客户端传入的随机值。查看业务日志确认加锁成功、释放锁、获取锁失败的时间线和 Redis 中锁 key 的过期时间做对比。开启 Redisson 的日志级别为 DEBUG可以输出加锁解锁的详细过程。我整理过一个常见问题速查表方便快速定位现象可能原因排查方法业务执行完锁没有释放忘记调用 unlock或解锁逻辑被异常跳过用 try/finally 确保解锁获取锁失败率过高锁粒度过大、等待时间太短、Redis 压力大调整锁粒度增大 waitTime锁自动释放后其他线程进入业务执行时间大于租约时间使用看门狗自动续期两个线程同时拿到锁主从切换、误删锁、未正确使用原子命令检查 Redis 高可用配置使用 Lua 解锁服务重启后锁还在进程异常退出锁没释放也未设置过期时间确保所有锁都设置过期时间6. 面试视角分布式锁高频考点6.1 面试官想听到的回答思路搜索关键词里“分布式锁面试题”排在很前面这说明不少读者还在准备面试。我顺便把面试中经常被问到的问题梳理一下不是让大家背答案而是理解背后的原理。第一个高频题分布式锁有哪几种实现方式回答时可以从 Redis、ZooKeeper、数据库三个方向展开。Redis 实现最简单、性能最好但一致性偏弱ZooKeeper 基于 ZAB 协议一致性更强但性能和部署成本不如 Redis数据库实现最为传统用唯一索引或悲观锁实现适合对一致性要求高、并发量不大的场景。第二个高频题如何保证 Redis 分布式锁的原子性回答要抓住两点加锁时用SET key value NX EX time一条命令解锁时用 Lua 脚本确保校验 value 和删除 key 是原子操作。再深入一点可以解释为什么不能拆成多条命令以及 Redisson 的底层实现。第三个高频题Redis 分布式锁可能有哪些问题主要讲锁超时提前释放、主从切换导致锁丢失、误删别人锁、锁不可重入、锁粒度过大等。同时给出对应的解决方案看门狗续期、RedLock 或 ZooKeeper、Lua 校验、可重入锁设计、按资源维度拆分锁。第四个高频题RedLock 的优缺点答出 RedLock 的基本原理是向多个独立 Redis 节点加锁超过半数成功才算成功同时说明它存在时钟假设和实现复杂度问题不是所有场景都适合。如果面试官追问你项目中有没有用过 RedLock建议诚实回答并说明项目中如何通过业务兜底规避锁丢失风险。6.2 项目经验怎么包装才可信面试中讲 Redis 分布式锁时千万不要只背理论最好带上真实问题。比如你可以讲之前负责的订单系统在秒杀场景下出现了超卖通过排查发现是 synchronized 无法跨实例生效于是改用 Redis 分布式锁随后又遇到了锁超时导致业务并发执行通过 Redisson 看门狗解决最后还补充了主从切换时的锁丢失分析和业务幂等设计。这样的讲述会让面试官觉得你是真的做过而不是临时背的。但也要注意不要为了包装而虚构没有做过的内容一旦被追问细节很容易露馅。7. 写在最后的一点心得分布式锁这个主题看起来很小但它是分布式系统的基本功之一。我在做的过程中最大的体会是锁最容易出问题的地方恰恰是使用方觉得“这很简单”的地方。原子性、锁超时、可重入、误删、主从切换每一个都会在特定条件下集中爆发。按照我个人经验如果你是第一次在自己的项目里引入分布式锁优先使用 Redisson不要自己造锁锁粒度尽量细锁内代码尽量短一定要给业务设计幂等兜底不要把可靠性完全押在锁上。这样即使将来 Redis 抖动或者主从切换也不至于造成不可挽回的数据错误。如果这篇文章对你有帮助欢迎把它当成一份踩坑笔记来用。实战中遇到问题可以再对照着查一查尤其是常见问题速查表那一节我后来复盘过多次基本覆盖了绝大多数线上故障的排查路径。
