ARTICLE DETAIL

资讯详情

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

Redis分布式锁从原理到实战:Spring Boot实现与避坑指南

Redis分布式锁从原理到实战:Spring Boot实现与避坑指南 在实际开发里只要一碰到“多个服务同时操作一个共享资源”的场景聊着聊着必然会冒出Redis分布式锁。尤其是用Spring Boot做微服务的时候synchronized和Lock这些JDK自带的锁只能在单进程内生效一旦服务做了集群部署它们就彻底失灵了。Redis分布式锁作为目前应用最广泛、上手最快的方案几乎成了Java后端面试和项目落地的标配话题。这篇文章我打算从零开始梳理一遍Redis分布式锁的来龙去脉为什么单机锁在分布式环境下会失效、Redis锁经历了哪些关键演进、用Spring Boot动手实现一套可用的锁需要怎么写、实际项目中经常踩到哪些坑最后再对比一下Redis锁和ZooKeeper、数据库锁这几种主流方案的取舍。无论你是准备面试、正在做技术选型还是已经在业务里写过一把“能用但偶尔出问题”的锁这篇文章都值得花十分钟认真看完。1. 分布式锁到底在解决什么问题很多刚开始接触分布式锁的同学都有一个困惑我在Service方法上加了Transactional再配合syncnized为啥还会出现数据错乱这个问题的根子在于你写代码的时候默认了整个系统只有一个JVM进程但实际生产环境里服务早就被部署成了多个实例用户的请求会被负载均衡分发到不同的机器上。1.1 从一个电商扣库存的场景说起我拿一个最常见的电商秒杀场景来举例。假设你的商品库存只剩1件两个用户同时发起购买请求Nginx把这两个请求分别转发到了服务A和服务B上。服务A里的代码执行if (stock 0) { stock--; }服务B里的代码执行if (stock 0) { stock--; }如果没有任何锁的约束两台机器的JVM里都读到了stock 1各自执行减一操作最后数据库里的库存可能就变成了负数。这就是典型的超卖问题。你可能会说我加个synchronized不就行了吗但synchronized锁的是JVM内部的某个对象监视器服务A加的锁服务B根本感知不到。它们俩仍然是各查各的、各扣各的超卖照旧。1.2 分布式锁的底层逻辑把“锁”搬到多进程共享的地方要让多台机器上的多个服务实例互相协调就必须有一个所有服务都能访问到的第三方组件来承载这把锁。这个组件可以是数据库、ZooKeeper、etcd当然也可以是Redis。Redis分布式锁的核心思想非常朴素利用Redis的单线程模型和SET key value NX命令的原子性谁能在Redis里成功写入这个key谁就获得了锁。其他服务实例再尝试写入时发现key已经存在就会失败从而进入等待或者业务降级逻辑。为了更直观地理解你可以把多台服务器想象成多个员工Redis就是公司前台那个唯一的金属信箱。谁能在信箱上贴上自己的名牌谁就先办事办完事之后把名牌撕掉下一个员工才能贴上去。1.3 一把合格的分布式锁必须具备的5个特性很多刚写分布式锁的新人会想这不就是在Redis里set一个值再删掉吗道理是这么个道理但真要做到生产可用必须同时满足下面这五个条件特性说明如果做不到会发生什么互斥性任意时刻只有一个客户端能持有锁多个客户端同时操作共享资源数据错乱防死锁锁必须设置过期时间或自动清理机制客户端崩溃后锁永远不释放形成死锁原子性加锁和解锁的操作都必须原子完成加锁过程中崩溃或解锁时误删他人锁可重入性同一线程可以多次获取同一把锁递归调用或嵌套业务时出现自己锁自己高可用锁组件本身不能成为单点瓶颈Redis宕机则整个业务不可用这五个特性不是全部都要在一开始就实现但是作为技术方案选型你必须清楚你的方案覆盖了哪几个、放弃了哪几个。接下来我们就按时间线把Redis分布式锁的几个经典版本串起来看这也是面试官最爱问的演进过程。2. Redis分布式锁的四个演进版本我在网上看过很多讲分布式锁的文章上来就贴一大段Java代码其实这样不好——如果不懂为什么这么写过两个月你回来看自己的代码都会觉得陌生。所以我这里把演进过程拆成四个版本帮你理解每一行代码背后的设计动机。2.1 第一版SETNX加EXPIRE各管各的最早的实现方式非常直观用SETNX命令尝试插入锁的key如果返回1说明抢锁成功然后立刻用EXPIRE设置过期时间防止进程崩溃后锁永远不释放。 SETNX order:pay:12345 1 (integer) 1 EXPIRE order:pay:12345 30 (integer) 1看起来没问题但这里埋了一个非常隐蔽的雷SETNX和EXPIRE是两个独立的命令不是原子操作。假设服务A执行完SETNX之后还没来得及执行EXPIRE进程突然宕机了——Redis里那把锁因为没有过期时间就永远躺在那里了。其他所有服务再也抢不到这把锁整个业务直接停摆。这个问题的本质在于两个命令的原子性无法保证。所以在实际工程中我一向不推荐用这种两条命令的方式去实现加锁。2.2 第二版SET命令一把梭原子性搞定Redis从2.6.12版本开始给SET命令增加了NX和EX/PX参数把加锁设置过期时间合并成了一个原子操作。这也就是目前大家常说的标准Redis分布式锁的基础。 SET order:pay:12345 12345 NX PX 30000 OK这条命令的意思是如果order:pay:12345这个key不存在就设置value为12345并且有效期30秒毫秒为单位。如果key已经存在则直接返回空结果。NXNot eXists只有key不存在时才设置成功PX设置过期时间单位是毫秒也可以用EX单位是秒value字段通常存放一个唯一标识比如UUID用于后面释放锁时做身份校验到这里加锁的原子性问题就解决了。但紧接着又冒出一个新问题怎么安全地释放锁2.3 第三版Lua脚本解锁也要保证原子性释放锁最朴素的做法是直接DEL可是这里有个经典事故服务A执行完业务去释放锁结果一DEL把服务B的锁给删了。这个事故的完整时间线是这样的服务A拿到锁设置过期时间10秒服务A执行业务逻辑结果卡了15秒锁到期自动释放服务B立刻抢到锁开始执行自己的业务服务A终于缓过神来执行DEL把锁删了服务C趁机也抢到了锁此时服务B和服务C同时持有锁所以释放锁之前必须先判断这把锁是不是我的。判断要用唯一标识UUID删除要用DEL。但是判断删除如果分两步走又会出现原子性问题——判断完了、还没删之前锁到期了另一个服务把锁抢走了然后你删了别人的锁。要彻底解决就得用Lua脚本把判断和删除封装成一个原子操作。Redis执行Lua脚本是原子的脚本执行期间不会有其他命令插入。-- 释放锁的Lua脚本 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本的逻辑很简单先GET锁key的value如果等于当前线程持有的唯一标识就DEL否则直接返回失败。由于整个脚本在Redis服务端是原子执行的从判断到删除的整个过程不会被其他客户端的命令打断。2.4 第四版Redisson的看门狗机制治好了过期时间选择困难症到了第三版Redis分布式锁在单实例场景下已经基本能用了。但还有一个让人头疼的问题过期时间设多久合适设太短业务还没执行完锁就过期了其他服务提前抢锁并发安全得不到保证设太长如果拿到锁的服务真的崩溃了其他服务要白白等多长时间才能恢复业界比较普遍的做法是Redisson封装好的看门狗Watchdog机制。Redisson的默认锁超时时间是30秒同时它会在后台启动一个定时任务每10秒执行一次续期操作——只要业务线程还在运行就不断把锁的过期时间重置为30秒。这样既保证了业务执行期间锁不会过期又在业务线程彻底崩溃时让锁最多30秒后自动释放不会占着茅坑不拉屎。用Redisson写分布式锁代码比用RedisTemplate原生API简单很多这也是目前很多公司落地时的首选Autowired private RedissonClient redissonClient; public void businessMethod(String orderId) { RLock lock redissonClient.getLock(order:lock: orderId); try { // 尝试加锁最多等待3秒锁的有效期交给看门狗自动续期 if (lock.tryLock(3, TimeUnit.SECONDS)) { // 业务逻辑 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }不过要注意Redisson的看门狗只在你没有手动指定leaseTime时才生效。如果你在tryLock里传了第三参数比如tryLock(3, 30, TimeUnit.SECONDS)那就表示锁的有效期固定为30秒看门狗不会给你续期。这一点很多人踩过坑后面我会专门讲。3. 基于Spring Boot从零实现一把可用的Redis分布式锁Redisson虽然好用但如果你只是想在项目里引入一个轻量级的锁或者想彻底搞懂底层原理亲手用Spring Boot RedisTemplate实现一把锁会让你对这套机制的理解提升一个量级。下面是一份可以直接抄作业的完整实现。3.1 项目依赖与基础环境准备首先确保你的Spring Boot项目里已经引入了Redis相关依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency然后在配置文件中配置Redis连接信息spring: redis: host: 127.0.0.1 port: 6379 password: database: 0 lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0提示Spring Boot 2.x以后默认使用Lettuce作为Redis客户端性能比Jedis更好。这里的连接池配置不是必选项但高并发场景下建议打开防止创建连接的开销拖垮接口性能。3.2 加锁用一行SET命令搞定这里我直接用StringRedisTemplate来操作value存放的是UUID用于身份识别。Service public class RedisLockService { Resource private StringRedisTemplate stringRedisTemplate; private static final String LOCK_SUCCESS_SCRIPT if redis.call(set, KEYS[1], ARGV[1], NX, PX, ARGV[2]) then return 1 else return 0 end; /** * 尝试获取锁 * * param lockKey 锁的key * param requestId 请求标识用于唯一标识当前持有者 * param expireMs 锁的过期时间毫秒 * return 是否获取成功 */ public boolean tryLock(String lockKey, String requestId, long expireMs) { // setIfAbsent 即 SETNX带过期时间 Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMillis(expireMs)); return Boolean.TRUE.equals(success); } }注意这里我用的是setIfAbsent(key, value, Duration)这个方法它底层会翻译成SET key value NX PX expireMs天然保证原子性。不要学网上有些教程分两步写setIfAbsent再expire那是把第二步的有效期设置丢了等于回到了第一版的坑。3.3 解锁Lua脚本保证判断和删除原子执行解锁就不能再用RedisTemplate提供的现成方法了因为get和delete分两步跑都不是原子的。这里必须用Lua脚本。Service public class RedisLockService { private static final DefaultRedisScriptLong UNLOCK_SCRIPT; static { UNLOCK_SCRIPT new DefaultRedisScript(); // 只有持有者本人才能删锁防止误删他人锁 UNLOCK_SCRIPT.setScriptText( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end ); UNLOCK_SCRIPT.setResultType(Long.class); } /** * 释放锁 * * param lockKey 锁的key * param requestId 请求标识必须是加锁时传入的同一个值 * return 是否释放成功 */ public boolean unlock(String lockKey, String requestId) { Long result stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(lockKey), requestId ); return Long.valueOf(1).equals(result); } }还记得之前那个误删他人锁的事故吗这段脚本就是解决方案。get(lockKey)拿到的value如果和当前请求的requestId一致才允许删除不一致就直接返回0表示锁不是你的你没有权限删。3.4 在业务代码里正确使用这套锁光有加锁解锁还不行业务代码里怎么调用直接决定了这套方案能不出问题。下面是一个标准的模板核心在于try-finally结构绝对不能省。RestController public class OrderController { Resource private RedisLockService redisLockService; private static final String LOCK_KEY_PREFIX order:lock:; private static final long LOCK_EXPIRE_MS 30000; PostMapping(/createOrder) public String createOrder(RequestParam Long orderId) { String lockKey LOCK_KEY_PREFIX orderId; String requestId UUID.randomUUID().toString(); boolean locked redisLockService.tryLock(lockKey, requestId, LOCK_EXPIRE_MS); if (!locked) { return 系统繁忙请稍后重试; } try { // 真正的业务逻辑扣库存、生成订单、发消息等 doCreateOrder(orderId); return success; } finally { // 无论业务执行成功还是抛异常都必须释放锁 redisLockService.unlock(lockKey, requestId); } } }几个细节值得再强调一下requestId每次请求都必须是新生成的UUID不能全局固定否则无法区分是不是自己加的锁锁的key尽量加业务前缀比如订单锁就带orderId这样锁粒度更细不同订单之间不会互相阻塞tryLock失败后不要原地疯狂重试可以返回提示或者用简单的自旋策略等100毫秒再试避免把Redis打爆3.5 锁的粒度与key设计经验实际业务中锁的粒度设计是个很考验功力的事情。同样的一个功能key设计得够细并发能力能提升好几倍。我举一个典型的场景积分系统里用户每天签到并发放积分。粗粒度keylock:sign那么所有用户签到都会被同一把锁串行化性能极差细粒度keylock:sign:userId:20250227每个用户每天一把锁用户之间完全并发只有同一个用户同一天重复签到才会被锁拦截所以锁的key设计原则是在保证业务语义正确的前提下key的粒度越细越好。但也不能一味的细比如你要给所有用户的投资总额做更新操作那锁就必须落在总额这个全局资源上锁粒度太细反而保证不了正确性。4. 分布式锁在实际项目里的应用场景与面试考点了解了实现原理再看它能干什么用、面试官喜欢问什么你对这套知识的掌握才算闭环。4.1 四个高频真实业务场景场景一秒杀库存扣减这是分布式锁最经典的使用场景。秒杀系统部署多台服务实例库存放在Redis里扣减前必须先拿到库存对应的分布式锁防止超卖。不过注意如果只是对Redis里的数字做减一操作用DECR命令本身就具备原子性不一定需要锁但当库存扣减之后还要同步到数据库、写订单表这种跨存储操作时锁就派上用场了。场景二定时任务的分布式调度很多系统都会用XXL-Job或Quartz跑定时任务比如每天凌晨同步数据。但如果你部署了两台服务实例又没有做任务分片同一个定时任务就会在两台机器上各执行一次。这时候分布式锁就是最简单粗暴的的解决方案每台实例在任务开始前尝试抢锁只有抢到锁的那台才执行任务。public void scheduledTask() { String requestId UUID.randomUUID().toString(); boolean locked redisLockService.tryLock(job:data-sync, requestId, 60000); if (!locked) { return; // 其他实例已经在执行了当前实例直接跳过 } try { // 执行定时任务的数据同步逻辑 } finally { redisLockService.unlock(job:data-sync, requestId); } }场景三防止接口重复提交用户手一抖前端连点了两次提交按钮后端可能就生成了两笔订单。这时候可以基于userId加锁要求必须在上一次请求的锁释放之后才能发起新请求。场景四缓存过期时的并发重建当缓存失效时如果大量请求同时打到数据库去重建缓存就是所谓的缓存击穿。分布式锁可以让只有一个请求真正去查库重建缓存其他请求先等待然后再从缓存里读取。这个场景Redis官方有一个建议写法——使用SET NX做互斥锁来保护缓存重建过程。4.2 面试官眼里分布式锁的5个高频考点面试环节里分布式锁这个知识点基本是三连问Redis怎么实现分布式锁有什么坑和ZooKeeper有什么区别我把常见考点整理成了一份清单考点一为什么不直接用synchronized单机锁只锁当前JVM进程多实例部署后无法跨进程生效。这是分布式锁存在的根本原因。考点二SETNX和EXPIRE分开设置会有什么问题非原子操作设置过期时间前服务崩溃会导致锁永远不释放形成死锁。正确写法是用SET key value NX PX或者用Redisson。考点三为什么释放锁要用Lua脚本虽然每个命令本身原子但多条命令组合在一起并不是原子。Lua脚本能把多条命令封装成一个整体交给Redis执行保证整个脚本执行期间其他命令不会插进来。考点四Redis分布式锁最大的坑是什么锁在Redis主从架构下可能丢失。Redis的主从复制是异步的如果客户端在master上写入了锁还没来得及同步到slavemaster就宕机了此时slave被选举为新的master但新master上并没有这把锁的记录其他客户端就能再次获取同一把锁互斥性被打破。这个问题的经典答案是RedLock算法也就是在多个Redis节点上同时加锁只有超过半数节点成功才算获取锁。但RedLock本身在业界争议也很大很多专家认为它在极端场景下仍有问题这里不展开面试时能说出来龙去脉就够了。考点五锁过期时间应该怎么设置既不能太短导致业务没完成锁就没了也不能太长导致服务崩溃后长时间不可用。常规做法是评估业务最大执行时间再加一个合理的缓冲值更优雅的做法是用Redisson的看门狗自动续期。5. 常见问题与排查技巧实录纸上得来终觉浅我把自己和身边同事在生产环境里真正遇到过的Redis分布式锁问题按现象 — 原因 — 解决方案整理成了一份问题速查表你可以直接拿来当排查手册用。5.1 问题速查表现象根本原因解决方案服务崩了之后锁一直不释放其他服务拿不到锁加锁时没有设置过期时间或SETNX和EXPIRE分开执行改用SET key value NX PX原子加锁业务A执行完删掉了业务B的锁删除前没有校验持有者身份解锁用Lua脚本判断value是否一致业务还没执行完锁就过期了出现并发问题过期时间设置太短或没有续期机制评估合理超时时间或用看门狗自动续期高并发下Redis锁性能急剧下降可能发生了惊群效应大量线程在自旋重试抢锁设置合理重试策略比如从自旋改成退避重试一个线程获取两次同一把锁导致自己锁死自己锁不支持可重入使用Redisson的getLock天然支持可重入或自己实现计数器主从切换后多个客户端同时拿到锁主从复制异步master故障后锁数据丢失使用RedLock多节点加锁或接受这个风险并做业务兜底5.2 惊群效应连环追问自旋加锁这个坑很多高并发项目都栽过。如果你在业务里用while (!tryLock()) {}这种写法一旦锁的持有者释放锁所有等待的线程会同时去抢——但最终只有一个能成功其他线程全部白忙一场这就是惊群效应。我见过一个项目因为这种写法把Redis的CPU打到接近100%排查了很久才发现是锁自旋引起的。应对办法有两个方向退避重试抢锁失败后Thread.sleep(50 Random.nextInt(100))让线程错开抢锁时间发布订阅用Redis的SUBSCRIBE监听锁释放事件锁释放时由发布者主动通知等待者去抢锁大幅减少无效请求5.3 锁的重入问题怎么处理再来聊可重入性。简单说如果一个方法内嵌套调用另一个需要同一把锁的方法比如事务方法嵌套事务方法同一个线程需要多次获取同一把锁。如果锁不支持重入第二次获取时就死锁了。Redis原生命令天然不区分同一个线程和另一个线程所以用RedisTemplate手写锁一般不支持重入。Redisson的RLock则在内部维护了一个计数器map同一个线程重复加锁时只增加计数不重复创建锁释放一次就减一次计数到零才真正删除key。如果你不想引入Redisson也可以自己用Redis的Hash数据结构实现可重入锁# 加锁时用Hash记录持有者信息和重入次数 HSET lock:order:123 owner requestId 1 HEXPIRE lock:order:123 30 30000 # 重入时 HINCRBY lock:order:123 requestId 1 # 释放时递减次数减到0才删除key不过说实话生产环境里我建议直接拥抱Redisson自己实现可重入锁的边界情况和并发bug实在太多没必要重复造轮子。5.4 监控与告警最后一个小建议Redis分布式锁必须纳入监控。我在团队里一般建议至少监控三个指标缺一不可。锁等待时间如果大量业务在等锁说明锁的粒度设置可能不合理锁获取失败率秒杀场景里这个指标升高是正常现象但非秒杀业务升高就要警惕锁持有时间持续超过阈值说明业务本身有性能问题6. 主流分布式锁方案横向对比Redis不是实现分布式锁的唯一选择。数据库、ZooKeeper、etcd都能做而且各自的适用场景差异很大。做技术选型时不能只听Redis性能好就无脑定方案必须结合你业务本身的写并发量、可用性要求、对一致性的容忍度来综合判断。6.1 数据库分布式锁最笨但最可靠数据库实现分布式锁的做法有基于唯一索引的INSERT加锁和基于版本号的乐观锁两种思路。悲观锁模式建一张锁表key字段加唯一索引获取锁就是往表里插一条记录释放锁就是删除记录乐观锁模式在业务表加一个version字段更新时用UPDATE ... SET stock stock - 1, version version 1 WHERE version ?影响行数为0说明被别人改过了需要重试数据库锁的优势是无需额外引入组件事务内天然支持回滚劣势也非常明显——性能天花板低高并发下数据库连接池会被锁请求占满反而拖垮整个业务。所以数据库锁更适合低并发、强一致、对性能不敏感的管理后台类系统。6.2 ZooKeeper分布式锁强一致的另一种思路ZooKeeper实现分布式锁的原理是利用了它的临时顺序节点。客户端在指定目录下创建临时顺序节点如果自己创建的节点序号最小就认为拿到了锁否则监听比自己序号小的节点等它删除后再尝试获取。ZooKeeper锁最大的优势是强一致性ZooKeeper集群通过ZAB协议保证数据在多个节点之间同步完成之后才对外提供服务所以不存在Redis主从切换导致锁丢失的问题。同时临时节点天然支持客户端崩溃自动清理不需要设置过期时间。劣势是性能和Session超时问题。ZooKeeper在节点频繁创建删除时会触发大量ZAB广播整体并发能力一般。如果客户端和ZooKeeper之间的Session网络抖动还可能出现临时节点被提前删除、锁被释放但业务还在执行的情况。6.3 三者选型对比维度Redis分布式锁ZooKeeper分布式锁数据库锁性能最高单机可达十万级QPS中等万级QPS最低受限于数据库连接一致性AP模型主从切换可能丢失锁CP模型强一致强一致可用性高Redis集群支持故障转移高ZooKeeper集群支持故障转移一般数据库主从切换较复杂实现复杂度低原生命令或Redisson都很简单中需维护ZooKeeper客户端低建表即可功能扩展可重入、看门狗、红锁可重入、可阻塞依赖事务扩展空间有限适用场景高并发秒杀、缓存重建、定时任务对一致性要求极高、低并发的分布式协调现有系统已有数据库不想引入新组件说实话我在绝大多数互联网业务场景里会优先选择Redis分布式锁因为它性能最好、引入成本最低配合Redisson还能解决自动续期和可重入问题。只有在金融支付、账户余额操作这类对一致性要求极高、出现并发问题会直接造成资金损失的业务里我才会考虑ZooKeeper或者数据库乐观锁。7. 写在最后的几个大实话做了这么多年后端Redis分布式锁是我见过网上资料最多、实际用错最多的技术点之一。很多团队上线了自研的锁平时一切正常一到高并发或者Redis抖动就出事故。这里我分享几个踩坑之后的真实体会。Redisson虽然强大但不要把它当黑盒。我建议你先用RedisTemplate实现一遍原生版本的锁亲手跑一遍高并发复现一下误删锁、锁过期这些问题再切换到Redisson你会对它内部的看门狗、加锁Lua脚本有截然不同的理解。这个学习路径比直接抄Redisson代码要扎实得多。如果你的业务真的要求锁绝对不能丢那就要接受CAP的取舍。Redis锁本质上在一致性上做出了一定让步换来的是极致的性能。你能做的不是指望Redis把一致性做到完美而是要想清楚万一锁真的失效了你的业务在数据的最终一致性上有没有兜底方案比如幂等表、乐观锁、对账补偿。分布式锁从来不是唯一的安全防线它只是第一道门。最后再分享一个小技巧凡是涉及分布式锁的接口一定要在压测环境里模拟一次Redis节点宕机的演练。提前发现问题、验证业务侧对这个故障的容忍度比在双11大促当天发现锁失效要安心得多。这套经验我用了很多年从来没让我失望过。
返回列表