ARTICLE DETAIL

资讯详情

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

Redis分布式锁原理与实战:解决集群并发下的数据一致性问题

Redis分布式锁原理与实战:解决集群并发下的数据一致性问题 1. 集群环境下的并发困局为什么单机锁不够用了我最早接触分布式锁是在一个很尴尬的场景里摔过跟头。当时服务已经上了集群两个节点同时跑定时任务结果数据库里出现了一堆重复的优惠券发放记录。排查的时候第一反应是代码写错了看了半天发现逻辑完全没问题——问题出在传统的Lock也好、synchronized也好它们都只对单个JVM进程内的线程生效。节点A的锁节点B根本感知不到两个进程各自为政并发请求自然就穿透了。说白了单机锁解决的是线程竞争分布式锁解决的是进程竞争。在集群架构下多个服务实例处理同一份业务数据时必须有一个所有实例都能访问的公共协调者谁抢到了这个协调者发下来的令牌谁才有资格执行业务逻辑其他实例只能等待或者放弃。Redis做这个协调者是目前最主流的方案之一。原因也很直白Redis本身是集群部署的天然具备高可用能力SET命令配合过期时间可以原子地写入一个唯一标识性能极高单实例的读写能力能到十万级QPS而且几乎所有后端团队都已经在运维Redis不需要额外引入一套ZooKeeper或者etcd的部署成本。不过这里得先泼一盆冷水Redis分布式锁不是简单写个SETNX就完事的它的正确性依赖很多边界条件的处理。网上流传的很多Redis分布式锁教程只讲了加锁和解锁漏掉了过期时间设置、锁误删、可重入、集群主从切换等关键细节生产环境跑起来大概率要出事。这篇文章我结合自己实际改造过的项目把从原理到落地的完整链路讲清楚特别是那些坑看完能帮你省不少麻烦。2. 锁的本质与Redis实现原理从单机锁到分布式锁的演进思路2.1 一把锁到底在锁什么要理解分布式锁先得搞清楚锁的本质。锁的核心作用不是拦住所有代码执行而是保护一段临界区——也就是多个线程同时访问共享资源时可能产生数据不一致的那段代码。比如库存扣减、订单状态流转、优惠券核销这些操作如果没锁保护两个请求同时读到库存为1各自扣减后写回库存就变成0而不是-1业务数据就错了。在单机环境下这个协调工作由JVM完成线程A拿到锁线程B就在synchronized的monitor上阻塞等待JVM保证同一个时刻只有一个线程能进入临界区。这是通过操作系统的线程调度和内存屏障实现的粒度精确到进程内部。但集群环境下两个服务实例跑在完全不同的JVM里它们的内存空间相互隔离谁也不知道对方是不是正在执行同一段逻辑。这时候需要一把大家都能看见的锁——锁的状态必须存放在一个所有实例都能访问的公共存储中。谁在这份公共存储里写入了我已加锁的状态谁就获得执行权执行完再清除这个状态。这就是分布式锁的基本模型。Redis在这个模型里充当的就是那个公共存储。SET key value NX PX expiry命令能实现原子地检查并写入NX参数保证只有key不存在时才能写入成功这对应互斥获取PX参数给key设置过期时间对应锁的自动释放防止持有锁的服务挂了导致死锁value存一个唯一标识对应锁的持有者身份解锁时用来校验防止误删。这一条命令就把分布式锁最核心的骨架搭出来了。2.2 SETNX的演变从两步操作到原子指令很多老教程里还在用SETNX加锁、EXPIRE设置过期时间的组合写法SETNX lock_key unique_value EXPIRE lock_key 30这个写法在面试里基本是送命题——如果SETNX执行成功后、EXPIRE执行前服务实例突然宕机锁的过期时间就永远设置不上这把锁就变成了永久锁所有后续请求全部卡死。这就是典型的非原子操作带来的问题。Redis 2.6.12版本之后提供了一个解决这个问题的方案就是前面提到的单条原子命令SET lock_key unique_value NX PX 30000一条命令完成不存在才写入和设置过期时间两个操作Redis服务端保证它们的原子性。这也是目前实现Redis分布式锁的基础指令理解这一点后面所有的实现方案都是在这条命令之上做扩展。我自己的习惯是给锁的value生成一个UUID 线程ID 时间戳的组合字符串或者直接用UUID.randomUUID().toString()保证全局唯一即可。这个唯一值是为了解锁时验明正身后面详说。2.3 为什么需要看门狗过期时间引发的两难给锁设置过期时间本质上是个两难问题过期时间设短了业务还没执行完锁就被自动释放其他请求趁虚而入临界区被并发进入设长了一旦持有锁的服务宕机其他请求就要白白等待很久才能接管。而且业务执行时间本身是个变量有时快有时慢一个固定值很难匹配所有场景。业界通用的解法是续期机制也就是Redisson实现的看门狗Watch Dog。简单说当你加锁成功后后台会启动一个定时任务每隔锁超时时间的三分之一Redisson默认锁超时30秒就是每10秒检查一次如果锁还在持有中就自动把过期时间重置为30秒。业务执行多久锁就续期多久既不会提前失效也能在持有者宕机时通过过期机制兜底。看门狗的作用非常关键它解决的是锁的生命周期必须覆盖业务执行周期这个根本问题。后面讲Redisson实现的时候我会给出更详细的参数说明。3. 三种主流的Redis分布式锁实现方案对比3.1 方案一纯Redis命令手写锁适合学习与轻量场景最快的落地方式就是用Spring Data Redis或Lettuce的API直接封装一个最简分布式锁// 加锁 String lockKey lock:order: orderId; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS); // 业务逻辑 if (Boolean.TRUE.equals(locked)) { try { // 执行临界区业务 } finally { // 解锁使用Lua脚本保证原子性 String script if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScriptLong(script, Long.class), Arrays.asList(lockKey), requestId); } }这个方案的优点是零依赖、代码透明你能控制每一个细节。缺点是以下能力需要自己逐一实现可重入同一线程重复获取锁、看门狗续期、公平等待抢不到锁的请求怎么排队退避。如果只是学习原理或者业务场景非常简单可以先用这个方案跑通生产环境强依赖的话我建议往下看Redisson。解锁环节为什么一定要用Lua脚本这里多说一句。解锁其实是两步先GET对比value是否自己的标识再DEL删除key。如果分开执行中间可能发生锁已经到了过期时间被自动释放另一个请求重新加锁成功然后你的DEL删的是别人的锁——这就是经典的误删锁问题。用Lua脚本把两步操作交到Redis服务端原子执行才能保证只有持有者本人能解锁。3.2 方案二Redisson的RLock生产环境首选Redisson是Java生态里最成熟的Redis分布式锁客户端它把上面说的看门狗续期、可重入、公平锁、红锁等都封装好了。核心用法简单到离谱RLock lock redissonClient.getLock(lock:order: orderId); boolean locked false; try { locked lock.tryLock(2, 30, TimeUnit.SECONDS); if (locked) { // 执行业务逻辑 } } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } }tryLock的三个参数分别是等待获取锁的最大时间拿不到锁就放弃避免无限阻塞、锁的自动过期时间、时间单位。这里有个容易踩的坑如果你不传过期时间Redisson会启用默认30秒的看门狗方案锁会自动续期直到unlock如果你手动传了过期时间看门狗就不生效锁到时间自动释放。所以传参前要想清楚业务的最大执行时长别随手填个数字。Redisson是用Lua脚本实现的原子加锁和解锁内部数据结构用的是Redis的Hash而不是简单的String。这也是它支持可重入的关键同一个线程重复加锁时Hash里对应字段的计数加一解锁时减一计数归零才真正删除key和ReentrantLock的计数思路完全一致。3.3 方案三RedLock——高安全场景才需要的红锁Redisson的RLock虽然好用但它有一个隐患主从切换下可能丢失锁。场景是这样的锁写入master节点master还没来得及同步到slavemaster宕机slave被提升为新的master但新master上并没有这把锁的数据其他请求就能顺利加锁成功——锁的互斥性在故障瞬间被破坏了。Redis官方给出的应对方案是RedLock思路是少数服从多数同时向多个独立的Redis节点官方要求至少5个且相互之间没有主从关系执行加锁操作只有成功加锁超过半数节点如5个中至少3个才认为加锁成功释放锁时向所有节点发送解锁命令。RedLock的代价是明显的需要额外部署多套Redis实例运维复杂度翻倍加锁时间变成多次网络通信的累加性能下降而且在极端情况下依然存在理论上的安全窗口。所以我的判断是如果你的业务允许偶尔的重复执行比如幂等性设计做得好用法案二足够如果业务绝对不能容忍并发穿透比如分布式事务中的资源冻结再考虑RedLock。绝大部分互联网业务场景其实不至于走到RedLock这一步——系统架构先保证Redis集群本身的高可用比纠结RedLock的极端场景更实际。4. 实战案例集群环境下订单并发扣减库存的完整实现4.1 场景设定与代码骨架为了把原理落到实地我以一个最常见的并发扣减库存场景为例。假设我们有3个后端服务实例对应3个JVM进程它们共享同一个Redis集群和一个MySQL库存表。用户疯狂点击下单时请求被负载均衡分发到不同实例上同一个商品ID的扣减请求可能同时落在不同实例上。目标同一时刻只有一个请求线程能真正执行库存扣减生成订单这段逻辑。核心代码结构如下Service public class StockService { Autowired private RedissonClient redissonClient; Autowired private StockMapper stockMapper; public OrderResult deductStock(Long productId, Integer quantity) { // 以productId作为锁的粒度不同商品的请求互不阻塞 String lockKey lock:stock: productId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { // 最多等待3秒获取锁获取成功后锁的自动过期时间由看门狗持续续期 locked lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { // 没拿到锁返回繁忙标记提示用户稍后重试 return OrderResult.busy(); } // 1. 查询当前库存 Stock stock stockMapper.selectByProductId(productId); // 2. 校验库存是否充足业务规则 if (stock.getAvailableStock() quantity) { return OrderResult.noStock(); } // 3. 扣减库存使用乐观锁版本号防止脏写 int updated stockMapper.deductStock(productId, quantity, stock.getVersion()); if (updated 0) { return OrderResult.retry(); } // 4. 生成订单从略 return OrderResult.success(); } finally { // 释放锁前判断当前线程是否还持有锁防止误释放 if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }注意几个细节。第一锁的粒度按productId细分而不是用一把全局锁这样不同商品的并发请求可以并行处理避免互相拖累大幅提升系统吞吐量这也是面试里常考的一个优化点——锁的粒度越细并发能力越强。第二tryLock设置了最大等待时间3秒防止某个请求一直等锁导致线程堆积。第三释放锁前用isHeldByCurrentThread()做了复核这个检查能避免一种隐蔽的问题因为看门狗续期的存在理论上锁不会在业务执行中被释放但万一业务耗时极长锁到期后又被别人获取当前线程unlock就会删掉别人的锁——这个判断可以有效规避。4.2 幂等性兜底锁不是万能的分布式锁并不能保证整个业务流程完美无瑕它只能保证互斥进入临界区。一个很多新手容易忽略的问题是业务执行过程中如果出现异常导致部分数据写入了但事务没提交成功重试的时候怎么办比如上面的扣减库存第一步执行了查询库存、扣减库存但第二步生成订单抛了异常数据库事务回滚库存恢复。如果客户端收到异常后立刻重试一切正常。但如果重试是另一个实例发起而业务处理中调用了外部接口比如调用支付网关——这个外部调用可能已经成功但响应丢失重试就会造成重复扣款。所以实践中的原则是锁保证并发互斥幂等设计保证业务正确两者缺一不可。具体到库存扣减场景我会在订单表上加业务唯一索引比如orderNo重复插入时报错直接返回重复提交对外部接口调用则要求接口本身支持幂等传入预生成的唯一请求ID。4.3 锁超时与业务超长的实战权衡我在生产环境遇到过一次非常典型的锁超时事故。当时业务里有一段逻辑要批量刷新大量缓存耗时通常只要2秒但因为某次上游依赖服务响应极慢这段逻辑整整跑了45秒。而我配置的锁过期时间是30秒锁在第30秒自动释放了第二个请求立刻进来拿到新锁两个线程同时进入了临界区。这类问题的排查思路是先看监控确认临界区代码的P99耗时再把锁的过期时间设置为P99耗时的3到5倍给足冗余最好启用Redisson的看门狗让锁自动续期避免写死一个固定值。Redisson默认看门狗每10秒续期一次锁超时30秒的三分之一这意味着业务执行时间超过30秒也不用担心锁提前失效。不过也要注意看门狗有个前提持有锁的客户端进程还活着。如果进程因为Full GC长时间STWStop The WorldJVM暂停所有线程的垃圾回收过程可能持续数秒看门狗线程也被暂停锁到期后照样释放。极端情况下GC导致的锁提前释放依然无法完全避免。真要彻底解决得引入数据库事务或分布式事务中间件做二次校验这也是为什么我说锁不是银弹。5. 从单体到集群再到高可用锁方案的演进路线5.1 单机Redis vs 主从架构 vs 哨兵模式分布式锁的可靠性和底层Redis架构强相关。我用一个表格把常见部署形态对锁的影响说清楚部署形态锁的可用性锁的安全性适用场景单机RedisRedis挂则锁全部不可用安全开发测试环境、允许短暂不可用的低要求业务主从哨兵主节点故障自动切换可用性高主从切换瞬间可能丢锁同步延时导致常规生产业务、可容忍偶发抖动的场景Redis Cluster数据分片单分片为主从结构可用性高同样存在分片主从切换的丢锁窗口大规模Redis使用场景业务上需要做幂等兜底多节点RedLock可用性取决于节点多数派存活理论安全性最高但仍非绝对极严苛场景运维成本高一般不建议这里要特别澄清一个误区很多人以为用了Redis Cluster集群就可以高枕无忧了但实际上Cluster的分片主从切换和主从架构一样存在同步窗口问题。锁的key属于某一个分片分片的主节点宕机后如果锁数据尚未同步到从节点新的主节点上就没有这个key互斥性在故障切换的瞬间被打破。这是CAP理论中可用性和一致性冲突的体现——Redis优先保证了可用性牺牲了强一致性。5.2 based on your业务形态怎么选最合适技术选型没有绝对的最好只有最合适。结合我自己的项目经验给一个比较务实的决策路径如果业务量级不大QPS几百Redis主从哨兵已经够用锁的丢失概率极低配合业务幂等设计完全可以接受。如果是订单、支付、库存这类核心交易链路我建议用Redisson的RLock 业务唯一约束 数据库乐观锁三层防线叠加而不是只依赖分布式锁。如果公司已经有多套独立部署的Redis实例且团队有充足的运维能力可以考虑RedLock但一定要评估好性能损耗和运维成本。如果系统允许偶尔的数据重放比如某些统计类场景那直接用SETNX手写的简单锁都行别为了高级而过度设计。我在实际项目中见过一个反面教材团队为了保险给一个简单的发券接口上了RedLock结果因为网络抖动导致加锁耗时飙升接口P99从50ms涨到了800ms用户反馈体验明显变差。后来降级成普通的RLock配合业务上做了幂等表问题反而全部消失。所以我的原则一直是先保证业务正确性兜底再追求锁的绝对可靠分布式锁是用来减少并发冲突概率的不是用来替代业务幂等的。6. 避坑总结分布式锁在项目里最容易踩的五个问题6.1 未设置过期时间的死锁这属于最基础但也是最高发的坑。早期版本的SETNX加锁后忘记EXPIRE或者两步操作之间有间隙导致堆积请求全部阻塞。解决思路已经说过用一条SET key value NX PX原子命令或者直接用Redisson不要自己拼两步操作。6.2 锁误删锁因过期自动释放后其他线程成功加锁原线程这时去DEL——删掉的其实是别人的锁。这个问题必须用value校验来防御加锁时写入唯一标识解锁前先GET对比一致才DEL而且这个对比删除要用Lua脚本原子执行。Redisson内部就是这么做的因此生产环境强烈建议直接用它而不是手写。6.3 锁的重入问题同一个线程在持锁状态下再次调用加锁方法如果锁不支持重入就会把自己阻塞死——典型的场景是加锁方法里调用了另一个也加了同一把锁的本地方法。手写SETNX方案完全不处理重入而Redisson的RLock基于Hash结构天然支持同一线程的重入计数。如果你的业务里锁的方法有嵌套调用务必选用支持重入的实现。6.4 锁粒度太粗导致性能雪崩有些同学直接用lock:all做全局锁只要业务稍微复杂所有加锁操作全部串行系统吞吐量直接跌落一个量级。正确的做法是把锁的粒度细化库存锁按productId、订单锁按userId或orderId、优惠券锁按couponId。锁粒度越细致系统并发能力越强。当然粒度太细也可能导致锁数量膨胀需要结合业务控制——比如热点商品秒杀场景按商品维度加锁就已经是单机热点这时候需要考虑队列削峰或Redis原子操作来替代。6.5 锁内做了耗时操作锁是用来保护临界区的但临界区越大锁持有时间越长其他请求等待越久系统吞吐量越低。常见的反面案例是在锁内部调用外部HTTP接口、做慢SQL查询、批量处理大量数据。我的建议是把锁内代码压缩到最小——只保护真正需要互斥的那几步操作外部调用尽量放到锁外。如果实在无法避免锁内远程调用一定要有超时控制和降级方案比如设置tryLock的等待时间上限。7. 拓展思考分布式锁以外的并发控制手段分布式锁只是并发控制工具箱里的一件工具有些场景其实有更轻量或更合适的替代方案。Redis原子操作像库存扣减这种读-改-写模式的场景可以直接用DECR或Lua脚本在Redis内部完成扣减和校验连分布式锁都不需要加。比如DECR stock:1001一条命令就完成了扣减返回负数说明超卖。这是Redis单线程模型带来的天然原子性优势。数据库乐观锁给库存表加一个version字段更新时校验版本号一致才执行UPDATECASCompare And Swap的思路。这个方案适合并发冲突不频繁的场景扣减失败就重试。上面库存案例里我特意用了乐观锁作为兜底。消息队列串行化把并发请求转化为有序消息通过MQ的队列特性让同一商品的请求串行消费从根源上消灭并发。秒杀场景最常见的架构就是请求进MQ 消费者单线程处理效果比分布式锁更彻底。数据库分布式事务涉及多库多表的一致性操作可以引入Seata之类的分布式事务框架用全局锁和事务协调保证一致性。不过引入成本高、性能开销大非必要不建议。这些手段不是互相排斥的而是可以根据场景组合。比如秒杀场景Redis原子预扣 MQ排队 数据库乐观锁兜底订单支付场景分布式锁防并发重复支付 幂等表防重复通知。每种手段都有它的边界组合使用才能构建出健壮的系统。8. 最后的实操建议与个人经验如果让我给第一次做分布式锁的团队一个建议我会说先别着急写代码花半天时间想清楚你的锁是防什么的、失败之后会怎样再决定用哪种方案。我们团队最初手写锁踩了不少坑后来统一切换到Redisson代码量大幅减少线上问题几乎绝迹。具体实操时还有几个小细节非常值得注意。一是锁的命名规范建议统一为业务前缀:锁对象:业务标识比如lock:stock:1001方便排查问题及监控系统过滤。二是加锁后务必打日志记录锁的key、持有线程、等待时长、释放时间线上出问题时这些日志能帮你快速还原现场的锁竞争情况。三是监控告警别漏掉锁等待时长如果发现某把锁的平均等待时间持续上涨说明临界区负载过高要及时优化。关于Redisson的版本我会优先选择最新稳定版因为分布式锁相关的Bug修复大多集中在新版本里。配置上默认的看门狗参数锁默认30秒、每10秒续期适合多数场景但如果业务里有长耗时操作可以灵活调整lockWatchdogTimeout参数——注意这个参数是全局的调整前要评估所有使用该RedissonClient的业务场景。最后再提醒一个容易忽视的运维点锁相关的key一定要设置合理的过期时间策略和监控清理机制。即使有看门狗极端情况下比如持有锁的进程被强制杀掉来不及释放锁Redis里仍可能残留少量锁key。虽然它们会因过期自动清理但如果业务上有加锁后长时间不释放的异常场景建议写个定时任务扫描锁key的存活时长超过阈值就告警。锁key的生命周期管理这事工程师写代码时经常忽略但生产事故往往就藏在这些细节里。
返回列表