分布式锁非银弹:Redis锁失效场景与乐观锁兜底实战
1. 从一次扣库存事故说起分布式锁为什么需要兜底去年夏天我接了一个订单系统的值班电话用户反馈某爆款商品库存显示还剩 50 件但付款成功后一直提示库存不足请稍后重试。查监控发现库存字段被扣到了负数而且同一笔订单在数据库里生成了两条记录。当时负责这个接口的同事一脸笃定地说我们加了分布式锁啊Redis 锁是有的怎么会超卖后来我们把时间线拉出来才看清了完整经过。商品详情页和下单接口都接入了同一个 Redis 锁锁的 Key 是product:10086:lock。正常情况下第一个请求拿到锁扣库存释放锁第二个请求等锁释放后再执行。但那天凌晨Redis 主节点因为内存碎片率过高触发了故障转移主从切换的那个瞬间锁还没同步到从节点新主节点上根本没有这把锁的存在。结果两个并发请求同时发现锁不存在同时进入了扣库存逻辑各自减了一次库存其中一条还把订单状态写成了重复状态。这不是什么冷门场景。主从切换、进程 GC 停顿、网络分区、锁过期提前释放每一种情况都能让我加了锁变成我的锁其实不存在。分布式锁在理想情况下确实能保证互斥但它依赖的 Redis 本身有一个绕不开的事实Redis 不是强一致系统尤其在副本复制和故障转移过程中锁的可见性会出现短暂的空窗。所以后来我在团队里立了一个规矩分布式锁只能作为第一道闸门业务关键路径上必须再设计一层乐观锁兜底或者用数据库的唯一约束和条件更新做最终防线。锁负责把并发从多个人同时进屋子降到最多两个人同时进屋子乐观锁负责在屋子里真正动手改数据时保证只有一个人能改成功。1.1 事故复盘锁明明加了库存还是超卖了我们复盘的时候发现问题并不出在锁没加而在于锁的语义在故障转移时被打破了。当 Redis 以单机模式部署时锁的语义可以被描述成只要 Key 存在客户端 A 就拥有对临界区的独占权。客户端 B 会不断尝试获取直到 A 释放。这个过程在正常运行时没有任何问题。但一旦引入主从复制事情就变了。主节点负责写从节点负责读和备份。锁的写入先落在主节点再通过异步复制同步到从节点。异步复制的意思是主节点写完就返回成功根本不等待从节点确认。如果此时主节点宕机哨兵或集群选举出一个从节点升为新主节点而旧主节点上那个刚写入的锁 Key 还没来得及复制到从节点那新主节点上就没有这把锁。客户端 B 这时候来获取锁发现能获取成功于是两个客户端同时进入了临界区。这里有个容易忽略的细节很多人以为主从切换是秒级完成的觉得故障窗口很小可以接受。但业务用的 Redis 锁过期时间通常设置成 10 秒、30 秒甚至更长。主从切换期间只要旧主节点上的锁有哪怕几百毫秒没同步完新主节点上出现的无锁窗口就可能持续到切换完成后的一个完整网络请求周期。对于并发高、执行快的扣库存接口来说这个窗口足以让两个请求同时穿过锁。1.2 分布式锁的本质不是事务是低概率下的协调原语我后来跟同事解释这件事时用了这样一个类比。分布式锁更像是小区门口的登记保安他的职责是尽量让业主挨个进门避免大家挤在门口。但他不是房屋产权系统房屋真正划归到谁名下最终要看房产局登记的那本账。对应到系统设计里Redis 锁是尽量协调数据库事务和条件更新才是最终记账。锁的作用是降低并发冲突的概率把原本可能同时在数据库里修改同一条记录的多线程请求压成绝大多数时候只有一个进来。它解决的是资源竞争中的排队问题。但一旦锁失效、锁提前过期、锁被中断同一条数据依然可能被多个请求修改这个时候必须有一个机制在数据落库环节强制把关。这就是乐观锁兜底的价值。乐观锁不依赖任何外部协调组件而是在数据本身携带版本信息。每次更新都带着一个前提条件只有在当前版本号等于我当初读取到的版本号时更新才能成功。这个条件是在数据库事务里执行的由数据库的原子性和行级锁保证比 Redis 锁的保证力度强得多。1.3 分布式锁不是银弹业务代码为何需要第二道防线很多团队的误区是把加了 Redis 锁当成不用再考虑并发安全的充分条件。实际上锁只是第一道防线它的失败模式太多了锁过期时间设置过短业务还没执行完锁就自动释放了。客户端持有锁时发生长时间 GC 停顿另一个线程趁虚而入。主从切换导致锁丢失如上文所述。网络抖动客户端明明持锁但服务端已经判定连接超时并释放了锁。任何一道失败模式都会让互斥变成并发。而乐观锁兜底的本质是我不再信任外部的互斥协调器一定到位我直接在数据模型里加入防止旧值覆盖新值的校验条件让最终防线离数据最近、离网络最远。2. Redis 三种部署形态下一致性风险的差异有多大很多人一听到Redis 有分布式锁即可就直接跳过部署形态的思考。实际上单机、主从复制、集群三种形态下锁的一致性问题完全不同风险等级和应对策略也完全不同。2.1 单机模式伤害范围最小但过期时间就是你的命门单机模式下所有客户端都访问同一个 Redis 实例。锁的互斥性由 Redis 单线程模型天然保证同一时刻只有一个客户端能成功 SETNX。这个模式下最大的风险点不是锁丢而是锁过期。举个例子。你的锁过期时间设了 10 秒正常业务耗时只要 200 毫秒看起来绰绰有余。但如果某个瞬间你的应用线程发生了 Full GC停顿了 15 秒那锁早就释放了另一个线程拿着锁进来前一个线程醒过来又继续执行俩线程同时改数据。这种情况在单机 Redis 下也完全可能发生。很多分布式锁客户端比如 Redisson提供了看门狗机制锁拿到后自动续期避免因业务执行时间超过锁过期时间而提前释放。但看门狗也有个前提线程必须活着并且能持续和 Redis 通信。一旦客户端宕机、网络断连看门狗无法续期锁照样释放。单机模式还有个容易踩的坑锁的 Key 和业务超时时间的搭配。有人习惯把锁过期时间设得特别短比如 500 毫秒想着反正业务快结果高并发下大量请求因为排队等原因拿不到锁频繁进入重试反而把 Redis 打挂。我认为单机模式下锁过期时间至少要设置为业务正常耗时的 3 到 5 倍,同时配合续期机制而不是拍脑袋设一个很小的值。2.2 主从复制主节点故障瞬间锁丢失的概率被放大主从复制模式下问题从锁过期升级成了锁可能根本不存在于新主节点上。我们来推演一个具体的时间线客户端 A 向主节点写入锁 Key主节点返回 OK。主节点尚未把 Key 复制到从节点。主节点宕机哨兵系统完成故障转移从节点晋升为主节点。客户端 B 向新主节点请求锁新主节点上不存在该 Key获取成功。客户端 A 和客户端 B 同时进入临界区。这个问题的根源在于 Redis 主从复制是异步的。即使你使用WAIT命令强制让写操作等待从节点确认也只能做到尽可能同步无法完全消除窗口因为从节点可能滞后、网络可能分区、哨兵选举也需要时间。有些人会尝试用 RedLock 算法来解决这个问题。RedLock 要求锁在超过半数的 Redis 节点上写入成功才算获取成功。它的设计初衷就是靠多数派来抵消单点故障导致的数据丢失。但 RedLock 并不是万无一失的当发生网络分区持有锁的客户端被隔离无法和大多数节点通信其他客户端可能拿到锁当进程发生长时间 GC 停顿锁可能过期其他客户端也能拿到锁。RedLock 降低了风险概率但没有从根上改变Redis 不是强一致存储这一事实。我的看法是主从模式下把分布式锁当成尽力而为的协调器是理性的选择。你可以在锁层面多做几层保障Redisson 的看门狗、多副本同步等但数据库侧的乐观锁兜底不能省。2.3 集群模式Slot 迁移、网络分区与多数派缺失Redis Cluster 比主从复制多了一层 sharding把数据分散到多个主节点上。分布式锁的 Key 会被路由到某个 Slot 对应的主节点。表面上锁还是依托单节点互斥但集群模式引入了一些新的坑。第一个坑是 Slot 迁移。当你做水平扩容或缩容Redis Cluster 会把 Slot 从一个主节点迁移到另一个主节点。锁的 Key 正好在迁移区间内时可能出现锁写入失败或读取不到的情况。虽然这种场景不频繁但在大促扩容时你极有可能碰到。第二个坑是网络分区脑裂。如果集群中的一个主节点与其它节点网络断开但它所在的客户端连接仍然可以通信这时候这个主节点还在接受写入请求。锁写入了但因为主节点离线集群可能不承认它的写入或者旧主节点重新连回后发现自己的数据版本已经落后被要求丢弃数据。后果就是一个客户端以为自己拿到了锁但锁实际并未被集群承认。第三个坑是锁 Key 的节点分布对业务请求的影响。比如下单接口和库存接口可能在同一个事务里操作多个商品如果每个商品的锁都落在一个 Redis 节点N 个商品就要跨 N 个节点加锁。要么用 Redis Cluster 的 hash tag 机制把相关 Key 集中到同一个 Slot要么就接受跨节点访问带来的额外延迟。2.4 对比单机、主从复制与集群的故障窗口和防护手段我整理了这三者在分布式锁场景下的关键差异方便你在做技术选型时快速对照部署形态主要的锁失效方式故障窗口大小推荐的额外防护单机锁过期、业务超时、线程 GC 停顿秒级到分钟级看门狗续期、数据库条件更新主从复制异步复制丢失、主从切换、哨兵选举毫秒级到秒级RedLock/多副本锁 乐观锁兜底集群模式Slot 迁移、脑裂、节点宕机后数据丢弃秒级甚至更长多数派确认、业务层幂等 乐观锁兜底不管哪种形态结论都一样Redis 锁的失败窗口客观存在只是概率高低不同。你无法通过精心配置 Redis来根除它只能在业务代码上补一道最后的防线也就是接下来要讲的乐观锁兜底。3. 乐观锁兜底的原理业务层如何与 Redis 锁配合乐观锁这个词听起来很高深但它背后的思想非常简单修改数据的时候带着一个我读到的数据长什么样的版本标记。如果有人在我读取之后、修改之前把数据改了那我的修改条件就不成立了修改失败。3.1 乐观锁的核心版本号与条件更新最常见的实现方式是给业务表加一个version字段每次更新时同时更新version version 1。伪代码如下UPDATE product_stock SET stock stock - 1, version version 1 WHERE product_id #{productId} AND version #{expectedVersion};如果影响行数为 1说明这次更新成功且期间没有其他人改过。如果影响行数为 0说明 version 已经变了数据被其他人抢先修改过本次更新失败。这个机制的妙处在于它完全绕开了分布式锁。即使两个客户端同时执行这条 SQL数据库的底层也会做好行锁或行版本控制保证同一时刻只有一条更新真的生效。数据库在这里是天然的最后防线比 Redis 锁可靠得多。需要注意一点不是所有 UPDATE 都必须加版本号。对于强竞争、高价值的数据库存、余额、优惠券数量必须加。对于弱竞争或顺序要求极低的数据如日志、报表统计加了反而增加无谓的冲突重试。我在设计时一般遵循只要这条数据修改后会影响金额、数量、状态流转就必须有版本字段。3.2 数据库侧的条件更新怎么和 Redis 锁形成互补有人会问既然数据库条件更新已经能保证并发安全那分布式锁是不是完全是多余的这个问题我很早就想过。答案是不能说完全多余锁仍然有价值。第一锁能减少冲突重试次数。如果没有 Redis 锁高并发下大量请求会同时打到数据库数据库的行锁竞争会非常激烈乐观锁失败率会很高一堆请求进入重试循环数据库压力剧增。有了一层 Redis 锁大部分请求在前面就排队了实际打到数据库的并发量大大降低。乐观锁只是用来兜底避免偶尔穿过 Redis 锁的请求造成严重错误。第二锁能保护非数据库资源。库存扣减可能不只是更新数据库还要调用外部服务、写缓存、发消息。如果多台机器同时执行这些操作单纯靠数据库乐观锁是拦不住外部调用的重复执行的。锁把整个操作体圈起来尽量保证在同一时间只有一个实例在执行完整的业务链路。所以正确的姿势是Redis 锁负责减少并发进入临界区的概率乐观锁负责保证真正修改数据时的原子性和正确性。两者是互补关系不是替代关系。3.3 重试策略锁能等版本号不能乱跳乐观锁失败后的处理策略同样重要。很多人遇到 version 不匹配就直接抛异常或者直接重试整个业务流程。前者导致用户体验差后者可能导致无限循环甚至雪上加霜。我的建议是给乐观锁的失败重试设置两层限制次数限制和时间限制。比如最多重试 3 次每次重试之间间隔 50 到 200 毫秒。如果重试后仍然失败再走降级路径——比如提示用户稍后重试或者记录异常数据到告警系统人工介入。还有一个经常被忽略的点乐观锁的 version 更新要和作用域对应。如果并发操作的是库存和订单状态两个表你不能只在一张表上加 version 然后去更新两张表这样第二张表照样会出问题。要么把两张表的更新放在一个数据库事务里用同一个事务的操作顺序保证一致性要么在业务上设计一个状态机通过订单状态字段限制前置状态保证只有待支付状态的订单才能被重复扣减。4. 代码落地一套分布式锁 乐观锁的组合方案说了半天原理直接上一套可落地的代码骨架。以最常见的下单扣库存场景为例。4.1 下单扣库存的代码骨架public boolean deductStock(Long productId, Integer count) { // 1. 获取分布式锁 String lockKey product:lock: productId; boolean locked redisLock.tryLock(lockKey, 3_000L, 10_000L, TimeUnit.MILLISECONDS); if (!locked) { // 拿不到锁返回失败或走队列 return false; } try { // 2. 读取当前库存和版本号 ProductStock stock stockMapper.selectByProductId(productId); if (stock.getStock() count) { throw new BizException(库存不足); } // 3. 乐观锁兜底更新 int rows stockMapper.decreaseStock( productId, count, stock.getVersion() // 条件版本号必须等于读取到的值 ); if (rows 0) { // 4. 版本冲突代表分布式锁之外还有并发进来修改过 // 记录告警触发重试或降级 alertService.send(锁失效事件productId productId); return false; } // 5. 扣减成功后生成订单等后续逻辑 orderService.createOrder(productId, count); return true; } finally { // 6. 释放锁 redisLock.unlock(lockKey); } }这段代码的关键点有三个锁的作用范围是整个事务边界包括读数据、扣库存、建订单。乐观锁的作用范围是库存表这条记录的更新操作不依赖锁是否存在。当rows 0时说明发生了锁穿透必须告警。因为正常情况下有 Redis 锁在乐观锁失败率应该非常低。一旦频繁出现说明锁本身有问题比如主从切换、锁过期时间过短、锁误删。4.2 Redis 锁的 Key 设计、过期时间与看门狗锁的 Key 要尽量有业务语义并且包含资源 ID不要用全局字符串lock这种一把锁锁所有资源的粗粒度设计。粗粒度锁会严重降低并发能力细粒度锁则要根据资源维度拆分。比如商品库存锁用product:lock:{productId}用户优惠券锁用coupon:lock:{userId}:{couponTemplateId}。过期时间我一般设置为业务最大执行时间的 5 到 10 倍。比如正常的扣库存创建订单链路是 200 毫秒锁过期时间我设置 2 秒。这里有个权衡设置太长万一持有锁的客户端宕机其他请求会阻塞很久设置太短业务慢一点锁就提前释放。所以我更推崇客户端支持看门狗自动续期让锁持有时间跟着业务实际耗时走而不是固定一个死值。如果你们没有看门狗机制务必要加上锁的唯一标识校验。每个客户端拿锁时生成一个随机 token释放锁时用 Lua 脚本校验 token 是否是自己持有防止误删别人的锁。4.3 乐观锁冲突后的处理路径乐观锁冲突处理我强烈建议不要简单抛异常。合理的路径是延迟 50 到 100 毫秒后重试一次。重试前重新读取最新版本号和库存。连续失败超过 3 次记录日志并返回系统繁忙。对特别重要的接口比如支付、退款触发告警便于后续排查是不是 Redis 锁有异常。这里要单独强调乐观锁重试不能无上限。无上限重试带来的问题有两个一是用户请求的 RT 被无限拉长体验极差二是重试过程中的读操作可能占用额外数据库连接流量高峰时容易把连接池拖垮。宁可返回失败让用户稍后重试也不要让系统在并发风暴里反复折腾。5. 兜底不只有乐观锁幂等、唯一约束与状态机的分工乐观锁是兜底方案的一种但它不是唯一的选择。实际系统里分布式锁、乐观锁、幂等键、数据库约束、状态机各自承担不同的职责组合起来才是完整的防线。5.1 状态机比扣数量更接近真相的业务建模拿订单系统举例。订单的状态是待支付 → 已支付 → 已发货 → 已完成每个操作都要求前置状态正确。比如支付操作必须先确认订单处于待支付状态才能改为已支付。这个条件本身就是一个天然乐观锁。UPDATE orders SET status PAID, pay_time NOW() WHERE order_id #{orderId} AND status WAIT_PAY;如果影响行数为 0说明订单状态已经不是待支付了。这时候不管 Redis 锁在不在第二次支付都会被挡住。这种状态机设计比单纯的库存 version 更接近业务真相因为它的语义是状态必须按顺序流转不能跳级、不能倒退。我遇到过不少团队为了省事把库存扣减和订单状态更新拆成了两个独立接口各自加锁各自用乐观锁结果一个事务跨了两次网络调用中间任何一次失败都会导致数据不一致。正确的做法是尽量把状态更新和数量扣减收敛到一个数据库事务里让数据库的原子性和行锁成为最终裁决者。5.2 数据库唯一键和消息幂等锁外的另一道墙有些场景乐观锁还没有唯一键好用。比如防止用户重复下单与其用锁不如在订单表创建一个(user_id, product_id, biz_unique_id)的唯一索引。同一个用户对同一个商品的同一个业务请求号在数据库层面就只能插入一次。第二次插入直接报唯一约束错误比任何锁都彻底。这个思路也适用于消息队列的消费场景。消费端从 MQ 拿消息处理前先往消息去重表里插入一条记录表里有唯一键。如果插入成功才处理插入失败说明这条消息已经处理过直接 ACK 丢弃。这样即使分布式锁失效、消费端重复消费也不会产生重复订单或重复扣款。5.3 警惕把 Redis 锁做成万能同步工具我在很多项目里见过一种危险倾向把 Redis 锁当成解决所有并发问题的万能工具。订单用锁库存用锁扣款用锁连读缓存都加锁。锁的粒度越来越粗性能越来越差最终出问题时会很复杂。实际上很多所谓的并发问题完全可以用数据库能力直接解决不需要绕一圈引入 Redis 锁。比如防重复下单用唯一索引。防止金额多扣用SET balance balance - amount WHERE balance amount。防止状态回退用状态机前置条件。防止重复消费用消息幂等表。当这些数据库层面的约束已经足够时Redis 锁只是一种减少冲突概率的优化手段而不是保证正确性的必要条件。把这两者分清楚代码会简单很多故障也会少很多。6. 线上实战中的配置参数与避坑清单最后汇总一些我在线上实战中总结出来的参数参考和踩坑经验。如果你正准备给系统加分布式锁和乐观锁兜底可以直接拿去做参考。6.1 我常用的锁参数参考场景锁粒度锁过期时间重试策略兜底机制扣减库存商品 ID 维度2~5 秒配合看门狗最多等 1 秒version 条件更新 3 次重试创建订单用户 ID 商品 ID3 秒失败返回提示唯一索引幂等退款操作订单 ID5 秒最多等待 2 秒状态机校验 版本号优惠券领取用户 ID 券模板 ID2 秒最多等 500 毫秒唯一索引 状态字段校验这些参数不是拍脑袋来的。锁过期时间需要基于你在压测环境里测出来的业务执行 P99 耗时来定建议至少是 P99 耗时的 3 倍但不要超过 10 秒不然故障恢复太慢。重试次数上限和等待时间要控制在接口超时预算之内比如下游给你 2 秒超时你的重试就不能超过 1.5 秒。6.2 最容易踩的坑版本号粒度、锁混淆、恢复流程版本号粒度是一个高频坑点。有的表加了 version 字段但更新时条件里没有 version或者 version 更新逻辑写成了version #{expectedVersion}1而不是version version 1,这两个写法在并发场景下的语义完全不同。前者可能把别人的 version 覆盖掉后者才是原子递增。锁混淆也很常见。不同的业务模块如果用了同一个 Redis 锁前缀比如一个模块用lock:product:1另一个模块也用lock:product:1它们虽然业务无关但会互相阻塞。建议统一锁 Key 的规范业务域:资源类型:资源ID并在代码评审时强制检查。还有一个恢复流程的问题。有些团队在乐观锁失败时直接返回失败但没有考虑如何处理已经产生的脏数据。比如库存被多加了一次订单生成了但没有支付这时候需要配套的补偿任务来定时巡检。我的建议是任何乐观锁失败率高的接口都要有对应的数据巡检和人工补偿 SOP。6.3 个人经验先想好锁失效以后的降级路径聊到最后我想分享一个这几年最深的体会。很多人在设计分布式锁时脑子里想的全是锁一定要万无一失于是疯狂堆方案RedLock、多副本、看门狗、可重入。这些当然有用但更重要的是你要提前想清楚如果锁最终还是失效了你的业务能不能自动降级一个好的降级路径至少包含三件事业务核心数据库存、金额、状态的最终一致性必须由数据库条件更新和状态机保证不依赖 Redis。锁失效事件必须可观测。每次乐观锁冲突、每次锁获取异常都要有日志、告警、指标。业务链路上要有幂等机制确保锁穿透后产生的重复请求不会造成实际损失。换句话说分布式锁是加速通过闸机的优化器乐观锁加数据库约束才是闸机坏了以后不会死人的护栏。优化器可以升级护栏绝对不能拆。只有把这一层想明白了你在 Redis 单机、主从复制、集群模式下部署分布式锁时心里才真正有底。我个人在实际项目里已经养成了一个习惯写完分布式锁代码后第一件事不是验证锁能不能正常拿到而是手动模拟锁失效场景——干掉 Redis 主节点、把锁过期时间调成 1 毫秒、让一个线程持有锁后阻塞 10 秒——然后看业务代码能不能靠乐观锁兜住。能扛住这套方案才算是真的能在线上活下去。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →