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

资讯详情

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

SpringBoot集成Redisson分布式锁实战:可重入锁、看门狗与生产踩坑

SpringBoot集成Redisson分布式锁实战:可重入锁、看门狗与生产踩坑 在上线之前我也没想到问题会出在这种地方订单服务从单机扩容到 3 个实例之后同一时间只有一台机器会跑定时关单任务结果在压测环境里发现同一笔订单被两台实例同时处理了一遍关单、退款、发短信全重复了。代码里明明给关键方法加了synchronized但三个 JVM 各自锁自己那一片等于没锁。后面我把核心业务换成了 Redisson 分布式锁这个问题才算按下去。说得直白一点Redisson 是 Java 生态里最常用的 Redis 客户端之一很多人搜的时候会顺手打成 Redission但指的是同一个东西。它不止帮你连 Redis还提供了一套现成的分布式锁实现可重入锁、读写锁、公平锁还有自动续期的看门狗机制。这篇文章就把我在 SpringBoot 项目里接 Redisson 做分布式锁的完整过程、底层原理和踩过的坑写清楚给正准备动手或者单纯想搞明白原理的朋友一份可以照着用的参考。1. 分布式锁到底锁的是什么——先给出问题的现场1.1 多实例部署下单机锁为什么靠不住先还原一下我当时遇到的问题。订单表里有一批待关闭的订单我用 SpringBoot 自带的Scheduled起了一个定时任务每分钟去扫一次把超时未支付的订单关掉。单机部署的时候一切正常synchronized或者ReentrantLock随你怎么用反正一个进程里同一时刻只有一个线程在扫表。后来为了高可用我把服务扩成 3 个实例问题立刻暴露每台实例都会触发同一个定时任务都不会感知另外两台在干什么3 个线程同时查出一批相同的待处理订单然后各自执行关单、库存回滚、通知用户用户被重复扣款或重复退款订单状态被后一个线程覆盖。单机锁synchronized、ReentrantLock的边界在 JVM 内部它拿的是进程内的监视器对象A 台的锁 B 台根本不知道。分布式场景下需要一把“不在任何 JVM 进程内”的锁让所有实例去同一个外部存储里竞争资源谁抢到谁就有资格操作。这就是分布式锁解决的问题。我选 Redis 做这个外部协调者主要是因为它快而且项目本来就在用。锁本质上就是一个带有互斥语义的 Redis 键谁成功创建了这个键谁就持锁处理完毕删除这个键就是释放锁。1.2 自己用 SET NX 写锁为什么写着写着就想换掉很多团队没有直接上 Redisson而是自己用 Redis 命令攒一套锁最常见的写法是这样的# 尝试加锁key 不存在才能写入 SET lockKey 1 NX EX 30 # 业务处理... # 释放锁 DEL lockKey压测和极端场景下这套自研锁会有几个很现实的问题。第一锁没有持有者身份标识线程 A 在业务超时后锁自动过期线程 B 拿锁执行结果 A 还没结束最后 A 的DEL把 B 刚拿到的锁删了直接并发失控。第二不具备可重入性同一个线程里如果某个 Service 嵌套调用了另一段也需要加同一把锁的方法第二次SET NX会因为 key 已存在而失败自己把自己卡死。第三业务执行时间不可控时固定 30 秒过期时间很难选调短了容易提前释放调长了进程万一挂掉要等很久锁才自动清掉。这些问题理论上都能用 Lua 脚本和随机数解决但要自己实现“锁身份校验 原子释放 自动续期 可重入计数”代码量不小边界情况也容易漏。Redisson 把这些都做好了我直接用它的RLock就行相当于把一个“单机 JVM 锁”的体验平移到了分布式环境里。2. 这些场景需要分布式锁这些场景别凑热闹2.1 不加锁真的会出事故的业务场景不是所有并发场景都要上分布式锁但下面这几类场景如果项目是多实例部署我建议认真考虑加锁。第一库存扣减和余额变更。比如秒杀场景里库存是共享资源多个实例同时执行UPDATE stock SET count count - 1 WHERE id ?会带来超卖和扣减不一致。加分布式锁把“查询库存、校验、扣减”这个复合操作串行化是最直接的做法。第二幂等处理和防重复回调。支付回调、消息推送回调这类接口天然会重复调用多实例下同一个回调可能在两台机器同时执行。用业务单号作为锁 key谁先拿到锁谁处理后到的直接返回“处理中”比在数据库里加唯一索引更灵活。第三多实例定时任务防重。这是我踩过的坑。如果你的定时任务没有用 XXL-Job 这类独立调度平台而是每个实例都跑那任务入口处加一把分布式锁相当于只在集群里选一个“leader”执行。第四热点缓存并发重建。缓存过期瞬间大量请求同时回源数据库重建缓存会造成数据库压力尖刺。用缓存 key 作为锁 key第一个线程回源并写缓存后续线程发现已有结果直接返回有效避免缓存击穿。2.2 不需要分布式锁的情况用了反而拖垮性能分布式锁不是银弹引入它会增加一次 Redis 网络往返锁的粒度控制不好还可能让整个接口吞吐下降一个数量级。下面这些情况我一般建议先别用服务还是单实例部署。synchronized或 JDK 的ReentrantLock完全够用没必要为分布式锁付出额外成本。数据层本身就有原子操作。比如UPDATE ... SET count count - 1 WHERE count 0数据库自带行锁加分布式锁等于重复保护。可以接受最终一致性。比如一个统计接口偶尔读到旧数据不影响业务就不需要硬锁。并发量极低操作又是单条 Redis 命令或单条 SQL 就能完成的原子操作直接执行完事。我的习惯是先明确“到底共享的是什么资源”再决定锁的粒度。分布式锁解决的是“跨进程修改同一个共享资源”如果一个操作对单条记录只做一次原子更新数据库、Redis 自身的原子命令就能保证正确性那就不需要额外加锁。3. 把 Redisson 接进 SpringBoot依赖、配置和 Bean 初始化3.1 Redisson 到底是什么和 Jedis、Lettuce 有什么区别很多人问过我项目里已经用RedisTemplate了为什么还要引入 RedissonRedisTemplate底层用的连接池是 Lettuce 或 Jedis它们解决的是“建立连接、执行命令”这件事属于基础通信层。Redisson 是在这个通信层之上做了大量封装它提供分布式锁、分布式集合、分布式队列、限流器、原子计数器等高级对象这些对象天然适配分布式场景。以分布式锁为例它不只是替你执行一条SET NX而是通过一段 Lua 脚本保证“判断 key 是否存在、写入身份、设置过期时间”这三个动作的原子性同时又实现了可重入计数、看门狗续期。这些逻辑如果自己写要么需要写一堆 Lua要么用多条命令组合然后小心处理原子问题。Redisson 的价值就是把这些琐碎但关键的细节封装成 API。3.2 依赖选型官方 starter 还是原生依赖SpringBoot 项目接入 Redisson 有两种主流方式。第一种是直接用官方提供的 starterSpringBoot 的自动装配能帮我把RedissonClient注入容器配置文件也比较简单。dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.5/version /dependency第二种是不使用 starter只引入核心依赖自己写Configuration创建RedissonClientBean。这种方式在项目里 Redis 连接配置比较复杂、或者我想完全手工控制配置项时更灵活。dependency groupIdorg.redisson/groupId artifactIdredisson/artifactId version3.23.5/version /dependency需要注意版本兼容。Redisson 新版本对 SpringBoot 的兼容范围也有变化如果你的 SpringBoot 主版本比较高选 Redisson 版本时最好去官方 GitHub 的 release 说明里看一眼对应的 SpringBoot 版本区间避免出现NoSuchMethodError这类隐蔽问题。3.3 配置文件与 RedissonClient 初始化如果用 starter可以在application.yml里直接配置spring: redis: redisson: config: | singleServerConfig: address: redis://127.0.0.1:6379 database: 0 connectionMinimumIdleSize: 4 connectionPoolSize: 16这里面spring.redis.redisson.config是 Redisson 自己的配置载体写法是 Redisson 的 YAML 风格。有一点非常容易踩坑spring.redis.host、spring.redis.port这些是 Spring Data Redis 的配置Redisson 的 starter 并不会自动读取它们。如果你项目里主要通过RedisTemplate操作缓存同时想用 Redisson 做锁需要把两套连接配置分开维护。如果不用 starter我用的是手动创建 Bean 的方式代码长下面这样Configuration public class RedissonConfig { Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setDatabase(0) .setConnectionMinimumIdleSize(4) .setConnectionPoolSize(16); return Redisson.create(config); } }destroyMethod shutdown是在 Spring 容器关闭时主动释放 Redisson 的线程池和连接池资源这个细节很容易被忽略但如果不加应用重启时可能出现连接没释放干净的告警。有一点提醒一下Redisson 的客户端连接是和RedisTemplateLettuce/Jedis完全独立的两套池子各自持有连接资源。如果两个都配置了很大的池大小Redis 服务端连接数会翻倍我之前就遇到过开发环境 Redis 连接数被占满的故障配置的时候注意估算整体连接数上限。4. lock()、tryLock()、unlock() 背后——可重入锁的存储与续期机制4.1 一个标准加锁代码模板以及为什么必须 try/finally接入 Redisson 之后我最常用的锁 API 是tryLock。下面这段代码基本是我的通用模板Resource private RedissonClient redissonClient; public void changeStock(String orderId, int delta) { String lockKey biz:stock:order: orderId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(500, 系统繁忙请稍后重试); } // 业务逻辑查库存、校验、扣减、写流水 doChangeStock(orderId, delta); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException(500, 系统繁忙请稍后重试); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }这里几个细节拆开说。tryLock(3, 30, TimeUnit.SECONDS)表示最多等待 3 秒拿锁拿到锁之后锁的租约时间是 30 秒。如果 3 秒内没拿到返回false直接给前端一个“系统繁忙”的响应避免大量请求全部阻塞在锁上。finally里释放锁是铁律。业务逻辑抛异常时如果不释放锁这个锁会一直被当前线程持有直到租约过期或看门狗不断续期导致后续请求全部拿不到锁。lock.isHeldByCurrentThread()是释放锁前的确认。防止因为某些异常路径导致锁没拿到或者锁已经被别的线程替换当前线程调unlock()产生不可预期的副作用。如果你对业务执行时长很有信心也可以直接调用lock.lock()内部会阻塞等待并且默认启用看门狗。但我更推荐带等待超时的tryLock因为在生产环境里让接口无限等锁往往意味着把大量的线程积压在 Redis 调用上最后出现请求线程池耗尽。4.2 Redis 里锁的存储结构以及“可重入计数”是怎么实现的Redisson 的可重入锁之所以能“重入”不是靠本地线程变量而是靠 Redis 里的 Hash 数据结构。假设锁 key 是biz:stock:order:10001持有这个锁的线程会记录成下面这样Redis KeyHash FieldHash Value含义biz:stock:order:100019d7c1a5e-3f22-4b11-8c10:1231锁标识 线程ID 作为字段值代表重入次数其中 Hash Field 通常由UUID : Thread.currentThread().getId()组成这个字段就是“锁持有者标识”。当同一个线程再次调用lock()时Redisson 执行的是类似HINCRBY的计数逻辑值从 1 变成 2这就是第二次重入。所谓“可重入”本质就是每次加锁把对应持有者的计数加 1每次解锁把计数减 1当计数减到 0 时才真正删除这个 key。这样外层方法和内层方法都可以对同一把锁执行加锁、解锁而不会把自己卡死。Redisson 实现这一步用的是 Lua 脚本核心逻辑大致如下-- 加锁逻辑 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]);-- 解锁逻辑 if (redis.call(hexists, KEYS[1], ARGV[3]) 0) then return nil; end; local counter redis.call(hincrby, KEYS[1], ARGV[3], -1); if (counter 0) then return 0; else redis.call(del, KEYS[1]); return 1; end;多实例场景下两个线程可能在同一个时间点尝试加锁如果“判断 key 是否存在”和“写入 key”是两个独立命令中间就会被其他线程插入产生错误加锁。Lua 脚本在 Redis 里是原子执行的把这些判断和写入塞进一个脚本从根上杜绝了竞态窗口。这也是 Redisson 比自己在命令行里拼SETNX EXPIRE靠谱的核心原因。4.3 看门狗续期不指定过期时间时锁是怎么自动续命的关于过期时间Redisson 有一个默认参数叫lockWatchdogTimeout默认值 30000 毫秒也就是 30 秒。当你在调用lock()或tryLock(waitTime, TimeUnit)时没有显式指定租约时间Redisson 会启动一个后台延时任务每 10 秒默认值的 1/3执行一次续期把 key 的过期时间重新设置为 30 秒。这个后台任务就是大家常说的看门狗WatchDog。这样设计的好处是业务方法执行多久锁就能续命多久理论上不用担心“业务没跑完锁先过期”的问题。与此同时如果持有锁的线程所在的应用进程突然崩溃、宕机看门狗也会随之消亡Redis 里的锁最多 30 秒后自动过期不会出现死锁。有个容易误解的点如果你在调用tryLock(3, 30, TimeUnit.SECONDS)时显式传了第二个参数30这个 30 会被当成固定的锁租约时间此时看门狗不再启动。也就是说锁持有 30 秒后强制自动释放无论业务是否执行完。所以我个人的习惯是业务方法执行时间不可控尽量不传租约时间用看门狗自动续期业务方法必须限定最长执行时间再传租约时间同时把业务逻辑放在这个时间内锁租约时间并不是越长越好太长会拖累锁异常释放的效率太短则可能业务没结束锁就没了。5. Redisson 的读锁与写锁读写锁怎么落地5.1 读写锁的典型场景读读并发、写写互斥、读写互斥分布式锁不一定全是互斥锁。有些场景里多个线程只是读取一个共享配置如果也用完全互斥的锁所有读请求会被强行串行化白白损失并发能力。Redisson 提供了RReadWriteLock语义和 JDK 的ReadWriteLock对齐读锁和读锁之间不互斥多个线程可以同时持有读锁写锁和写锁之间互斥读锁和写锁之间互斥有写锁时读锁拿不到有读锁时写锁也要等待。我常用的一个场景是本地规则配置一个接口负责更新路由规则全公司的请求都会实时读取这份规则。读取频率极高更新频率很低。如果没有读写锁所有读取请求都去抢同一个互斥锁性能明显下降如果用读写锁读取直接并发进行只有更新时才需要排空读流量整体设计就合理得多。另一个场景是构建缓存。比如导出一份大报表导出过程耗时较长期间其他线程反复读旧报表没问题但如果有人触发了重新生成就不希望读线程在旧数据和新数据之间切换读到半成品。读写锁很适合控制这种“先写后读”的发布语义。5.2 RReadWriteLock 接入代码用 RReadWriteLock 也是一样的套路先根据一个业务 key 拿到读写锁对象再分别取读锁和写锁Resource private RedissonClient redissonClient; public void readConfig() { RReadWriteLock rwLock redissonClient.getReadWriteLock(biz:config:route); RLock readLock rwLock.readLock(); readLock.lock(); try { // 读取配置并组装到缓存 return loadRouteConfig(); } finally { readLock.unlock(); } } public void updateConfig(RouteConfig newConfig) { RReadWriteLock rwLock redissonClient.getReadWriteLock(biz:config:route); RLock writeLock rwLock.writeLock(); writeLock.lock(); try { // 写配置、刷缓存、发布新版本 saveRouteConfig(newConfig); } finally { writeLock.unlock(); } }注意readLock()和writeLock()必须来自同一个RReadWriteLock实例也就是同一个业务 key。如果两个地方分别创建了RReadWriteLock对象但 key 不一样那它们之间没有协调关系读写锁保护也就失效了。5.3 读写锁内部模式以及一个容易死锁的升级陷阱Redisson 读写锁在 Redis 里的实现比普通锁多了一层模式标记。大概做法是在锁 key 对应的 Hash 里存一个mode字段用来标记当前锁状态是“读模式”还是“写模式”。模式为read时后续读锁可以继续重入写锁请求会被阻塞模式为write时其他读锁和写锁都不能进入直到写模式结束。这里面有两个原则需要记牢。第一个是“同一线程持有写锁时可以继续拿读锁”因为写锁本身就具有最高排他性再拿读锁属于重入的顺带能力。第二个是“同一线程持有读锁时不建议去拿写锁”。如果线程 A 先持有读锁然后因为业务逻辑升级又想去拿写锁而其他线程也持有读锁这个写锁永远等不到所有读锁释放线程 A 就陷入永久阻塞。这种从读锁升级到写锁的操作在 JDK 的ReadWriteLock里本身就容易造成死锁Redisson 也绕不开这个语义陷阱。解决思路有两种如果真的需要读后写一开始就别用读锁直接用写锁保护整个流程或者把“判断是否需要升级”放在加锁之前完成而不是拿到读锁之后再尝试获取写锁。6. 生产环境里更容易踩的几个坑以及我的处理习惯6.1 锁 key 的命名直接决定锁粒度合不合理锁 key 的粒度其实就是你并发的粒度。我见过有人偷懒整个服务只用一个静态锁 key比如biz:user:lock。这样一来所有用户的所有操作全被串行化接口吞吐量直接崩掉。我的命名习惯是业务域:资源类型:唯一标识例如订单关单order:close:{orderId}用户签到user:sign:{userId}:{yyyyMMdd}库存扣减stock:deduct:{skuId}。唯一标识挑选的核心标准是“业务真正修改的最小资源单位”。能精确到订单号的就不要只精确到用户能精确到商品 SKU 的就不要只精确到店铺。锁粒度越细能并发执行的请求越多但对 Redis 的 key 数量压力也越大所以也要平衡。真要追求极致还可以给锁 key 加上业务前缀方便在 Redis 里按前缀排查和清理。6.2 拿不到锁时是阻塞等待还是快速失败加锁失败后怎么处理直接影响用户体验和系统稳定性。lock()会一直阻塞等待我不太建议在面向用户的请求链路里使用因为等待时间不可控请求线程会被占住一旦某个锁长时间被占用所有打过来的请求都会堆积在线程池里。tryLock(waitTime, ...)是我更推荐的方式给锁一个最大等待窗口比如 3 秒拿不到就立刻返回失败。用户侧的表现是“系统繁忙”配合前端重试策略整体体验可控。还有一种做法是拿到锁失败后进入一个延后重试队列或者直接返回一个幂等令牌让用户稍后查询结果这个要看具体业务形态。另外锁等待时间和租约时间千万不要设置成一个值。比如你设置等待 5 秒、租约 5 秒那么两个请求几乎必然在 5 秒边界处产生锁刚释放、另一个刚好拿到的临界情况。一般等待时间小于或等于租约时间的三分之一能让锁周期更稳定。6.3 锁的巡检、优雅停机与残留锁清理生产环境出过几次事故之后我习惯了给锁加一层可观测性。最简单的做法是在加锁解锁前后打印耗时和 key但要注意别把敏感业务信息打进去。稍微完善一点可以在 Redis 里预设一批前缀统一、便于检索的锁 key用HGETALL或SCAN去巡检看是否长期存在某个锁 key 没释放。应用优雅停机时如果 Redisson 的线程池没有正确关闭看门狗可能还在继续续期导致锁在应用退出后仍然存在较长时间。所以我配置RedissonClientBean 时一定带上destroyMethod shutdown同时在停机逻辑里显式释放业务上还在持有的锁。如果业务确实出现异常导致锁持有者线程已经不存在但 Redis 里锁 key 还在可以等lockWatchdogTimeout到期自动释放。也可以手工在 Redis 里删除对应 key 应急但一定要先确认当前没有活动业务正在使用这把锁否则删锁等于放进来两个并发的操作后果更严重。6.4 面试官爱问的几个“分布式锁”加分点Redisson 分布式锁也是面试高频题围绕标题里这几个关键词背下来不如真正理解。可重入锁的原理本质是 Hash 结构里的计数增减加锁HINCRBY解锁直到计数归零再删 key。看门狗的续期默认 30 秒、每 10 秒续一次显式传租约时间后看门狗不会启动。读写锁的关键就是区分读模式、写模式读读并发、写独占、读写互斥。再说一个小经验面试里被问到“Redis 分布式锁和 ZooKeeper 分布式锁怎么选”尽量不要只说“Redis 快ZooKeeper 慢”而是围绕场景展开。如果你的业务能接受轻微的数据不一致用 Redisson Redis 足够如果业务对一致性极其敏感而且你愿意牺牲一部分性能和运维复杂度ZooKeeper 的临时顺序节点方案可以提供更强的可靠性。技术选型永远是看业务场景不是看哪个方案更“高级”。我自己在项目里的习惯是优先用 Redisson因为接入成本低、API 简洁、可重入和读写锁都能直接拿到配合看门狗机制大部分互斥需求都能覆盖。真到了需要极强一致性保证的阶段我会单独审视业务而不是盲目把所有的锁都换成 Zookeeper。Redisson 这把锁不是万能的它解决的是“多个进程必须互斥操作同一个共享资源”的问题但锁背后的业务逻辑是否原子、事务是否完整、幂等是否兜底这些还是要靠应用自己设计好。把分布式锁当成最后一道防线而不是唯一防线是我这几年做并发改造最深的一点体会。
返回列表