1. 从一次线上超卖说起分布式锁不是数据安全的万能钥匙做秒杀系统那一年我踩过一个特别典型的坑Redis分布式锁加了流量也扛住了但线上还是出现了超卖。一开始我怀疑是库存扣减并发写错了后来查日志、复现场景才发现问题根本不在业务代码而在Redis分布式锁本身——准确说是主从切换的那一秒锁凭空“丢”了。补上数据库乐观锁兜底之后这道闸才算真正焊死。这篇就从业务代码出发把“为什么有分布式锁还需要乐观锁兜底”这件事讲透。先解释一下基本面。分布式锁解决的是一个很直接的问题多个进程、多台机器同时操作同一个共享资源时怎么保证同一时刻只有一个人能进临界区。而乐观锁解决的是另一个问题即使两个请求同时读到了数据、同时尝试写库数据库怎么保证最后写进去的结果是合法的、不脏的。很多人以为这两者是重复的防护其实它们是两条完全不同的防线分布式锁挡的是“入口”乐观锁守住的是“结果”。这篇文章适合给正在做电商、秒杀、抢购、库存扣减、订单防重这类业务的同学看也适合准备分布式锁相关面试的人。我会从一次真实超卖场景切入先复现当时那段看似没毛病的业务代码再分别拆解Redis在单机、主从复制、集群三种部署形态下的潜在一致性问题最后给出完整的乐观锁兜底方案。如果你也在“加了锁还是出乱子”的坑里这篇应该能帮你把问题彻底想明白。1.1 当时那段“以为没问题”的扣库存代码那时候的代码逻辑很简单拿到分布式锁之后读库存、判断库存是否充足、扣减库存、生成订单最后释放锁。伪代码大概是这样的public Result createOrder(Long userId, Long goodsId, Integer num) { String lockKey lock:order: goodsId; RLock lock redisson.getLock(lockKey); boolean locked false; try { locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { return Result.error(系统繁忙请稍后再试); } // 1. 查询库存 Stock stock stockMapper.selectByGoodsId(goodsId); // 2. 检查库存 if (stock null || stock.getStock() num) { return Result.error(库存不足); } // 3. 扣减库存 stockMapper.decreaseStock(goodsId, num); // 4. 生成订单 orderMapper.insert(...); } finally { if (locked) { lock.unlock(); } } return Result.success(); }这段代码在当时看起来无懈可击有锁、有库存判断、有事务、有finally释放。但问题恰恰出在“锁”和“库存校验扣减”之间那条看不见的缝隙里。只要Redis锁在规定时间内失效或者锁数据在主从同步前丢失两个线程就会同时进入临界区同时读到库存为1同时通过检查然后各自扣减于是库存变成-1超卖出现了。这个场景不是理论推演而是真实发生过的事情。所以从那之后我对分布式锁的态度从“有锁就安全”变成了“锁只是漏斗的第一层数据库校验必须是最后一层”。1.2 分布式锁到底锁住了什么很多人对分布式锁的期望过高以为它能像事务一样保证数据绝对一致。实际上分布式锁只做一件事在有限时间内尽量让所有客户端互斥地进入临界区。它的核心性质有三个互斥性、可释放性、可容忍一定时间内的失效。互斥性好理解就是同一把锁同一时刻只能被一个客户端持有。可释放性是说不管客户端是正常结束还是崩溃锁最终要能被释放否则就会死锁。这一点靠过期时间兜底。可容忍一定时间内的失效这句话最容易被忽略——Redis分布式锁从来不敢承诺“绝不失效”它只承诺“失效的概率足够低”。而业务数据的一致性最终还是要靠存储层去保证。Redis是缓存层、协调层它上面的锁数据本身就不是业务数据的“权威记录”。真正的权威记录在数据库里。所以当锁失效导致两个线程撞车时数据库必须能识别出“谁的数据是合法的”这就要靠乐观锁、约束条件这类数据库层面的机制来兜底。1.3 乐观锁兜底解决的到底是哪个环节的问题乐观锁兜底解决的是“两个线程同时把数据写坏了”的最后一公里问题。Redis锁失效后两个线程都会执行到扣减库存这一步它们各自带着自己读到的库存值。如果没有乐观锁SQL就是UPDATE stock SET stock stock - 1 WHERE goods_id ?这种无条件的更新谁后到谁覆盖库存直接被扣成负数。加了乐观锁之后SQL会变成UPDATE stock SET stock stock - 1 WHERE goods_id ? AND stock ?这里stock ?就是一条内置的条件判断。数据库在更新的时候会按行加锁真正执行更新那一刻才去校验条件是否成立。第二个线程执行时库存已经被第一个线程扣没了条件不成立更新影响行数为0对应业务上就判定为“库存不足”于是整个流程回滚。这就是乐观锁兜底解决的核心环节——把“读到的旧值”和“数据库当前的真实值”之间的偏差在校验阶段拦截掉。2. Redis分布式锁的常规实现与边界在哪聊完了问题来源我们先把手写分布式锁的常见姿势捋一遍同时讲清楚每步为什么这么写。只有理解了锁的机制边界你才知道哪些故障是它天生绕不过去的。2.1 SETNX加过期时间最原始的锁长什么样早期很多人用SETNX命令实现分布式锁。SETNX的意思是“Set if Not eXists”只有key不存在时才能设置成功返回1表示拿到锁返回0表示锁被别人持有了。最简单的写法是这样SET lock:order:1001 uuid-1234 NX PX 30000这条命令只做一件事如果keylock:order:1001不存在就设置值uuid-1234并且带上30秒过期时间如果key已经存在命令直接失败。为什么一定要用一条命令同时设置值和过期时间因为在老版本里有人写两步先SETNX再EXPIRE。如果SETNX成功之后、EXPIRE执行之前进程崩溃了这把锁永远没有过期时间直接导致死锁。所以从Redis 2.6.12之后官方就建议把值、NX、PX合成一条命令。锁的值为什么不能用固定字符串比如“1”因为释放锁的时候需要校验“是不是我自己加的锁”。用固定字符串进程A释放锁的时候可能把进程B刚获取的锁给删了。所以一般会用UUID、RequestId这类唯一标识作为value删除前先比对value。2.2 从手写锁到Redisson看门狗做了什么手写锁有个很明显的问题过期时间定多少都不踏实。定短了业务还没执行完锁就过期了定长了如果进程崩溃其他请求要白白等很久。于是Redisson这类客户端库引入了“看门狗”机制。Redisson默认的锁超时时间是30秒叫lockWatchdogTimeout。线程获取锁成功后Redisson会启动一个后台定时任务每10秒给这把锁续期一次把过期时间重新刷到30秒。只要持有锁的线程还活着锁就一直有效一旦线程崩溃看门狗跟着没了锁在30秒左右自动过期释放。这个设计很优雅但它有个盲区如果你的Java应用发生了Full GC整个JVM暂停业务线程停了看门狗线程也停了。暂停时间超过30秒的话锁照样过期。等GC恢复业务线程继续跑它手里的锁其实已经没有了。这个场景和主从切换丢锁一样都是“失效窗口”只能靠兜底。2.3 解锁的Lua脚本为什么必须这么写释放锁的关键在于“先校验再删除”的原子性。你肯定不想出现这种情况A线程的锁已经过期B线程拿到锁然后A线程执行DEL把B的锁删了导致两个线程同时进入临界区。正确的解锁方式必须保证“比对value”和“删除key”这两个动作是原子的中间不能被其他指令插入。所以在Redis里这个操作要用Lua脚本来做if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本的意思是先GET这个key的值如果等于当前线程自己持有的唯一标识ARGV[1]才执行DEL否则返回0表示锁不是你的不删。因为Lua脚本在Redis中是单线程执行的整个脚本内部不会被其他命令打断所以不存在“比对完和删除之间被插队”的窗口。Redisson的unlock()内部就是这么实现的。2.4 重入、公平性这些“锦上添花”不一定有用Redisson还支持可重入锁就是说同一个线程可以多次获取同一把锁内部用计数器计数只有计数归零才真正释放。这个特性在业务需要嵌套调用时会方便但也容易让人忽略锁的真实状态。还有人关心公平锁先请求先得。但在分布式锁场景下公平性往往不是第一需求大家更在意的是“别让锁失效导致业务出错”。我曾经见过有人为了追求所谓“绝对可靠”在Redisson基础上自己封装了一层“锁失败就无限重试”的逻辑。结果某个请求获取锁失败后一直空转重试把线程池打满了。这就是典型的过度设计。分布式锁本身允许一定的获取失败业务上应该快速失败或者短暂重试而不是无限等锁。3. 单机、主从、集群模式下的一致性问题这一节是全篇的核心我们把Redis三种部署形态下的潜在问题一个个拆开看看每一条是怎么让分布式锁“失效”的。每一种形态都有各自的软肋理解了这些你自然能推导出为什么需要兜底。3.1 单机模式即使Redis不宕机JVM的GC也会替它“过期”单机Redis看起来最简单锁写在一个节点上没有复制延迟没有选主问题。但单机模式照样会出问题根源往往不在Redis而在应用进程本身。最典型的场景就是JVM停顿。假设线程A拿到锁锁的过期时间设为10秒线程A在临界区里正执行到一半刚好发生Full GC整个应用暂停了15秒。这15秒里Redis里的锁已经到期自动删除。GC结束后线程A继续执行后面的代码从它的视角看自己还“持有”着这把锁但实际在Redis里锁已经不存在了。此时线程B过来获取锁顺利拿到也进入临界区。两个线程同时操作同一份数据超卖发生。就算你的应用没有JVM停顿问题线上还有网络抖动、磁盘IO堵住、远程调用超时等情况都可能让业务执行时间超过锁过期时间。所以“锁过期时间一定要大于业务执行时间”这句话在实际生产中很难保证这也是为什么看门狗机制能缓解但是不能彻底解决。3.2 主从复制异步复制才是丢锁的头号元凶生产环境为了高可用Redis很少用单机通常会配主从复制加哨兵。但这里就埋了一个更大的雷Redis主从复制是异步的。具体来说主节点接到写命令后会先写入自己的内存然后返回给客户端“写成功”接着才把命令异步传给从节点。这个“异步”意味着从节点可能落后于主节点落后的程度取决于网络和负载。正常情况下只有几毫秒但主节点宕机的那一瞬间数据落后多少就完全说不定了。分布式锁丢锁的完整链路是这样的线程A在Redis主节点上写入锁key比如SET lock:order:1001 uuid-111 NX PX 30000。主节点刚写完还没把这条命令同步给从节点主节点宕机了。哨兵检测到主节点不可用把从节点提升为新主节点。这时候新主节点上根本没有这把锁。线程B来获取锁SETNX直接成功于是线程A和线程B同时以为自己持有了锁。这个场景的关键点在于Redis主从复制不保证“强一致”只保证“最终一致”。哨兵和集群的故障转移保障的是可用性而不是数据不丢失。只要复制路径上有任何延迟锁就可能丢。MySQL的主从切换同样有这个问题但MySQL通常配合半同步复制、以及业务层的事务约束来兜底而Redis的定位决定了它很少做同步复制。3.3 Cluster集群模式故障转移窗口期与“脑裂”的旧主Redis Cluster的定位和主从复制模式有一些不同它把数据按slot分布到不同节点每个主节点带若干从节点。当某个主节点宕机从节点会被提升同样存在异步复制导致的数据丢失。除此之外集群模式还引入了分区和“脑裂”的问题。举个例子一个Cluster集群有6个节点其中主节点M1负责一部分slot从节点S1负责备份M1。如果网络分区把M1和客户端划分到一侧其他节点在另一侧。其他节点通过心跳发现M1失联经过投票选出S1为新主节点。但这时候客户端仍然连着旧的M1而且M1自己并不知道已经被“架空”它还在继续接收写请求包括写入锁。在这种情况下旧主M1上的锁还没过期新主S1也从客户端B那边接收了新锁。两个客户端各拿着一把“不同的锁”进入同一个临界区。这是在分布式系统里典型的脑裂现象Redis自身机制很难完全避免。从业务角度看我们又回到了同一个结论数据写入必须要有一道数据库层面的条件约束来把关。3.4 为什么Redlock也不是银弹既然单机、主从、集群都有问题那是不是多个Redis实例组成的Redlock红锁就能解决Redlock的思路是客户端向奇数个比如5个独立的Redis实例同时发送加锁请求只有超过一半N/21实例返回成功才算真正拿到锁。Redlock在大多数正常运行场景下表现不错但它依赖一个严格的前提所有实例的时钟都是准的而且网络往返时间稳定可控。如果某个实例的时钟发生跳跃或者某个客户端在网络隔离后仍然访问到一部分实例它依然可能在加锁成功后被另一边的客户端“绕过去”。业界对Redlock的争论早就存在尤其是Martin Kleppmann在《How to do distributed locking》里指出Redlock仍然不能保证绝对的互斥它只是把失效概率降得更低。所以我的观点很明确不管你是用单机锁、红锁还是什么“多级锁”分布式锁都只能作为第一道防线绝不能作为数据一致性唯一的裁决点。真正的裁决点永远应该在数据存储层。4. 乐观锁兜底方案设计与核心细节前面讲了很多理论下面直接进入方案设计。乐观锁的核心很简单更新数据的时候带上一个条件让数据库在真正执行更新的那一瞬间去判断“这条数据是否还是你当初读到的那条”。4.1 数据库乐观锁的原理拿版本号去CAS常见的乐观锁实现方式是给表加一个version字段。每次读取数据时把version一起查出来更新数据时SQL写成SET version version 1 WHERE id ? AND version ?。执行更新后如果影响行数为0说明version已经被别人改过了你读到的数据是旧数据必须重新读取再重试。这个做法和CASCompare And Set是同一个思想只不过CAS发生在CPU层面这里发生在数据库层面。数据库在更新时会锁住这行记录然后比较条件条件不满足就不改整个检查更新过程是原子的不会出现“A读到旧值、B也读到旧值、两个人都能写成功”的情况。乐观锁有一个重要的适用前提并发冲突不频繁。如果两个线程天天打架重试次数会很高反而是悲观锁效率更高。但在秒杀这种场景里冲突多反而是常态所以重试要有上限不能让线程无限自旋。4.2 库存扣减场景用“剩余库存”当版本号库存扣减和普通字段更新有一个特殊之处剩余库存stock本身就是天然的版本号。扣减之前我们要求库存充足真正执行更新时把“库存充足”这个条件写进WHERE条件不满足就不让扣减。SQL长这样UPDATE t_order_stock SET stock stock - #{num} WHERE goods_id #{goodsId} AND stock #{num}这里的关键是stock #{num}既是一个业务校验又是一个并发控制条件。两个线程同时读到库存为1同时执行这条SQL。数据库给这行记录加锁第一个线程先执行库存从1变成0影响行数为1第二个线程再执行时stock 1已经不成立了影响行数为0。我们在Java层通过判断影响行数是否为1来决定业务是否成功。如果业务要求更严格的版本比对比如不想让老版本数据“静默跳过”可以额外加version字段UPDATE t_order_stock SET stock stock - #{num}, version version 1 WHERE goods_id #{goodsId} AND stock #{num} AND version #{oldVersion}这样既能防止超卖又能发现数据发生过变化。对很多电商系统来说stock #{num}已经足够但如果你在同一个表上还有其他更新逻辑加version会更容易排查问题。4.3 兜底和分布式锁的分工需要强调乐观锁兜底不是替代分布式锁二者是配合关系。分布式锁的作用是尽量把并发挡在临界区外面让大多数请求按顺序执行减少乐观锁冲突。乐观锁的作用是当分布式锁失效或有漏网之鱼时在数据写入的最后一步把错误数据拦住。如果把整个业务流程比作一个仓库分布式锁是仓库门口的排队叫号机乐观锁是货架上的库存计数标签。叫号机坏了两个客户同时进仓库拿最后一件货物但只要货架系统记录的是“已售出”第二个客户就会在结账时被拦下来。没有乐观锁第二个客户会直接抱走并不存在的货物这就是超卖。从性能角度看分布式锁让大部分请求在Redis层就被串行化代价是毫秒级的锁等待乐观锁让数据库层做最终校验代价是极少数的冲突重试。两者结合既保住了高并发下的吞吐又保住了数据正确性的底线。4.4 重试与失败处理乐观锁更新失败后不能直接把异常抛给用户也不能无限重试。我自己的经验是这样先判断失败原因如果原因是影响行数为0说明是并发冲突可以短暂退避后重新查询、重新执行。重试次数一般控制在2到3次。比如第一次失败后sleep 100毫秒重试第二次失败后sleep 200毫秒重试第三次失败就直接返回“系统繁忙”。这里有个小技巧是把sleep时间加一点随机抖动比如在±30毫秒内随机波动。为什么因为如果所有重试请求都在同一时刻醒来它们又会扎堆撞在同一个点上形成“惊群效应”反而更容易失败。如果重试之后还是失败业务上要做的不是继续硬扛而是把请求标记为失败、记录日志甚至做补偿。比如秒杀场景返回“手慢了”然后把失败原因和扣减提交的数据结构记录下来供后续分析。失败的请求如果确实是库存不足不要遮掩把真实原因返回给用户。5. 完整业务代码示例与主从切换模拟这一节给你一份可以直接抄到项目里的完整示例同时讲讲怎么在测试环境复现“主从切换丢锁”这个现象。看懂了现象你才能真正理解兜底代码的必要性。5.1 Java层完整代码示例先整理一下整体流程获取分布式锁 → 查询库存 → 业务校验 → 扣减库存乐观锁条件更新→ 生成订单 → 释放锁。如果任意一步失败事务回滚。Transactional(rollbackFor Exception.class) public Result createOrder(Long userId, Long goodsId, Integer num) { String lockKey lock:order: goodsId; RLock lock redisson.getLock(lockKey); boolean locked false; try { // 第一道防线分布式锁 locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { return Result.error(系统繁忙请稍后再试); } // 查询库存当前读取到的数据 Stock stock stockMapper.selectByGoodsIdForUpdate(goodsId); // 业务校验 if (stock null || stock.getStock() num) { return Result.error(库存不足); } // 第二道防线乐观锁扣减库存 int rows stockMapper.decreaseStockWithCondition(goodsId, num, stock.getVersion()); if (rows 0) { // 这里说明分布式锁失效或冲突扣减没成功 return Result.error(库存扣减冲突请重试); } // 生成订单 Order order new Order(); order.setUserId(userId); order.setGoodsId(goodsId); order.setNum(num); orderMapper.insert(order); return Result.success(); } finally { if (locked) { lock.unlock(); } } }注意我查询库存时用了selectByGoodsIdForUpdate如果你的数据库是InnoDB这个查询会带行锁。为什么在分布式锁之下还要加数据库行锁因为分布式锁是在Redis层的互斥数据库行锁是在事务层面的互斥。如果Redis锁失效至少数据库事务还能保证“同一时间只有一个事务能真正更新这行库存”配合乐观锁的条件更新把超卖的概率压到最低。对应的Mapper SQLupdate iddecreaseStockWithCondition UPDATE t_order_stock SET stock stock - #{num}, version version 1 WHERE goods_id #{goodsId} AND stock #{num} AND version #{version} /update加version条件的意义在于如果业务逻辑不只是扣库存还可能在一次请求里对库存做其他操作有了version字段就能确认数据没有在你处理过程中被动过。5.2 模拟主从切换怎么复现丢锁在测试环境复现丢锁并不难前提是你能控制Redis主从切换节奏。步骤如下准备一个Redis主从环境配置好哨兵或者手动指定主从关系。用redis-cli在主节点上设置一把锁SET lock:test uuid-123 NX PX 30000。立刻查看从节点的数据redis-cli -p 从节点端口 GET lock:test。这一步关键可能能读到值也可能读不到取决于复制是否已经完成。如果你执行太快大概率读不到。在主节点上执行DEBUG SLEEP 5模拟主节点处理请求卡顿或者直接SHUTDOWN NOSAVE模拟宕机。等哨兵将从节点提升为主节点或者手动执行SLAVEOF NO ONE让从节点变成主节点。在新的主节点上执行GET lock:test你会发现key不存在。然后执行SET lock:test uuid-456 NX PX 30000新客户端成功获取锁。这样你就复现了“客户端A拿锁成功但锁丢失客户端B也拿锁成功”的场景。测试完记得清理环境别把DEBUG SLEEP这类命令带到生产上。5.3 参数怎么定锁时长、重试次数、版本号策略关于锁过期时间如果使用Redisson默认的30秒和看门狗大多数业务够用。如果你用手写锁我的建议是取“业务最长耗时的3到5倍”比如平时扣库存只要50毫秒但考虑到GC、网络抖动锁过期时间至少设置5秒别抠门。太短没有容错太长故障恢复慢。重试次数就用上一节说的2到3次每次sleep 50到200毫秒并加随机抖动。不要设置无限重试否则高并发下一旦锁竞争剧烈线程会被拖死。版本号字段类型用INT或者BIGINT就够了更新时version 1。很多老项目会遇到一个坑表里没有version字段加字段的时候要注意历史数据默认值比如默认0。存量数据第一次被更新时要保证version从0变成1SQL里的AND version #{oldVersion}才能匹配上。6. 排查实录与避坑清单最后分享一些我在实际项目中遇到的问题和排查思路整理成速查表方便你遇到类似症状时快速定位。有的是知识层面的有的是实操层面的都是踩过坑换来的。6.1 问题速查表现象根因处理思路加了分布式锁还是超卖主从切换丢锁/JVM停顿锁过期加乐观锁条件更新兜底检查复制延迟锁一直获取不到锁过期时间太长或释放逻辑有bug检查finally是否执行检查是否存在死锁等待解锁时报错或误删他人锁没校验锁value直接DEL改用Lua脚本比对后删除业务执行时间超过锁过期时间超时时间设置太短启用看门狗或按最长耗时3倍设置重试请求把线程池打满无限重试获取锁限制重试次数加随机退避扣库存后订单重复创建锁失效后两个线程都过了校验订单表加唯一索引扣库存用乐观锁条件有一个特别容易被忽略的坑如果你在finally里调用lock.unlock()但业务方法本身已经把事务提交了然后unlock抛异常整个方法会被标记为异常可能触发意想不到的回滚。所以我习惯在释放锁的时候单独捕获异常并记录日志不让锁释放影响业务结果。6.2 几条血泪经验第一Redis分布式锁的值一定要带上唯一标识释放前要比对。很多人一开始图省事用固定字符串等到误删锁导致并发事故才回头改。Redisson默认代码是安全的手写锁则务必注意。第二乐观锁条件里的stock #{num}不是“性能优化”是业务防线的核心。即使你觉得加了锁就不会有并发问题也一定要写这个条件。因为分布式锁的失效窗口是客观存在的你不是在防“正常情况”你是在防“异常情况”。第三主从复制延迟不是只在Redis存在MySQL也一样。如果做双写、读写分离最好有一个“写后读”的补偿策略比如关键操作强制走主库或者增加延迟容忍。分布式锁和乐观锁的组合其实也和这个思路一致高层机制负责性能底层机制负责正确性。第四监控很重要。我上线这套方案之后加了几个监控指标分布式锁获取失败次数、乐观锁冲突次数、扣减SQL影响行数为0的次数。日常不怎么看但一旦出问题这些指标能直接告诉你是Redis锁失效了还是数据库条件冲突了省去大量排查时间。第五如果业务对数据一致性要求极其苛刻比如账户余额、订单状态流转不要只依赖Redis分布式锁。可以考虑把核心状态机建模放到数据库里用数据库事务唯一约束状态字段兜底。Redis锁在这种场景里更适合做“入口限流”而不是“数据正确性的担保人”。回到开头那个超卖问题。后来我把扣库存SQL加上stock #{num}条件再把订单表加上防重唯一索引同时在Redis锁外面补了Redisson自身的看门狗机制这套组合上线后再也没有出现超卖。怎么说呢分布式锁解决的是99%的正常并发乐观锁解决的是那1%的极端故障。你兜住的不只是一行数据而是整条业务链路的信任。下次再有人问“有分布式锁为什么还要乐观锁”你就可以把这篇甩给他然后告诉他去看看主从切换那一秒发生了什么。