ARTICLE DETAIL

资讯详情

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

Redis分布式锁从手写到Redisson:核心原理与生产实践

Redis分布式锁从手写到Redisson:核心原理与生产实践 1. 先搞清楚分布式锁到底解决什么问题分布式锁这个东西只要你的系统一拆成多实例几乎都会撞上它。最典型的例子就是秒杀场景里的库存扣减三个应用实例同时收到下单请求如果每个实例都在本地用一个 synchronized 或者 Lock 做并发控制那只是锁住了当前进程里的线程另外两台机器照常往里冲库存最后必然超卖。这时候就需要一把所有实例都能看见、都能争取的锁也就是分布式锁。很多人会问能不能用数据库的唯一索引或者悲观锁来实现可以但问题也很明显数据库自身容易成为瓶颈行锁竞争激烈时性能下滑很快而且事务长时间未提交还会拖垮连接池。至于 ZooKeeper 或 etcd 这类协调服务它们的强一致模型很诱人但引入额外组件、运维成本和部署复杂度都要考虑。而 Redis 因为高性能、数据结构简单、落地成本低成了目前最主流的分布式锁承载者。基本上你出去面试只要是聊到缓存治理、并发控制、中间件落地分布式锁都是绕不开的一环。这篇内容适合两类读者一是已经在项目里用过 Redis但只是简单 get/set想了解分布式锁原理和正确姿势的人二是准备面试想要一套能说清楚“为什么手写锁容易踩坑、Redisson 为什么更好”的逻辑链的开发者。我会从手写一把锁开始逐步暴露问题再解决问题最后落到 Redisson 的最佳实践全程用 Java 代码示例思路同样适用于其他语言。2. 手写一把可用的 Redis 分布式锁从踩坑到完善手写分布式锁的过程本质上就是不断发现分布式系统里那些“看似简单、实则坑深”的问题。我会带着你从最幼稚的版本开始一步步演进到接近生产可用的形态。2.1 第一版SETNX 加过期时间雏形有了但会死锁最直觉的思路用一个 Redis Key 表示锁谁成功写入谁就拿到了锁。Redis 里恰好有SETNX命令意思是“只有 Key 不存在时才设置成功”。于是很多人第一版锁长这样// 获取锁 Boolean success redisTemplate.opsForValue().setIfAbsent(lock:order:123, locked); if (Boolean.TRUE.equals(success)) { try { // 执行业务逻辑 doSomething(); } finally { // 释放锁 redisTemplate.delete(lock:order:123); } }这个版本暴露出的第一个问题如果业务逻辑抛异常或者服务进程在释放锁之前突然宕机Key 永远不会被删除锁变成了死锁。之后所有请求都会卡在获取锁这一步事故就这么发生了。于是大家会条件反射地加一个过期时间redisTemplate.opsForValue().setIfAbsent(lock:order:123, locked, 30, TimeUnit.SECONDS);过期时间解决了死锁但又引出两个新问题释放锁时可能把别人刚拿到的锁删掉加锁和设置过期时间如果分两步执行中间进程宕机同样会导致死锁。第二个问题在早期 Redis 版本中确实存在因为当时 SETNX 和 EXPIRE 是两条独立命令后来 Redis 2.6.12 提供了SET key value NX EX seconds的原子写法这个问题才算在命令层面被解决。但“误删别人的锁”这个问题光靠原子加锁还不够。2.2 第二版用唯一标识防止误删锁设想这样一个时间线线程 A 拿到锁设置了 30 秒过期但 A 的业务执行了 35 秒锁已经过期自动释放线程 B 拿到锁开始执行此时 A 终于执行完毕在 finally 里执行 delete删除的其实是 B 的锁。B 后面的并发请求又可能拿到锁整个互斥就失效了。正确的做法是加锁时写入一个当前线程或请求的唯一标识释放前先判断这个标识是否还属于自己只有是自己的才删。这个过程用两条命令也能做但判断和删除之间仍然有时间窗口所以必须用 Lua 脚本保证原子性-- 加锁 if redis.call(set, KEYS[1], ARGV[1], NX, EX, ARGV[2]) then return 1 else return 0 end-- 释放锁 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endJava 端用唯一标识可以取 UUID 拼接线程信息或者用 Thread ID 加请求序号只要保证在锁的维度上全局唯一即可。很多网上教程到这一步就结束了说“这样就完美了”。但实际上如果业务执行时间超过锁过期时间锁已经释放后续线程照样可以进入临界区所以还需要看门狗机制或者手动续期。手写这套逻辑很繁琐这也正是我们后来转向 Redisson 的原因之一。2.3 第三版可重入与自旋等待除了误删还有两个高频需求可重入和阻塞等待。可重入是指同一个线程在持有锁的情况下再次获取同一把锁应该能直接成功。比如一个方法加锁后内部调用了另一个同样加锁的方法。基于 Redis 的实现里可重入最常见的做法是用 Hash 数据结构把锁的信息存成field:value形式field 存放线程标识value 存放重入次数-- 加锁可重入 if redis.call(exists, KEYS[1]) 0 then redis.call(hset, KEYS[1], ARGV[1], 1) redis.call(expire, 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(expire, KEYS[1], ARGV[2]) return 1 end return 0-- 释放锁每释放一次重入次数减一减到 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 return 1 else redis.call(del, KEYS[1]) return 1 end阻塞等待则更像 JUC 里的 Lock 语义拿不到锁的线程不会立刻返回失败而是自旋重试。自旋需要控制频率不能死循环空转打爆 Redis我见过一种做法是让线程 sleep 50 到 200 毫秒再重试同时设置最大等待时间。但这样既有延迟又浪费 CPURedisson 给出的答案是信号量机制加发布订阅等锁被释放时通知等待线程而不是无脑重试。2.4 从手写走向 Redisson不只是因为懒写到这里你应该能感受到一把生产可用的分布式锁远不是一条 SETNX 那么简单。你需要处理原子性、唯一标识、可重入、续期、阻塞唤醒和异常兜底。如果这些你自己实现意味着每行代码都要经过充分压测和 Review还要考虑 Redis 连接池、序列化、超时配置等各种边界条件。与其重复造轮子不如选择一个被广泛验证的成熟方案。Redisson 是一个基于 Redis 的 Java 客户端官方定位是分布式协调工具包它的 RLock 就是对分布式锁的完整封装。而且 Redisson 内部用的是 Lua 脚本把加锁、重入、续期、释放逻辑全部原子执行。接下来我们就看看它在项目里怎么落地。3. Redisson 接入与生产实践Redisson 不像 Jedis 和 Lettuce 那样把自己定位成纯连接客户端它提供了一整套分布式数据结构分布式锁、分布式集合、分布式队列、原子计数器等。它也支持 Spring Boot 自动装配代码侵入性很小。3.1 Redisson 快速接入与配置Maven 工程里加依赖dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.5/version /dependency在application.yml里写基础配置spring: redis: redisson: config: | singleServerConfig: address: redis://127.0.0.1:6379 password: null database: 0如果你用的是 Spring Boot 2.x也可以通过RedissonClient手动构造Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionMinimumIdleSize(4) .setConnectionPoolSize(16); RedissonClient redisson Redisson.create(config);有个细节容易被忽略Redisson 默认的序列化器是 Kryo 或 FST而 Spring 容器里常配置的是 Jackson 序列化器。如果同一个 Redis 实例既给 Spring RedisTemplate 用又给 Redisson 用建议在 Config 里显式指定与业务一致的序列化方式避免两种客户端读写同一个 Key 时发生反序列化异常。调试的时候我用 Another Redis Desktop Manager 或 RedisInsight 查看锁 Key 的结构RLock默认会使用Hash结构存储线程信息和重入计数一眼就能看出来是谁在持有锁。3.2 一次最正规的加锁释放流程我建议团队里所有加锁操作都走同一个封装好的工具类而不是直接把 RLock 散落在业务代码里。规范流程长这样Autowired private RedissonClient redissonClient; private static final String LOCK_PREFIX biz:lock:; public void withLock(String lockName, long waitTime, long leaseTime, TimeUnit unit, Runnable action) { RLock lock redissonClient.getLock(LOCK_PREFIX lockName); boolean locked false; try { locked lock.tryLock(waitTime, leaseTime, unit); if (!locked) { throw new RuntimeException(获取分布式锁超时, lockKey lockName); } action.run(); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }这里有两个关键点。第一tryLock(waitTime, leaseTime, unit)的语义是最多等waitTime毫秒如果拿到锁锁自动在leaseTime后过期。如果leaseTime不传Redisson 会启用看门狗自动续期。这引出了常见的面试题“Redisson 的锁会不会因为业务没执行完而提前释放”标准答案是如果你没指定 leaseTime看门狗会默认每隔 10 秒给锁续期到 30 秒直到线程主动释放如果你指定了 leaseTime看门狗就不会启动锁在指定时间后必然释放。第二释放锁前用isHeldByCurrentThread()判断是有意义的虽然 Redisson 内部通过线程标识保证了不会误删其他线程的锁但显式判断可以让日志更清晰。我在项目里遇到过一种情况业务方法在持有锁期间主动删除了当前锁 Key然后 finally 里又调用 unlock这时候 Redisson 会抛出IllegalMonitorStateException控制不了锁的持有者。排查这种问题最快的办法就是去看锁 Key 的 type 和 ttl确认是不是被外部命令误删了。3.3 看门狗机制锁过期了还在执行业务怎么办看门狗是 Redisson 最值得拿出来讲的设计。它的原理说起来不复杂加锁成功后如果锁没有设置过期时间即 leaseTime 为 -1Redisson 会在客户端启动一个定时任务默认每 10 秒执行一次检查锁是否还存在如果存在就把锁的过期时间重置为 30 秒。这相当于一个后台线程持续为当前线程的锁续命直到线程释放锁或进程崩溃。默认参数是通过lockWatchdogTimeout配置的默认值 30000 毫秒续期周期是它的三分之一。如果你的业务平均执行时间很长想调大这个值我建议基于实际压测结果设置而不是随意放大。因为看门狗只能续期持有者自己的锁如果锁因为 Redis 主从切换、网络分区等原因丢失看门狗也无法挽回。另一个实际经验不要在持有锁期间做太重的 IO 操作比如远程调用第三方接口、批量写数据库因为这些操作的耗时不可控一旦超过看门狗能覆盖的范围锁被自动释放后其他线程进入临界区逻辑就会出问题。我见过一个案例业务里嵌了一个外部短信接口某段时间对方响应变慢单次调用耗时四五秒一次完整业务超过 30 秒结果锁提前失效多个线程并发扣减最终账实不符。后来我们把所有远程调用移到加锁前或者给远程调用单独设超时时间才把问题压下去。3.4 主从切换与 Redlock 的争议面试官非常喜欢问一个问题Redis 主从架构里客户端在 Master 上加了锁但数据还没同步到 SlaveMaster 宕机后 Slave 被提升为新的 Master锁丢了怎么办Redisson 的默认单机模式确实无法完全规避这个问题因为它只和单个 Redis 节点交互。Redis 的作者 Antirez 提出了 Redlock 算法在多个独立部署的 Redis 节点上依次加锁只有当超过半数节点加锁成功且总耗时小于锁有效时间才认为锁真正获取成功。Redisson 提供了RedissonMultiLock来支持这种多节点模式。但这里必须说清楚Redlock 在业界一直有争议包括分布式系统专家 Martin Kleppmann 和 Antirez 之间的经典论战。核心矛盾在于即使加了 Redlock客户端 GC 停顿仍然可能导致进程暂停期间锁过期而其他客户端拿到锁。Redlock 只能降低概率做不到绝对互斥。所以我的建议是先从业务层面容忍极低概率的重复执行比如扣减前查一次库存、生成幂等号再决定是否值得引入 Redlock。大多数业务系统的 Redis 主从切换频率很低加上锁超时时间设置合理单机 Redisson 锁已经够用只有到了资金类、强一致类业务才需要更复杂的设计。4. 实战中的常见问题与面试高频考点这一部分我用速查表的方式整理我在排查分布式锁问题时最常遇到的情况以及对应的解决方案。4.1 常见故障排查速查表现象可能原因排查方法解决建议获取锁一直失败日志报超时锁未释放看门狗失效Redis 里TTL lockKey查看剩余时间HGETALL lockKey查看持有线程确认业务是否长时间阻塞排查是否手动删除过锁锁提前释放并发进入临界区业务耗时超过 leaseTime主从切换锁丢失加日志记录锁获取与释放耗时监控 Redis 主从切换事件调大 leaseTime 或依赖看门狗远程调用移出临界区释放锁时报 IllegalMonitorStateException当前线程不是锁持有者打印调用链确认是否有异步线程或代理类导致的线程标识变化用isHeldByCurrentThread()判断后再 unlock锁 Key 存在但业务没在执行进程崩溃前持有锁锁未过期看锁的 TTL 和创建时间锁过期时间不能太长建议 30 秒内配合看门狗压测时 TPS 突然下降锁竞争激烈大量线程自旋等待Redis 的INFO commandstats观察 Lua 调用次数应用侧打印等待时间缩小锁粒度用 tryLock 设置合理等待时间排查分布式锁问题时我通常会先开一个 Redis 客户端工具盯住锁的 Key。这里顺带提一句很多人纠结 Redis 可视化工具该选哪个我现在常用 RedisInsight 和 Another Redis Desktop Manager前者官方维护、功能全后者轻量、适合快速看 Key。排查锁问题看 Key 的type、ttl、hgetall三项就够了不需要很重的监控面板。4.2 分布式锁面试题答题模板面试题问来问去其实就那么几类我整理了一个答题思路比死记硬套强。问“分布式锁有哪些实现方案”先分层数据库锁、Redis 锁、ZooKeeper 锁、etcd 锁。然后说各自适用场景数据库锁简单但性能差Redis 性能好但存在主从切换的极端情况ZK 和 etcd 强一致但引入额外组件。千万别一上来就只说 Redis要让面试官觉得你有全局视野。问“Redis 分布式锁怎么实现”从 SETNX 讲起主动说出三个坑死锁、误删、原子性。然后讲解决方案过期时间、唯一标识、Lua 脚本。再讲 Redisson 的可重入 Hash 结构和看门狗机制。问“Redisson 锁的原理”要能说出几个关键词Lua 脚本、Hash 数据结构、看门狗、发布订阅。如果面试官追问可以画一条时间线线程 A 加锁成功线程 B 通过 Redis 的 Channel 订阅锁释放消息A 释放后 B 收到通知再去竞争锁。Redisson 底层用了 Netty 的发布订阅能力来实现阻塞等待所以它的等待不是无脑轮询。问“锁失效怎么办”先分两种业务未执行完锁过期由看门狗续期解决主从切换导致锁丢失可以聊 Redlock 的取舍。这里我建议说实话说明 Redlock 并非万能最终还是要靠业务幂等来兜底这种回答反而显得更有实战经验。4.3 使用中的一些额外建议锁粒度控制是最容易被忽略的。不要把整个用户 ID 加锁尽量缩小到订单号、库存维度。比如处理用户下单时用订单号做锁 Key 而不是用户 ID因为同一个用户可能并行下不同订单锁在用户维度会平白阻塞其他订单。合理设计锁超时时间。业务预期最长执行时间是 2 秒锁超时就设 5 秒给余量但别给太多。如果业务里确实有耗时不可控的操作优先优化业务而不是无限调大过期时间。规范统一加锁入口。团队里每个人都按自己理解写一遍加锁逻辑后续排查成本会非常高。我现在的做法是提供一个DistributedLockUtil所有业务只传锁名、等待时间、执行业务逻辑锁内部统一做日志、监控上报和异常转换。这样出了问题只需要改一个类。别忘了加监控。锁获取等待时间、锁持有时间、获取失败次数这三个指标非常关键。我们曾经上线过一个新功能锁竞争突然加剧就是因为监控里看到“锁获取等待时间 p99 从 5ms 涨到 200ms”才第一时间发现了一个大 key 扫描导致的 Redis 变慢问题。5. 后续还能怎么扩展如果你用的是 Redisson其实不止 RLock 一种选择。它提供的RSemaphore、RCountDownLatch、RReadWriteLock也都很有用尤其是读多写少的场景用RReadWriteLock可以允许并发读只在写时互斥吞吐量比普通锁高不少。这是我在实际项目里体验很深的点。另外一个方向是把分布式锁和本地锁结合形成双层锁本地加锁挡住同 JVM 内的大部分线程只有极少数的线程才需要争抢 Redis 锁。这种方式能显著降低 Redis 的压力在很多高并发项目里都是一种经典优化手段。最后再分享一个小技巧在测试环境压测分布式锁时不要只看功能正常要故意制造锁过期、线程崩溃、Redis 主从切换这类故障看看业务是否会产生重复数据。我在公司做过一次混沌演练故意在持锁期间把线程 sleep 到超过锁超时时间结果发现有一个报表任务被重复跑了三次从此团队对锁超时的认识深刻了很多。这个教训比看十篇文章都管用。
返回列表