尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis分布式锁从手写到Redisson:超卖问题、死锁避坑与看门狗续期

Redis分布式锁从手写到Redisson:超卖问题、死锁避坑与看门狗续期 有次半夜线上告警我维护的商品秒杀系统出现超卖库存明明只剩 3 件同时涌进来的 5 个请求却都扣单成功了。代码翻来覆去查了几遍本地用synchronized锁得好好的怎么一部署到多实例集群就失效这个问题几乎是每个后端都会撞上的坎你锁住的是当前 JVM 里的线程却管不住集群另一台机器上的请求。Redis 分布式锁的价值就在这里——用 Redis 作为多实例共享的锁协调者让不同进程之间也能互斥。这篇文章我会从手写实现开始把死锁、误删、不可重入、无法续期这些坑一个一个踩过再对比 Redisson 的成熟方案讲清楚为什么生产环境最终都会落到 Redisson 上。1. 从一次超卖事故看分布式锁的边界1.1 单机锁在多实例环境下的失效原因先还原当时的现场。秒杀接口的伪代码大概是这样的synchronized (this) { int stock getStockFromDB(); if (stock 0) { throw new RuntimeException(已售罄); } reduceStock(); }单机部署时没有任何问题因为 JVM 内部只有一个锁对象。但上了 Nginx 负载均衡之后请求被分发到 A、B 两台服务实例上。A 机器在synchronized里扣库存的同时B 机器上的线程根本感知不到 A 的锁——它们是两个不同的 JVM 进程锁对象天然隔离。库存被扣成负数往往就是 A、B 同时进入了临界区。所以要明确一点分布式锁并不是替代synchronized/Lock而是解决跨进程互斥这个单机锁根本覆盖不了的问题。判断一个场景是否需要引入分布式锁就看你操作的资源是否被多个服务实例同时访问、并且需要保证互斥。1.2 分布式锁需要满足的三个核心约束很多教程一上来就丢代码容易让人忽略背后的设计目标。我把分布式锁的基本要求拆成三点后续不管是手写还是选型都围绕它们展开互斥性任意时刻只能有一个客户端持有锁这是锁的立身之本。安全性锁必须最终可释放不能因为持有者宕机就变成死锁所以要有过期时间兜底。可用性加锁、解锁过程要快不能因为锁服务本身拖垮业务同时要尽力避免把锁错误释放。这三条听起来简单实操中每条都有暗坑。第一条对应 Redis 的原子命令第二条对应过期时间的设计第三条对应释放锁时的持有者校验。缺了任何一条你都会在某个凌晨被线上事故叫醒。1.3 Redis 为什么能承担锁的职责在分布式锁的选型中Redis 不是唯一答案ZooKeeper 用临时顺序节点 Watch 机制做锁etcd 用租约和 Revision 机制都能实现。但 Redis 胜在简单、高性能、基础组件普及率高。大部分团队本来就有 Redis 做缓存不需要为锁单独引入一套新中间件加锁解锁的一次内存操作通常是亚毫秒级。代价是 Redis 的锁不如 ZooKeeper/etcd 那样具备强一致语义——这个缺陷后面讲主从切换时再展开。2. 手写第一版分布式锁能跑和能用是两回事2.1 最简实现Redis setnx 加锁 delete 释放如果你搜过Redis 分布式锁最简实现大概率会看到setnx加锁、del解锁的版本。我最初也是这么写的// 加锁 Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1); if (Boolean.TRUE.equals(locked)) { // 业务逻辑 } finally { // 释放锁 redisTemplate.delete(lockKey); }setnx的意思是 set if not existskey 不存在时写入成功返回 truekey 已存在时写入失败返回 false。这天然满足互斥性。解锁用delete把 key 删掉下一次请求就能加锁成功。在只有一个线程、从不宕机、业务秒级完成的理想世界里这个版本够用了但现实不是。2.2 第一个致命问题持有者宕机导致死锁上面代码最大的隐患是如果加锁成功的线程在执行业务时进程崩溃或者发生了长时间的 GC / 网络抖动finally里的delete根本不会执行。锁 key 会一直留在 Redis 里后续所有请求都会因为加锁失败而被卡死。这就是死锁。解决思路也很直接给锁设置过期时间让 Redis 在超时后自动清理。很多人会写成两步Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1); redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS); // 单独设置过期时间这样写有一个原子性问题setIfAbsent和expire是两次 Redis 命令中间如果服务宕机过期时间没设置成功锁照样不会被自动清理。正确做法是用一条命令同时完成不存在才写入和设置过期时间两个动作Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 30, TimeUnit.SECONDS);Redis 从 2.6.12 开始支持在一条SET命令中组合NX和EX参数setIfAbsent带超时时间底层就是这个命令原子性有保障。2.3 第二个致命问题误删别人持有的锁加了过期时间之后又冒出新问题假设线程 A 获取锁后业务执行超过了 30 秒锁自动过期释放。此时线程 B 获取到同一把锁开始执行自己的业务。A 终于跑完了执行delete把锁删掉——但它删的是 B 的锁。这就是误删。后果比死锁更隐蔽A 释放锁后线程 C 也能马上获取锁。同一时刻 B 和 C 同时持有锁互斥性被彻底破坏。要解决误删必须在加锁时给锁设置一个只有自己知道的标识删除前校验这个标识是否还属于自己。这就是下一版本手写锁的核心改进。3. 手写锁进阶UUID 标识、Lua 脚本与可重入3.1 加锁时写入唯一标识解锁前校验持有者改进后的逻辑很清晰加锁时value使用一个全局唯一的 UUID解锁前先查询当前锁的 value 是否等于自己的 UUID相等才删除。String lockKey lock:order: orderId; String lockValue UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS); // 解锁 String currentValue redisTemplate.opsForValue().get(lockKey); if (lockValue.equals(currentValue)) { redisTemplate.delete(lockKey); }这段代码比第一版严谨但还不够。get和delete是两次独立操作中间存在着时间窗口如果当前锁刚好在这个窗口内过期其它线程获取了锁并且写入了新的 value那当前的delete依然会删掉别人的锁。只是概率变低了不等于解决了。3.2 Lua 脚本保证校验 删除的原子性要彻底解决这个窗口得让比较 value 是否相等相等才删除这两个步骤在 Redis 端原子执行。Redis 的 Lua 脚本天然具备原子性因为脚本执行期间不会被其它命令插入。if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endJava 侧用DefaultRedisScript执行DefaultRedisScriptLong unlockScript new DefaultRedisScript( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end, Long.class ); Long result redisTemplate.execute(unlockScript, List.of(lockKey), lockValue); // result 1 表示成功释放这是手写锁的一个重要分水岭判断和删除必须作为一个原子操作提交给 Redis。只要这两个动作还是分开的两次命令你就永远有一个理论上的误删漏洞。这里我踩过一个坑用DefaultRedisScript执行 Lua 时如果lockValue走的是 JDK 序列化到了 Redis 端字符串比较可能对不上最好把 key 和 value 的序列化方式统一成字符串用StringRedisTemplate或者显式配置StringRedisSerializer。3.3 可重入锁的 Hash 结构实现上面的锁还不支持可重入。什么叫可重入就是一个线程已经持有锁的情况下方法递归或者嵌套调用同一个锁时应该能再次获取成功。synchronized天然具备这个能力但SETNX方案不行同一个线程第二次加锁时key 已经存在会加锁失败。如果一个锁会锁住另一个需要同一把锁的方法就会直接死锁。支持可重入的经典实现是把锁数据从简单字符串改成 Hash 结构。Hash 的 field 记录持有者的唯一标识value 记录重入次数-- 加锁 if redis.call(exists, KEYS[1]) 0 then redis.call(hset, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 end if redis.call(hexists, KEYS[1], ARGV[1]) 1 then redis.call(hincrby, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 end return 0-- 释放 if redis.call(hexists, KEYS[1], ARGV[1]) 0 then return nil end local counter redis.call(hincrby, KEYS[1], ARGV[1], -1) if counter 0 then redis.call(pexpire, KEYS[1], ARGV[2]) return 0 else redis.call(del, KEYS[1]) return 1 end每次重入把计数加一释放时计数减一减到零才真正删除 key。这个思路和 JDK 的ReentrantLock几乎一致只不过状态从 JVM 内存搬到了 Redis。注意重入次数必须是完整加锁几次、释放几次的严格配对否则计数永远减不到零会出现锁泄漏。3.4 手写锁无法优雅解决的最后一块拼图续期到这里手写锁已经解决了死锁、误删、原子性和可重入但还剩一个最头疼的问题锁的过期时间定多少合适定太短业务一慢锁就自动过期导致并发进入临界区定太长持有者宕机后锁要很久才能被释放其它请求会被阻塞很久。更麻烦的是业务耗时本身不可控一次完整的数据库查询、远程调用、消息发送实际耗时可能从几十毫秒到几秒。无论你预设一个多大的值都无法覆盖所有场景。成熟的方案是自动续期加锁后开启一个后台任务在锁快过期时主动延长它的过期时间解锁或任务结束后停止续期。这个机制说难不难但涉及定时任务的启停、线程安全、Redis 异常后的容错自己做很容易写出 bug。手写锁到这一步基本到了极限这也是我最终转向 Redisson 的直接原因。4. 为什么生产环境要选 Redisson别重复造锁的轮子4.1 Redisson 解决的不只是续期问题Redisson 是 Redis 官方推荐的 Java 客户端锁只是它众多分布式服务中的一个模块。它把前面手写锁演进过程中踩过的坑全部封装好了RLock接口贴合 JDK 的Lock设计加锁、解锁、尝试锁的语义和ReentrantLock高度一致学习成本低。加锁、可重入计数、释放锁全部基于 Lua 脚本实现原子性由 Redis 保证。内置看门狗WatchDog自动续期机制不用自己维护定时任务。4.2 选 Redisson 而不选自己维护锁代码的理由我在团队里做过一次小调查绝大多数项目最终都有各自的手写锁工具类但普遍存在几个问题加锁解锁逻辑分散在多个 Service 里、异常分支处理不一致、Redis 连接池参数和业务代码耦合、Lua 脚本没有统一管理。用 Redisson 之后锁的获取和释放是一个标准动作配置集中管理出了问题有社区兜底遇到疑难杂症还能翻源码。除非你的诉求只是临时加一个不重要的锁不想引入依赖否则我不建议长期维护一套自研锁。4.3 引入 Redisson 的成本有多低成本其实非常低。Maven 加一个依赖dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency然后注入RedissonClient就能用。相比自己写工具类、自己处理序列化、自己写 Lua这个迁移成本几乎可以忽略。5. Redisson 分布式锁的工作机制拆解5.1 lock() 与 tryLock()阻塞与超时控制Redisson 的RLock提供了两组核心方法RLock lock redissonClient.getLock(lock:order: orderId); // 方式一阻塞等待 lock.lock(); // 方式二超时等待 boolean locked lock.tryLock(3, TimeUnit.SECONDS);lock()会一直阻塞当前线程直到获取锁成功。这种方式适合内部短任务但如果 Redis 不可用或锁被人长时间持有当前线程就会一直挂起生产环境慎用。tryLock(waitTime, timeUnit)会最多等待waitTime时间超时后加锁失败返回 false你可以根据返回值决定走系统繁忙分支。这两种方式如果都没有显式指定 leaseTimeRedisson 会启用看门狗自动续期锁不会因为业务跑太慢而中途失效。另一个重载是tryLock(2, 30, TimeUnit.SECONDS)第二个参数表示锁的最大持有时间。一旦指定了 leaseTime看门狗就不会续期到时间锁强制释放。所以如果你选择这个重载leaseTime 必须大于预估的最大业务耗时否则会出现锁被强制回收导致的并发问题。5.2 看门狗续期原理默认 30 秒锁每 10 秒续一次看门狗是 Redisson 分布式锁最核心的卖点。默认配置下lockWatchdogTimeout是 30 秒。当没有显式指定锁超时时间时Redisson 加锁成功后会启动一个后台定时任务每隔内部锁租约时间 / 3也就是大约 10 秒把锁的剩余过期时间重新刷新为 30 秒。这意味着什么你的业务如果跑了 20 秒看门狗会在第 10 秒、第 20 秒各续期一次锁在业务执行期间不会过期。当业务执行完你调用unlock()主动释放锁看门狗任务同步取消。这个机制把锁超时时间这个问题彻底从业务代码里抹掉了。但要注意看门狗不是万能的如果 Redis 发生长时间网络故障续期命令发不出去锁到了时间照样会过期如果业务线程在持锁期间一直阻塞比如lock()之后又去等一个永远不会完成的网络调用看门狗会一直续期锁就一直释放不了。所以后面我会推荐在生产上用tryLock(waitTime)加显式解锁组合而不是裸用lock()。5.3 加锁解锁的 Lua 脚本一个操作串起重入与销毁Redisson 的加锁、解锁逻辑封装在RedissonLock里核心是几个 Lua 脚本。加锁脚本的思路和我前面手写的 Hash 可重入锁一致判断 key 是否存在、判断持有者标识是否存在、重入计数加一、设置过期时间。区别在于它把锁 key、持有者标识、过期时间规范化成了完整的上锁模板。解锁脚本中判断hexists和hincrby减一、计数归零才del这些动作在 Redis 端一次原子完成天然不会误删别人的锁。而且 Redisson 在解锁时会校验当前线程是否持有这个锁避免在 finally 里对一把已经不归自己的锁执行unlock()抛出异常。5.4 多实例下 Redisson 的部署形态Redisson 支持单节点、哨兵、集群、主从等多种模式。日常开发用单节点最省事Configuration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionPoolSize(32) .setConnectionMinimumIdleSize(8); return Redisson.create(config); } }生产环境如果是哨兵 / 集群部署改成useSentinelServers()或useClusterServers()就行。注意一点RedissonClient全局只创建一个实例不要每次加锁都new Config()连接池会被打爆。6. 生产落地Redisson 接入与业务代码范式6.1 标准加锁模板tryLock、业务、finally 的黄金组合我所在项目里最终沉淀了一套标准写法所有使用分布式锁的地方都按这个模板走Component public class OrderLockService { Autowired private RedissonClient redissonClient; public void deductStock(Long orderId) { String lockKey lock:stock: orderId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(2, TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请稍后再试); } // 真正的业务逻辑查库存、扣库存、写订单 doDeductStock(orderId); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }几个值得解释的细节使用tryLock(2, TimeUnit.SECONDS)最多等两秒。拿不到锁就快速失败不让请求无限阻塞。finally里必须判断isHeldByCurrentThread()防止当前线程加锁失败时去解锁一把不存在的锁或释放掉其它线程持有的锁。lockKey使用lock:前缀加上业务标识便于在 Redis 里排查问题。业务标识尽量用订单号、用户 ID、商品 ID 这类有业务含义的值不要让无关请求互相阻塞。锁粒度越细越好。对lock:stock: orderId加锁不同订单的并发互不影响对全局lock:stock加锁性能会急剧下降。6.2 锁超时后业务还没跑完怎么办很多人担心一种情况业务用到tryLock(waitTime)时没有指定 leaseTime看门狗会自动续期最终锁会一直活着直到unlock()。这样写其实是安全的因为锁的拥有者始终是你自己。但如果你显式指定了leaseTime 10业务跑了 15 秒锁在第 10 秒过期另一个线程拿到锁进入临界区——业务数据就有被重复处理的风险。针对这类场景我的做法分两层第一层能用看门狗续期的就尽量不指定 leaseTime第二层确实要指定 leaseTime 的话必须结合业务耗时压测给出余量并配套状态字段做幂等比如订单状态位 change by 条件更新。分布式锁不能包治百病幂等和乐观锁永远是最后一道防线。6.3 用自定义注解 AOP 收敛锁逻辑业务代码散落着tryLock/unlock模板也很容易失控。我后来用自定义注解做了一层封装把锁逻辑收敛在切面里Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RedisLock { String key(); long waitTime() default 2; }Aspect Component public class RedisLockAspect { Autowired private RedissonClient redissonClient; Around(annotation(redisLock)) public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable { String lockKey redisLock.key(); RLock lock redissonClient.getLock(lockKey); boolean locked lock.tryLock(redisLock.waitTime(), TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请稍后再试); } try { return joinPoint.proceed(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }使用的时候只需要在方法上标注RedisLock(key lock:order:#orderId) public void deductStock(Long orderId) { // 业务逻辑 }这个方案看起来清爽但有一个坑切面里拿不到方法参数时key 没法动态拼接。上面的示例用了 SpEL 表达式#orderId需要在切面里解析参数名和参数值实现起来略繁琐。如果觉得暴力可以约定key()支持#参数名占位在切面里用SpelExpressionParser解析。需要我细讲这块可以单独开一篇这里先点到为止。7. 分布式锁的高危边界与真实踩坑记录7.1 锁没等来就超时返回waitTime 设计误区有一次压测时发现部分请求频繁抛出系统繁忙我打开链路日志一看加锁失败全是因为waitTime设成了 100 毫秒。高并发场景下同一把锁被持有的时间稍微长一点后续请求就会大量超时失败。这不是锁的问题是等待时间参数不合理。waitTime的设定取决于两个因素单次获取锁后的平均持有时间、你希望请求在锁竞争时阻塞多久。如果业务大多在 100 毫秒内完成waitTime设为 300~500 毫秒是比较合理的如果业务要跑 1 秒以上waitTime至少要留到 2 秒。压测时观察一下加锁成功率和响应时间分位数再反推调整参数。7.2 看门狗续期失败的真实表现一次生产事故中Redis 主节点出现短暂抖动持续了约 15 秒。那段时间有业务线程已经持有锁超过 25 秒看门狗续期命令因连接异常没发出去。锁到期释放后同一资源被两个线程同时处理产生了重复退款。复盘时确认看门狗并不是一旦启用就永远不死锁的银弹它依赖 Redis 的可用性。如果你们的业务对极端一致性要求极高只靠 Redis 锁不够还需要在业务侧加状态机约束。我把这次事故的结论写进了团队规范分布式锁只能作为常规互斥手段不能作为唯一的一致性保障涉及资金、库存这类强校验场景一定要配数据库行锁或乐观锁做兜底。7.3 主从切换、故障转移导致的锁丢失这是 Redis 分布式锁最出名的软肋。场景是这样的客户端 A 在主节点上获取了锁但这条写命令还没同步到从节点主节点就挂了。哨兵把所有从节点中的某一个提升为新主节点但新主节点上没有 A 的锁记录。此时客户端 B 去加锁它能成功加锁导致 A 和 B 同时持有锁。面对这个问题的业界方案叫 RedLock同时向多个独立的 Redis 节点请求锁超过半数节点加锁成功才认为获得锁。Redisson 也提供了RedissonRedLock。但 RedLock 本身也有很大争议因为它把问题从一个 Redis 的强一致转移成多个 Redis 之间的多数派一致实现复杂、性能开销大生产环境真正采用的很少。我个人的做法是单 Redis 实例部署时接受这个风险因为锁服务本身就不是强一致存储对一致性要求苛刻的场景直接考虑 ZooKeeper / etcd 那套方案不要在 Redis 上硬扛。7.4 锁内做远程调用、锁外做事务提交的组合风险最容易被忽略的一个坑是锁的粒度覆盖不到事务提交。很多代码长这样方法上有Transactional事务提交发生在方法返回之后但unlock()是在方法内的 finally 里执行的。也就是说锁先释放了事务后提交。另一个线程在锁释放后立刻拿到锁去查数据库它可能查不到刚才那个事务还没提交的数据又会重复处理一笔订单。解决思路有几个一是把锁和事务边界收窄锁内只做必要操作事务提交也尽量放在锁内二是利用 Spring 的TransactionSynchronizationManager注册事务提交完成后的回调在事务真正提交后再unlock()。这个坑做支付对账时尤其致命我建议团队里所有涉及分布式锁 数据库事务的代码都按先事务后解锁的原则评审。8. 面试官真正想考的点和你该记住的判断标准8.1 高频问题手写锁哪里不行面试官问用 Redis 实现分布式锁通常不是真想听你背出SETNX的用法而是想看你有没有踩过后续的坑。一个合格的回答链路是先给出最简版然后主动指出死锁问题加上过期时间再指出误删问题引入 UUID再指出判断 删除不是原子操作引入 Lua再指出不可重入引入 Hash 计数最后指出过期时间不好定引出 Redisson 的看门狗。能完整走完这条链路基本就展示出了真实的分布式锁实践经验。8.2 高频问题看门狗的实现细节看门狗的底层实现大致是加锁成功且没有指定 leaseTime 时Redisson 启动一个ScheduleService定时任务每隔lockWatchdogTimeout / 3毫秒执行一次续期 Lua 脚本把 key 的过期时间重新设为lockWatchdogTimeout每次执行业务解锁时取消续期任务。关键点在于续期是基于独立的定时任务触发的不是基于业务线程的 sleep所以业务方法里怎么耗时都不影响续期。但定时任务执行失败时锁会过期所以不能完全依赖看门狗。8.3 高频问题Redisson 为什么用 Lua 脚本这个问题的标准答案是Redis 执行 Lua 脚本是原子的脚本执行期间不会被其它命令插入所以判断持有者、重入计数、设置过期时间这些多个步骤合并成一个操作避免了并发条件下的竞态。如果你把答案展开到手写版本的缺陷 自研脚本的维护成本 Redisson 社区版成熟度就比死记硬背强很多。8.4 我判断一个分布式锁方案是否成熟的五个标准最后分享一个我总结的判断标准新项目引入任何分布式锁方案时都会对着过一遍互斥是否可靠加锁和解锁是否都通过原子命令 / Lua 完成。持有者宕机后锁是否能自动释放有没有过期时间。是否支持重入会不会在同线程嵌套调用时死锁。锁的超时策略是否可控业务耗时变化时有没有续期机制兜底。异常分支是否处理干净比如tryLock失败、unlock抛错、Redis 故障等场景。按这个标准回头看我手里曾有过的自研锁工具类第四条就直接被判不合格。这其实也是我最终死心塌地切到 Redisson 的原因——看起来只是换一个客户端实际上是把一套经过大规模验证的分布式锁语义完整接到项目里。用的时候少一点自己发挥把标准模板严格落实线上就能少折腾很多。
返回列表