ARTICLE DETAIL

资讯详情

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

分布式锁实战:从setnx到Redisson,黑马点评秒杀场景的演进与避坑指南

分布式锁实战:从setnx到Redisson,黑马点评秒杀场景的演进与避坑指南 1. 黑马点评的秒杀场景为什么本地锁扛不住1.1 每人限购一单这个需求靠JVM锁为什么是伪方案先聊一个我在做黑马点评这个项目时印象特别深的事。里面有个很经典的业务优惠券秒杀一人一单。也就是说同一个用户在极短的时间内不能让同一张券被下单两次。你本地用Synchronized或者ReentrantLock测得好好的单机版跑几百个并发线程也没问题我一开始就是这么干的。结果把服务部署到多实例环境后噩梦就来了——同一个用户居然收到了两单成功提示。问题的根子在于本地锁锁的是JVM内部的对象监视器Monitor它只能拦住当前这个进程里的线程。一旦你的服务拆成两个实例、三个实例每个实例都是独立的JVM各自的synchronized互不相干。换句话讲本地锁是“各管各的房间门”没有一把“整个楼层共用的大门”。所以只要流量被负载均衡分发到两台机器上同一个用户的两个请求就能同时绕过各自的本地锁双双进入下单逻辑。这不是并发环境的问题而是“分布式环境下的互斥”压根没有解决。黑马点评这个项目虽然是教学项目但它的秒杀场景模拟得非常真实高并发、短时间、强一致。你把本地锁放进去单实例压测永远看不出来一扩机器就翻车。这个教训让我意识到秒杀这种业务锁的作用范围必须是一个“所有服务实例都能访问到的公共区域”Redis正好就是现成的那个区域。1.2 从synchronized到分布式锁锁的本质到底是什么我后来复盘时想明白了一件事锁的本质就是“多个人之间达成一个约定”。单机的约定靠JVM维护分布式的约定就得靠一个大家都信任的第三方。Redis的SETNX指令就是最简单的约定方式——“如果key不存在我才能写进去”。所有实例都去写同一个key谁能写成功谁就拿到锁其他人只能等。在黑马点评里这个公共区域就是Redis。Redis本身是单线程处理命令的天然保证所有客户端的SETNX操作是串行执行的不会出现两个线程同时写成功的可能。这一点非常关键它是整个分布式锁可靠性的基石。在做锁的方案选型时你首先要证明的也是“底层操作是不是原子的”不满足这一点后面所有优化都是空中楼阁。下面这张表是我当时对比本地锁和分布式锁时列的虽然简单但能帮你把思路理顺对比维度本地锁synchronizedRedis分布式锁作用范围单个JVM进程内所有接入同一Redis的实例实现原理JVM Monitor机制Redis原子命令锁的存储内存对象Redis Key适用场景单机应用、单实例部署多实例、微服务、集群部署故障影响JVM崩溃锁即消失Redis宕机影响所有实例所以做黑马点评的分布式锁本质上是在回答一个问题怎么用一个所有人都能访问到的Redis去模拟出JVM内部那把锁的效果并且做到在高并发、异常断电、网络抖动的情况下依然安全。这不是加一个依赖、写两行代码的事里面每一个细节都是坑。下面我把整个演进过程完整走一遍。2. 手写第一版分布式锁setnx的每一步都在补窟窿2.1 最朴素的setnx写法一上线就发现问题先来看最直白的版本。用Spring Data Redis操作Redis拿到锁的方式特别简单Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(lock:order: voucherId, userId.toString()); if (Boolean.TRUE.equals(success)) { // 拿到锁执行下单逻辑 try { createVoucherOrder(voucherId, userId); } finally { stringRedisTemplate.delete(lock:order: voucherId); } } else { // 没拿到锁 return Result.fail(不能重复下单); }setIfAbsent对应Redis的SETNX命令。这个方案的思路很清晰把“锁”建成一个keyvalue写用户ID只有key不存在时才能写入成功。第一个请求写入成功相当于拿到了锁后续请求发现key已经存在就说明有人正在处理直接拒绝。但我上线跑了一波压测立刻发现问题如果线程在执行业务逻辑的过程中抛了异常finally里的删除操作确实会执行但如果服务进程宕机、被强制kill -9、或者网络分区导致代码根本没跑到finally那把锁就永远留在Redis里了。后面的所有请求都会判定“有人正在下单”然后被拒绝。业务直接瘫痪。现场恢复的办法只有一个手动连上Redis敲一句DEL lock:order:xxx。这显然不现实——谁半夜守着Redis等着删key所以锁必须设置过期时间。2.2 加过期时间一个来不及释放的锁是怎么拖垮业务的给锁加过期时间本质上是给“异常情况”兜底。不管业务执行多久、是否崩溃锁最多存活N秒到期自动释放。这样服务恢复后后来的请求还能继续抢锁不会永远死锁。Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(lock:order: voucherId, userId.toString(), 10, TimeUnit.SECONDS);注意setIfAbsent加过期时间必须是在同一条命令里完成的。Spring Data Redis的setIfAbsent(K key, V value, long timeout, TimeUnit unit)这个重载方法底层是Redis的SET key value NX EX seconds原子性没问题。但这里抛出一个特别现实的问题这10秒过期时间设得对吗如果秒杀流程里包含库存扣减、订单创建、消息发送等一系列操作正常情况可能只要100毫秒但遇到数据库慢查询、Full GC、网络抖动单次业务执行可能就超过10秒。一旦超过锁自动释放另一个线程趁虚而入也能拿到锁于是一个用户的两笔订单就都在执行。这就是经典的“锁提前过期导致并发保护失效”。那是不是把时间设长一点就没事设成30秒、60秒看起来稳妥可一旦持有锁的实例真宕机了其他用户就得干等几十秒才能继续秒杀。原本一个只需要几十毫秒的接口硬生生被拖成几十秒超时这个体验对秒杀场景是毁灭性的。所以“固定过期时间”只是把问题从“锁死”变成了“锁死多久”而已没有根治。这里我当时的教训是不要相信“预估业务执行时间”这种说法。在复杂系统里你永远估不准一条SQL在高峰期会跑多久。后面的方案里动态续期或者说看门狗机制就是专门来解决这个问题的。2.3 误删别人锁你以为你在删锁其实在帮别人解锁加了过期时间之后又冒出一个更隐蔽的问题误删别人的锁。考虑到一个具体的时间线。线程A拿到锁value是“userId1001”。结果A执行太久10秒过期了。线程B立刻抢到锁value也是“userId1001”同一个用户开始下单。这时候A终于跑完进入finally执行delete。它会把自己的key删掉吗不它把B的锁删了。删完之后线程C也进来了也拿到了锁。于是B和C同时执行下单逻辑“一人一单”又失效了。同样的问题在另外一个场景更明显如果两个不同用户操作同一把锁value不同比如线程A写的是“1001”线程B写的是“1002”B拿到锁后A跑来删锁也照样把B的锁删掉。解决方式很简单删除之前先判断value是不是自己的是才删。Java里大概长这样String myValue userId.toString(); String currentValue stringRedisTemplate.opsForValue().get(lockKey); if (myValue.equals(currentValue)) { stringRedisTemplate.delete(lockKey); }这个逻辑本身没问题但它有一个非常致命的隐患判断和删除不是原子的。假设线程A执行get之后、delete之前GC停顿了锁正好过期了线程B抢到锁并写入新value。等A恢复过来它拿着刚才读到的旧value做判断——注意它根本不会再get一次了——虽然旧value已经不等于当前value了但这段代码并不会再验一次直接执行deleteB的锁还是被删了。很多初学者死磕“用value做标识”这个方案却忽略了一个点你要保证“比较并删除”这一整个动作是原子的。你不能先比再删因为中间有时间差也不能先删再比那更离谱。必须有一个机制能把这两步合成一步。这也是为什么后续的Lua脚本方案才是正解。3. 原子性问题的最终解法Lua脚本与Redisson3.1 判断删除必须是一条原子操作Lua脚本是怎么做到的Redis从2.6版本开始支持执行Lua脚本而Lua脚本在Redis里的执行是原子的——脚本运行期间其他客户端的任何命令都不会插进来。这正好解决了“判断value 删除key”两步操作之间的时间差问题。我当时的释放锁脚本长这样if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end在Spring Boot里用DefaultRedisScript配合RedisTemplate执行DefaultRedisScriptLong unlockScript new DefaultRedisScript(); unlockScript.setLocation(new ClassPathResource(unlock.lua)); unlockScript.setResultType(Long.class); Long result stringRedisTemplate.execute( unlockScript, Collections.singletonList(lockKey), userId.toString() );这里有几个容易忽略的细节。第一KEYS和ARGV必须区分开KEYS里的key会被Redis用于集群模式下的slot路由计算如果写成redis.call(get, ARGV[1])在Redis Cluster下可能因为key不在当前节点而报错。第二脚本里不要用redis.call(set, KEYS[1], ARGV[1], NX, EX, ARGV[2])这种把过期时间也放进Lua的写法因为你完全可以直接用Redis原生命令完成加锁没必要增加脚本的复杂度。至于加锁阶段Spring Data Redis的setIfAbsent(key, value, timeout, unit)本身就是原子的SET NX EX不需要额外写脚本。所以完整方案是加锁用原生原子命令释放锁用Lua脚本保证“判断删除”的原子性。我把代码整理一下public boolean tryLock(String key, String value, long timeout, TimeUnit unit) { return Boolean.TRUE.equals( stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit) ); } public void unlock(String key, String value) { stringRedisTemplate.execute( unlockScript, Collections.singletonList(key), value ); }这套手写方案最大的优点是依赖少逻辑透明出问题能一眼看穿。黑马点评项目用这个方案来讲解分布式锁的原理是完全够的。但它也有一些硬伤比如不可重入、过期时间固定、没有自动续期。你真正要在生产环境用建议直接上Redisson但前提是你已经理解了上面这些底层原理。3.2 Redisson的watchdog过期时间终于不用拍脑袋了Redisson是Redis官方推荐的Java客户端它对分布式锁做了大量封装。用它写锁代码极其简洁RLock lock redissonClient.getLock(lock:order: voucherId); boolean isLocked lock.tryLock(1, TimeUnit.SECONDS); if (isLocked) { try { createVoucherOrder(voucherId, userId); } finally { lock.unlock(); } }你注意我上面的代码我故意没有传leaseTime参数。这不是偷懒而是Redisson的一个关键机制如果tryLock只传等待时间、不传租约时间leaseTimeRedisson会启用看门狗Watchdog机制。看门狗的逻辑是这样的默认锁的过期时间是30秒但Redisson会为持有锁的线程启动一个后台定时任务每10秒检查一次锁是否还在持有中。如果锁还在就把过期时间重置为30秒。这样业务没执行完锁就永远不会过期一旦业务执行完或者线程挂了锁也会在最多30秒内自动释放。这是不是完美解决了“固定过期时间”的问题从实用性上讲确实解决了绝大多数场景。但有一点必须清楚看门狗是为“一直持有锁的线程”续期的不是为“没抢到锁的线程”服务的。如果你调了lock.lock(10, TimeUnit.SECONDS)这种带leaseTime的写法Redisson就不会启动看门狗10秒一到锁直接过期。这个语义上的差别很多人用错了都不知道。我看过不少人在项目里这样写lock.lock(30, TimeUnit.SECONDS);然后自我感觉很安全。实际上这等于放弃了看门狗又回到“拍脑袋定过期时间”的老路上。如果你确实需要控制最大持锁时间可以传leaseTime如果没有特殊理由就让看门狗替你做动态续期。3.3 手写锁和Redisson怎么选我最后用了哪种我把两个方案放在一起做个对比方便大家决策对比项手写setnxLuaRedisson学习成本低代码直观中封装较多需理解原理可重入不支持需自行扩展原生支持自动续期不支持需自行实现watchdog自动续期释放锁原子性需Lua脚本内置代码量中等很少生产可靠性可用但需自己处理边界较高社区成熟在黑马点评这个项目里我建议的顺序是先用setnxLua手写一遍把分布式锁的原理彻底吃透然后去读Redisson的源码感受一下官方实现是怎么解决你踩过的那些坑的。项目里最终用哪个取决于你的目标如果是为了面试讲清楚原理手写版就很有说服力如果是真实业务上线直接用Redisson别自己造轮子。不过话说回来Redisson的看门狗虽然解决了很多问题但它不是银弹。接下来我要讲的是分布式锁即便用了Redisson也依然存在的深层坑这些才是面试官真正想听的。4. 分布式锁最绕的三个隐性坑4.1 可重入同一个线程怎么再进一次先问一个问题如果你的下单方法里某个加了分布式锁的A方法内部又调用了同样加锁的B方法比如一个路由方法是AA里面调用了B完成实际下单而A和B都去获取同一把锁会怎样用setnx手写的版本第二个获取锁的请求会发现锁已经存在——而且竟然是自己持有的——但代码不会认账依然返回获取失败。这就是“不可重入”问题。意思是同一个线程不能重复获取同一把锁。本地锁的synchronized和ReentrantLock天然支持可重入。分布式锁要做到可重入得在锁的数据结构上下功夫。Redisson的实现是用Redis的Hash结构key是锁名称field是线程标识value是重入计数。每次重入计数加一每次释放计数减一。计数归零才真正删除key。这个设计其实启发了我锁不一定要存简单的字符串用Hash能表达更多信息。如果你想手写一个可重入的分布式锁用HSETHINCRBYHEXISTSHDEL这套命令组合就能实现核心就是记录“哪个线程”持锁以及“重入了几次”。但我也要提醒一句过度设计是分布式锁实践里最常见的浪费。大部分业务场景锁的嵌套并不频繁为了可重入去额外引入Hash结构的复杂度不一定划算。如果你的业务里真的有A方法调B方法抢同一把锁的情况先把代码结构改好比硬塞一把可重入锁更实在。4.2 业务超时和锁续期不是锁越短越好很多人习惯把锁的过期时间设得很短比如3秒、5秒理由是“反正业务很快”。但我在黑马点评的压测里碰到过一次特别典型的翻车。当时我把锁过期时间设成5秒想着秒杀下单也就几十毫秒5秒绰绰有余。结果缓存击穿瞬间大量请求同时打到数据库数据库连接池被打满下单逻辑里的某条SQL等连接等了6秒多。锁在5秒时过期释放第二个线程进入下单逻辑又等了5秒多这时候第一个线程还没结束。两个线程开始同时扣减库存库存直接变成负数。正确的思路应该是过期时间要大于业务正常耗时的上限而不是平均值。同时如果用了Redisson的watchdog它会自动续期你根本不用操心。如果手写锁你就得自己写一个“续期守护线程”定期去重置过期时间类似看门狗的简化版。伪代码大概是ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { stringRedisTemplate.expire(lockKey, 30, TimeUnit.SECONDS); }, 10, 10, TimeUnit.SECONDS);但这样做麻烦的地方在于你还要考虑续期失败怎么处理、线程池怎么关闭、续期和释放锁的竞态……所以你看手写锁不是不行是你得把各种异常路径都兜住工程量比想象中大得多。这也是为什么生产上我更倾向Redisson的原因不是因为它代码少而是因为它已经把边缘路径处理好了。4.3 主从切换时锁丢了怎么办聊一个更硬核的问题Redis主从架构下锁会丢。假设当前Redis是主从部署客户端A在主节点上写入了锁。主节点还没来得及把这个key同步给从节点主节点挂了。哨兵检测到主节点宕机提升从节点为新的主节点。此时新的主节点上并没有A写入的那个锁key。客户端B去新主节点上抢锁直接成功。于是A和B同时持有锁互斥保护彻底失效。这不是理论上的极端情况而是主从异步复制架构下必然存在的问题。Redisson对此提供了一个方案叫RedLock同时向多个独立的Redis节点申请锁只有超过半数节点申请成功才算拿到锁。听起来很完美但RedLock在实际生产中的争议非常大包括Martin Kleppmann和Redis作者antirez都为此专门写过文章辩论。RedLock的代价是可用性显著下降只要有一个节点不可达或者网络分区导致超过半数的节点无法访问整个锁服务就不可用业务直接停摆。我的观点很明确普通业务项目不要用RedLock主从架构下锁丢失的概率极低而RedLock带来的可用性风险却是实打实的。如果你的业务对锁的可靠性要求已经到了“绝对不允许两个线程同时执行”的级别应该考虑的是用强一致存储比如ZooKeeper的临时顺序节点来实现分布式锁而不是在Redis里死磕。Redis分布式锁适合“大部分时候可靠、偶尔出问题也能接受补偿”的场景黑马点评这种秒杀场景用主从哨兵Redisson默认锁模式就完全够用了。5. 黑马点评里的几个落地细节5.1 key的设计和锁粒度在黑马点评里锁的key是我最早踩坑的地方。一开始我图省事key直接用lock:order把所有订单的抢购全部锁在一把锁上。结果虽然“一人一单”保证了但同时所有用户的订单创建全部串行化——第一个用户下单期间第二个用户只能等着第100个用户排在后面响应时间直接爆炸。正确的做法是把锁粒度细化到“业务对象级别”。既然要锁的是“同一个用户不能重复抢同一张券”那锁的key就应该是“用户ID优惠券ID”的组合String lockKey lock:order: voucherId : userId;这样不同用户抢同一张券用的是不同的锁互相不阻塞同一个用户抢同一张券用的锁肯定一样互斥生效。这个设计被称为“细粒度锁”它把锁的竞争范围从“全局”缩到了“一个用户一张券”并发能力瞬间提升几个数量级。这里有一个非常容易在面试里被追问的点为什么锁不设置成只跟voucherId相关如果你只想保证“每张券的库存扣减安全”那把粒度放到voucherId没问题但黑马点评的“一人一单”要求的是用户维度同一个用户不能重复下单同一张券所以锁里必须带上userId。锁粒度是由业务约束决定的不是拍脑袋定的。5.2 释放锁的位置try-finally不是可选项所有锁操作释放锁必须放在finally里。这是我在代码评审时最常看到的问题之一。有些人写锁只考虑正常流程拿到锁后一顿操作如果中间抛出异常锁就一直不释放直到过期。这在高并发场景下等于人为制造死锁。正确的模板是这样的boolean isLocked lock.tryLock(1, TimeUnit.SECONDS); if (!isLocked) { return Result.fail(系统繁忙请稍后重试); } try { // 核心业务逻辑 return doCreateOrder(userId, voucherId); } finally { lock.unlock(); }注意tryLock失败不等于异常它只是告诉你“锁没抢到”。很多初学者会把tryLock抛出的异常和返回false混为一谈。返回值是false时业务上应该直接提示用户稍后重试而不是走异常处理流程。还有一个细节lock.unlock()不应该放在try块里否则业务逻辑抛出的异常可能中断解锁流程。finally里执行解锁是安全的因为Java保证finally块必然执行。当然lock.unlock()自身也可能抛异常比如当前线程不持有锁时调用会报IllegalMonitorStateException所以你在释放锁之前一定要确保自己确实拿到了锁。5.3 序列化方式和连接池配置黑马点评里默认用的是StringRedisTemplate这个选择在做分布式锁时特别重要。StringRedisTemplate默认使用String序列化器所有key和value都以字符串形式存储不会出现Java序列化后的一堆二进制乱码。如果你换成普通的RedisTemplate默认的JDK序列化会把userId变成一串带类信息的二进制数据不仅Redis里看着一团糟而且get和setIfAbsent之间一旦序列化方式不一致value就永远对不上锁就没法正常工作了。我建议在Spring Boot项目中做分布式锁统一使用StringRedisTemplatekey和value都显式转成字符串。如果你需要在Redis里存对象额外用JSON序列化器就行但锁本身不需要存复杂对象。连接池这块也值得提一嘴。高并发抢锁时每个线程都要从连接池获取连接。如果你用的是LettuceSpring Boot 2.x默认注意它的共享连接模式在特定场景下会有并发问题老项目里的Jedis则每个线程独占连接连接池耗尽会直接抛异常。我的建议是压测时关注一下MaxTotal和MaxWaitMillis参数不要等到线上报Cannot get Jedis connection再排查。我实际在压测时发现连接池大小默认8其实在秒杀场景下是不够的。把maxTotal调到50maxWaitMillis设为1000ms才勉强够用。这个数值不绝对但可以给你一个参考方向。最后分享一个个人习惯我练分布式锁有一段时间了慢慢地养成一个习惯每次拿到一个锁相关的方案先问自己三个问题——这个锁能保证互斥吗锁的释放路径覆盖所有异常分支吗锁的粒度跟业务约束匹配吗这三个问题过一遍很多看似高深的问题就藏不住了。如果你正在做黑马点评或者类似的秒杀项目可以把手写的setnxLua实现和Redisson各做一遍然后对比两个方案的释放锁逻辑。你会明显感觉到自己手写时踩过的坑正好就是Redisson帮你填平的坑。这个过程走完之后你对Redis的理解会有一个质的提升。说得再直白一点分布式锁的价值从来不在于那几行代码而在于你能不能在出问题时迅速判断出是哪一环出了错。把这个能力练出来比记住一百个锁方案都有用。
返回列表