ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redisson分布式锁实战:从原理到避坑指南

Redisson分布式锁实战:从原理到避坑指南 我平时不太写框架的快速入门但 Redisson 是少有的值得单独聊的例外。原因很简单分布式锁这个概念网上搜出来的内容九成都是教你用 SETNX 去 set 一个 key、设个过期时间先不说实现对不对光是各种边界情况就能让线上事故频发。而 Redisson 把锁做成了开箱即用的 Java API你只需要引入依赖、构造一个 client然后调用 lock() 和 unlock()剩下的可重入、自动续期、阻塞等待这些事底层全都帮你处理了。这篇文章我会从一个真实业务的角度把 Redisson 处理分布式锁的底层机制、关键配置、典型用法、常见坑全部串起来并且补充几张我在实际项目中打磨出来的“避坑清单”。如果你正准备把分布式锁落地到生产环境或者马上要去面试被问到 Redisson 源码细节这篇应该能帮你省下不少折腾的时间。1. 为什么单机锁在分布式环境里不好使先想一个问题一个普通的 synchronized 锁锁住的是当前 JVM 进程内的一个对象。单机部署时多个线程抢同一个锁JVM 自己就能保证互斥。可一旦服务部署成多节点请求被负载均衡分发到不同的 Tomcat 进程情况立刻不同节点 A 的 synchronized 根本不知道节点 B 里发生了什么。同样的“扣减库存”或“领取优惠券”操作在两个节点上同时执行两个线程都认为自己拿到了锁数据就乱了。更实际一点的场景多个服务实例同时消费同一个 MQ 消息如果不做幂等消息可能被重复处理多个定时任务节点同时触发同一个 job如果没有锁任务就会执行两遍秒杀场景下同一件商品被上万个请求打过来数据库里的库存字段极有可能被并发扣成负数。这时候我们就需要一个“所有节点都认账”的互斥机制也就是分布式锁。分布式锁的核心诉求只有三条第一互斥性。任何时刻只能有一个客户端持有锁。第二安全性。持有锁的客户端崩溃了锁必须能自动释放不能变成永久死锁。第三性能。获取锁和释放锁的开销必须足够小否则本来为了防并发结果把接口拖慢得不偿失。Redis 实现分布式锁是最主流的方案因为 Redis 本身是单线程执行命令天然具备原子性而且性能极高。但你直接基于 Redis 手写一套锁很快就会撞上四个麻烦锁过期了但业务没执行完导致并发临界区被突破持有锁的线程把自己的锁删了结果把别人刚获取的锁误删掉锁没有可重入能力同一个线程再次获取就会死锁Redis 主从切换时锁丢失整个互斥就失效了。Redisson 存在的意义就是把这四个坑全部堵上并且包装成一个连子类都能看懂的标准 Java 锁接口。2. Redisson 的核心设计锁是怎么变成一门“数学”的Redisson 的锁不是用简单的 SET key value EX 来实现的这一点非常重要。你去看它获取锁的 Lua 脚本会发现底层用的是 Redis 的 hash 数据结构。用一句话描述它做的事把一个锁对象存储为 Redis 里的一个 hashhash 的 key 是业务指定的锁名字hash 的 field 是持有锁的客户端唯一标识通常是 UUID 线程IDhash 的 value 是获取锁的次数。每次同一个线程重复获取锁就把这个 value 加一每次释放锁就把 value 减一。value 归零才真正删除这个 hash key。这个设计解决了两个核心问题。一个是可重入同一个线程可以反复 lock() 同一把锁而不会自我阻塞因为每次加锁只是在计数。另一个是误删问题释放锁之前Redisson 会先校验 hash 里的 field 是不是当前客户端的标识。若不是直接返回不删除若是才执行删除。也就是说锁的“归属”在数据结构层面就锁死了而不是靠“先 get 判断、再 del”这种非原子操作碰运气。还有一个很多人忽略的关键机制等待机制。你在用 Redisson 的 lock(long leaseTime, TimeUnit unit) 或者 tryLock 时其他线程在锁被占用时不会直接失败返回。它们会通过 Redis 的 pub/sub 订阅一个释放锁的 channel。当持有者释放锁后所有排队等待的线程都会收到通知然后重新尝试获取锁。这个设计比“轮询 sleep”要优雅得多避免了大量无效的 Redis 请求堆积。下面这张表是我自己在选型时整理的对比各方案处理分布式锁关键问题的能力关键能力普通SETNX过期Redisson锁自动过期支持但需自行管理支持watch dog 自动续期可重入不支持需自研原生支持基于 hash 计数误删他人锁极容易发生通过 clientId 校验避免等待锁不支持需自旋pub/sub 通知式等待主从切换锁丢失无法避免可搭配 RedLock但仍有限制如果你去面试被问到“Redis 分布式锁的实现原理”直接把这套 hash Lua pub/sub 的机制讲清楚基本算过关了。3. Redisson 快速接入三步把这把锁用起来讲理论讲太多容易飘直接上实践。接下来我会带你把一个最小可运行的 Redisson 锁跑通然后逐步添加到业务里的最佳实践。3.1 引入依赖与构建客户端假设你的项目是 Spring Boot先用 Maven 引入 Redisson 的 starter。Redisson 的版本更新比较快我习惯直接用最新的稳定版以下面的坐标为准dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency如果你不是 Spring Boot也可以直接用核心依赖redisson然后手动创建 RedissonClient。我这里还是用最常见的 Spring Boot 方式演示。最简单的构建配置spring: redis: redisson: config: | singleServerConfig: address: redis://127.0.0.1:6379 database: 0这是单机 Redis 的接法。生产环境一般会用主从或哨兵甚至 Redis Cluster配置会稍微复杂点。不过无论哪种方式Redisson 最终都会给你一个 RedissonClient 对象这个对象就是所有锁操作的入口。Configuration public class RedissonConfig { Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionMinimumIdleSize(10) .setConnectionPoolSize(32); return Redisson.create(config); } }有两点我在实践里体会很深。一是连接池大小要按并发量预留默认值偏保守高并发下会出现获取连接等待。二是 Redis address 那个前缀redis://不能丢如果开了 TLS 则要写rediss://这是很容易踩的小坑。3.2 第一个可重入锁实例下面这个例子几乎可以原样搬到你的业务代码里Resource private RedissonClient redissonClient; public void deductInventory(String productId, int count) { RLock lock redissonClient.getLock(lock:product: productId); try { lock.lock(10, TimeUnit.SECONDS); // 这里写你的核心业务逻辑 inventoryService.deduct(productId, count); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }注意我用的写法是lock.lock(10, TimeUnit.SECONDS)不是默认的无参 lock()。为什么因为这里直接指定了锁的过期时间是 10 秒。这个写法会关闭 watchdog 自动续期机制锁到期后 Redis 不管业务有没有结束都会强制释放。那什么时候用无参的 lock() 呢当你无法预估业务执行时长同时又不想因为锁过期导致并发数据错乱的时候。比如一单跨境订单的完整处理中间可能涉及多个 RPC 调用耗时浮动非常大。这时候无参 lock() 更安全因为 Redisson 会在锁持有期间每 10 秒自动续期一次把锁的有效期续到 30 秒业务只要没结束锁就不会过期。3.3 解锁时为什么要判断 isHeldByCurrentThread这段代码里我加了一个isHeldByCurrentThread()的判断很多人会问这不多此一举吗其实这个判断在非常规流程里能救命。想象一个场景你在 lock() 之后执行了一段耗时的 RPC锁已经因为时间耗尽自动释放了另一个线程 B 抢到了锁开始执行。此时你的线程终于走到了 finally如果直接调用 unlock()会发生什么Redisson 的 Lua 脚本会校验 lockValue 不是你的客户端标识从而拒绝删除锁。这个校验本身就是安全的兜底。但更微妙的是如果业务异常抛出线程 A 想释放一把已经不属于它的锁Redisson 内部的看门狗任务可能都没有正常退出下次再获取同一把锁时状态就变得很奇怪。加一个 heldByCurrentThread 判断是确保你只释放自己持有的锁从流程上更干净。再说得直白一点正确姿势是拿到锁之后把它放在 try-finally最后不解锁成功绝不罢休。我在生产代码里还见过把 unlock() 写在 catch 里的一旦核心业务没有抛异常锁就永远不会释放直接死锁。这是分布式锁使用中排名第一的惨案。4. 深入 lock() 与 tryLock()这 5 个参数值得抠一下Redisson 的锁方法表面看就两个lock() 和 unlock()但如果你只停留在这一层生产环境迟早给你颜色看。我挑几个实际用得上的方法和参数展开说说。4.1 lock() 的三种写法第一种lock()。无参默认以 30 秒为过期时间后续每 10 秒自动续期 30 秒。适合你拿不准业务耗时的场景就是防止业务没跑完锁先没了。第二种lock(long leaseTime, TimeUnit unit)。手动指定过期时间关闭续期。适合你明确知道这段业务最多 1 秒或 2 秒就结束比如简单的库存扣减、优惠券核销一次性把锁的时长给定死。这样如果服务进程直接宕机Redis 也会在指定时间之后自动释放锁不会死锁。第三种lock(long waitTime, long leaseTime, TimeUnit unit)。既设定等待锁的时间又设定锁的过期时间。这个在真实业务里是相当实用的。它允许如果锁被其他线程占用当前线程最多等待 waitTime 这么久等不到就直接放弃返回。配合返回值判断你可以做到“抢不到锁就快速失败”比如秒杀场景直接返回“您手速慢了”。4.2 tryLock() 与 waitTime 的区别tryLock()是我们的好朋友。无参 tryLock() 表示“尝试获取锁拿不到就立刻返回 false不等待”。带参 tryLock(long waitTime, long leaseTime, TimeUnit unit) 表示“尝试等待 waitTime 这么久期间锁释放了我就获取如果超时还没拿到就返回 false”。哪种场景用哪个应该很清楚了。但是这里有个性能陷阱我要提醒大家。如果你盲目地把 waitTime 设得很大比如 60 秒那么当锁被一个慢业务占住时你的线程会一直阻塞等待。在微服务体系里这会快速耗尽 Tomcat 线程池导致整个服务雪崩。我自己常用的做法是waitTime 不超过 3 秒业务可以失败重试的waitTime 调到 1 秒即可秒杀这种必须快速响应的场景直接用无参 tryLock()。4.3 看门狗机制watch dog到底怎么工作很多面试者提到 Redisson 的看门狗都会说“自动续期”但具体怎么续的、续多久说得清楚的人很少。看门狗其实是一个后台定时任务。当你通过无参lock()获取锁时锁的默认 leaseTime 是 30 秒同时 Redisson 会启动一个 netty 定时任务这个任务每 10 秒执行一次。每次执行时它会检查当前线程是否还持有这把锁如果还持有就将锁的过期时间重新设置为 30 秒。一旦你主动 unlock()看门狗任务立刻取消。这个机制保证了一个线程只要没主动释放锁并且进程本身还活着锁就永远不会过期。注意看门狗只在“未指定 leaseTime”时生效。一旦你使用lock(10, TimeUnit.SECONDS)这种指定过期时间的写法看门狗就关门了。原因很简单你都定了死期就没必要再续命。这两个模式不要混用面试里也经常拿这个当考点。4.4 锁的值与 Lua 脚本长什么样最后补充一下底层的 Lua 脚本。Redisson 加锁的脚本核心是这样的这是它早期版本的简化思路新版更复杂但核心一致if (redis.call(exists, KEYS[1]) 0) then redis.call(hset, KEYS[1], ARGV[1], 1); redis.call(pexpire, KEYS[1], ARGV[2]); return nil; 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 nil; end; return redis.call(pttl, KEYS[1]);这段脚本的逻辑非常好理解锁不存在就直接创建并计数 1锁存在且当前线程标识一致就加 1否则说明被别人持有返回剩余过期时间用于阻塞等待。整个检查与设置是原子执行的因此不会出现竞态条件。这就是 Redisson 在没有分布式事务的情况下仍能把锁的获取做到线程安全的根本原因。解锁脚本则是先校验持有者、再递减计数、计数归零才删除。这套原子操作避免了“误解锁”和“多释放”的问题。如果面试官让你手写一个分布式锁你可以用这段脚本作为底层依据把“为什么不用 setnxdel”这个问题彻底讲明白。 ## 5. 常用锁类型盘点不只是 RLock还有读写锁和信号量 很多人以为 Redisson 只能做互斥锁其实它提供了好几类锁适用场景完全不同。我只挑生产环境里用过且值得推荐的几种讲。 ### 5.1 读写锁ReadWriteLock 场景是“读多写少”。比如商品详情页大部分请求在读取缓存或数据库少部分请求在修改商品价格。如果全部加互斥锁读操作之间都会被串行化性能损失非常大。Redisson 的 RReadWriteLock 实现了读读并发、读写互斥、写写互斥。代码示例 java RReadWriteLock rwLock redissonClient.getReadWriteLock(lock:price: productId); RLock readLock rwLock.readLock(); RLock writeLock rwLock.writeLock();读锁可以被多个线程同时持有写锁是独占的。这个思路和 JUC 里的 ReentrantReadWriteLock 几乎一致只是从进程内扩展到了分布式环境。另一个常见用法是“缓存刷新”场景多个节点同时回源数据库刷新缓存如果你只是先查缓存再回源会产生缓存击穿。用读锁处理查询、用写锁处理回源可以显著降低数据库压力。5.2 公平锁FairLockRedisson 的RLock默认是非公平锁。什么意思就是线程 A 释放锁之后其他阻塞等待的线程不按先来后到的顺序抢锁谁运气好谁先抢到。大多数业务无所谓但某些对顺序敏感的场景比如用户排队领取资源需要公平。Redisson 提供了getFairLock()它的实现是让等待的线程按 FIFO 队列排队。用起来和普通锁没有区别只是底层维护了一个队列代价是每次抢锁都会多点 Redis 操作性能比非公平锁低一些。5.3 信号量和计数锁RSemaphore信号量本质上不是锁是“限流器”。比如某个资源最多允许 3 个线程同时访问剩下的人必须等待。Redisson 提供getSemaphore(name)初始化时指定许可证数量通过acquire()和release()控制占用和释放。这个在某些老系统接口限流、或者特殊资源池化管理的场景里很好用。实现上限流我一般优先用 RSemaphore而不是自己在业务里维护一个 Redis 计数器后者在并发扣减时容易产生负数。5.4 RedLock多节点锁的争议与用法RedLock 是 Redis 作者提出的一个多节点分布式锁算法要求锁同时写入多个独立 Redis 节点只有大多数节点写入成功才算获取锁。Redisson 提供了RedissonRedLock支持这个方案。但说实话我对 RedLock 的评价比较中性它能解决单点故障问题但由于需要多个节点响应性能比单机锁低很多而且业界对 RedLock 本身是否足够安全一直有争论。如果你不做严格的金融级场景单机 Redis 主从哨兵已经能满足绝大多数业务。用 RedLock 前一定问自己一句我的 Redis 节点真的会同时宕机吗如果不会就不要为低概率事件牺牲大量性能。6. 分布式锁的经典使用场景从秒杀到定时任务我把项目里用到的几个典型场景列出来你可以直接对照业务场景去套用。6.1 秒杀扣库存防超卖在 Redis 里先预扣库存或者直接在数据库更新库存。最核心的步骤是在“校验库存 - 扣减库存 - 生成订单”这整段流程上加锁。锁的 key 最好带商品 ID粒度要细到商品级别而不是全局一把锁。否则 A 商品的秒杀会把 B 商品也堵住并发能力极差。RLock lock redissonClient.getLock(lock:seckill:stock: skuId); if (lock.tryLock(0, 5, TimeUnit.SECONDS)) { try { int stock stockService.getStock(skuId); if (stock 0) { return 已售罄; } stockService.deductStock(skuId); orderService.createOrder(userId, skuId); } finally { lock.unlock(); } } return 系统繁忙请稍后再试;这里 waitTime 设成 0是因为秒杀场景抢不到锁就应该立刻返回而不是傻等。leaseTime 设成 5 秒是因为一段纯数据库操作通常不会超过这个时间。如果数据库存在慢查询那问题应该在 SQL 层面解决而不是靠无限续期锁。6.2 定时任务分布式调度防止重复执行定时任务框架如 xxl-job、quartz在高可用部署时通常会配多个执行器。如果不加锁每个节点到点都会触发同一个 job。解决方法是在 job 开始执行时尝试获取一把全区唯一的锁抢到锁的节点执行没抢到的直接退出。RLock lock redissonClient.getLock(lock:job:daily-settle); if (!lock.tryLock(0, 60, TimeUnit.SECONDS)) { return; } try { // 执行每日结算 settlementService.executeDailySettlement(); } finally { lock.unlock(); }leaseTime 要大于 job 的最长执行时间。我见过一个线上事故job 正常执行只需要 10 秒但某天数据量突增跑了 40 秒结果锁在第 30 秒过期另一个节点又进来重复执行导致账务重复入账。这类问题加一个看门狗就能解决改用无参 lock()让锁持续续期到业务真正结束。6.3 MQ 消息幂等MQ 在极端情况下会重复投递消息消费者如果不做幂等就可能重复记账。用 Redisson 实现“消费中”标记非常自然。消费者收到消息后尝试获取锁lock:msg:idempotent:{messageId}。抢到锁说明这个消息第一次被处理执行业务抢不到说明另一个节点正在处理或已处理过直接丢弃或进入待确认队列。6.4 分布式应用中的库存预占下单前先锁定库存支付成功再真正扣减支付失败或超时则释放库存。这个业务里锁的持有时间跨度往往很长几分钟到几十分钟。这种场景千万不要用固定 leaseTime必须用无参 lock() 加看门狗续期。否则用户还没付完钱锁就过期了结果同一件库存又被卖给别人。你可以把这个“预占”理解成一种分布式事务的轻量替代方案虽然不等价但在很多场景下已经足够把并发控制住。7. 实战踩坑与排查技巧这些坑我替你踩过了这部分我会写得比较碎但每条都是真实生产环境里能救命的经验。7.1 锁粒度与性能优化这是新手最容易犯的错所有业务共用一把锁。比如lock:user所有用户的所有操作都串行化并发直接残废。正确的粒度原则是“按业务资源类型拆分”商品锁按 SKU ID 分用户锁按用户 ID 分订单锁按订单号分。粒度越细并发度越高但也不能细到每个字段都加锁那样锁的数量太多Redis 内存也会炸。经验值是让锁代表一个可独立争用的业务实体而不是保护所有共享资源。7.2 锁超时对业务的影响固定 leaseTime 的场景里如果业务执行时间超过锁的过期时间其他线程就会提前拿到锁此时原来的线程还没执行完就会出现两个线程同时进入临界区。别以为我在制造焦虑我见过真实案例一个“发放优惠券”的接口因为慢 SQL 导致执行时间超过锁的 3 秒过期时间同一用户的同一优惠券被发了两次。解决的思路有两层第一层优先检查业务执行时间是否可控尽量对耗时的 SQL 和 RPC 做监控第二层如果确实不可控直接使用无参 lock()让看门狗兜底。不要为了追求“锁必须过期”而强行设一个太短的 leaseTime。7.3 Redis 连接与服务不可用如果 Redis 本身挂了Redisson 的锁就用不了了。此时所有获取锁的请求都会抛异常。防这种问题我建议在业务代码里做一个降级策略。核心金融类操作宁可失败也不要继续执行非核心的操作可以降级为“本地乐观锁”或直接放行但要明确接受并发可能带来的少量脏数据。说到底分布式锁只是“尽最大努力”保证互斥如果 Redis 宕了你不可能拿分布式事务来替代它很多时候业务需要在“可用性”和“一致性”之间做个取舍。7.4 锁没有释放最典型的问题是 unlock() 放在 try 里而不是 finally 里。业务流程正常的时锁能释放一旦业务抛异常直接跳过了 unlock锁就永久占着。另一个常见问题是在使用 tryLock 时忘了处理返回值拿到锁和没拿到锁执行的是同一段业务等于锁白加。我的习惯是所有锁操作都在 finally 中释放且开头必须先判断是否持锁成功。如果你觉得这个 template 写起来太啰嗦可以用一个简易的 AOP 注解或环绕通知去封装把 tryLock、finally-unlock 统一处理业务代码只暴露真正的业务方法。7.5 主从切换与锁丢失使用 Redis 主从复制时如果主节点宕机锁的数据还没来得及同步到从节点这时从节点被提升为新的主节点锁就丢了。Redisson 对这个问题的解决办法是提供 RedLock 多节点方案。但 RedLock 本身也有争议并且在大多数场景下主从切换造成的锁丢失概率极低。如果业务对一致性要求非常高我会把重点放在“数据校验”而不是“锁”上例如在数据库扣减库存时加乐观锁进行二次校验。分布式锁是第一道防线数据库乐观锁是最后一道防线两者结合比单纯依赖任何一方都靠谱得多。7.6 连接池耗尽Redisson 的连接池如果配置过小在高并发获取锁时会导致连接获取超时。现象是线上大量请求报RedisConnectionException但 Redis 本身负载并不高。排查时优先看连接池的配置。我在压测里的经验是单机 Redis 场景下连接池 size 设为 CPU 核心数的两倍能支撑大部分业务如果连接池调大后仍然报错基本可以判断是 Redis 端到客户端的网络链路或 Redis 自身的吞吐到了瓶颈就要考虑加从节点或换集群了。8. 附Redisson 分布式锁速查表问题原因解法死锁unlock() 未放在 finally统一用 finally 释放或用 AOP 封装重复执行固定 leaseTime 过短改用无参 lock()开启看门狗续期性能低下锁粒度太大按业务实体拆分锁 key误删锁未校验锁归属Redisson 已内置但不要手写 del并发等待阻塞waitTime 设太长减小等待时间快速失败主从切换丢锁Redis 同步延迟用 RedLock或在数据库侧二次校验连接池耗尽pool size 偏小调大连接池并监控 Redis 负载锁永久占用业务异常未释放或看门狗异常检查异常处理路径并设置合理 timeout9. 最后再分享一个小技巧我在做压测和线上问题复盘时会专门为 Redisson 的锁加一套日志切面记录每次加锁的 key、等待时间和持锁时间。这套切面最大的价值不是监控性能而是发现“锁竞争热点”。哪把锁等待时间特别长说明对应的资源争抢太激烈要么是并发量过高要么是锁粒度没有拆细致。沿着这条线去优化比盲目加 Redisson 的配置参数有效得多。另外获取锁时最好给每个业务 key 加上统一前缀比如lock:order:后续排查问题时在 Redis 里用SCAN lock:*就能快速定位那一堆顽固的锁。这些小习惯看起来不起眼但面对几百个微服务同时跑的环境你就能体会到标准化命名的价值了。希望这篇能帮你把 Redisson 用得更顺手如果有什么你自己踩过的坑欢迎在评论区补全互相省点时间。
返回列表