Redis分布式锁与缓存穿透击穿雪崩实战解析
先聊个现状吧。现在只要一聊高并发面试官十有八九会把你往Redis 分布式锁和缓存穿透/击穿/雪崩这两个话题上带。这俩东西之所以被问得烂大街不是因为它简单而是因为它是服务端后端工程里最典型的看起来会、一落地就翻车的坑位。你背过红锁算法、看过Redisson的源码解析、甚至能在简历上写精通缓存治理但真到了线上锁突然失效、缓存击穿把数据库打挂、主从不一致导致数据错乱这些问题一出现你之前背的那些理论还真不一定能救命。这篇文章不打算给你堆砌一堆概念名词。我尽量从这个方案为什么要这么设计的角度把分布式锁的核心细节和缓存异常三大难题拆开讲透。适合刚接手高并发项目的后端开发也适合那些想系统梳理Redis实战经验的求职者。全文以 Java 生态为例但思路是通用的。1. 分布式锁的场景、设计目标与选型1.1 什么时候才需要分布式锁很多人一提分布式锁就想到秒杀其实分布式锁最早的典型场景是多个服务节点操作同一个共享资源。举个最简单的例子订单超时关单任务。你部署了三台应用服务器定时任务在每个节点上都会触发。如果没有锁三台机器同一时刻都在扫订单表、都在改订单状态轻则重复处理重则把已支付的订单也关掉。单机JVM的synchronized在这时毫无意义因为三台机器各有一把锁互相看不见。这时候你需要的是一个所有节点都认的第三方协调者让它们去同一个地方抢锁这就是分布式锁的原始诉求。类似的场景还有分布式定时任务只在集群中一台执行接口幂等性控制防止重复提交库存扣减防止超卖分布式事务中的资源锁定多个消费者争抢同一个资源如MQ消息重复消费后的兜底判断标准很简单如果你的系统是单机部署用本地锁就够了一旦扩容到多实例并且多个实例会并发修改同一个共享资源才需要考虑分布式锁。1.2 设计一把分布式锁绕不开的四件事我做了多年分布式系统踩过的坑多到数不清。后来我总结了一把可靠的分布式锁必须满足的四个条件第一互斥性。任意时刻只能有一个客户端持有锁。这个听起来是废话但实现时最容易出问题——比如先判断再删除不是原子的会导致锁被其他线程误删这就破坏了互斥性的前提。第二安全性。锁必须设置过期时间防止持锁方宕机后锁永久不释放形成死锁。但过期时间设置得不好又会带来新问题业务执行时间超过锁过期时间锁提前释放其他线程进来了互斥性再次被破坏。这一环上的细节最多。第三可用性。加锁、解锁的操作本身要简单高效不能因为锁服务不可用就导致整个业务不可用。这也是Redis能做分布式锁的天然优势——它的读写性能远超数据库而且自带过期机制。第四容错性。锁服务出现网络抖动、主从切换时方案仍然要保证锁不出现大的误判。这个要求最苛刻也是Redisson、RedLock这些方案的争论焦点。1.3 为什么是Redis实现分布式锁你可以用MySQL乐观锁/悲观锁、ZooKeeper临时顺序节点、etcd租约Revision也可以用Redis。MySQL方案最大的问题是性能频繁的锁表、行锁竞争会让数据库很难受而且数据库本身容易成为瓶颈。ZooKeeper和etcd方案强一致、锁安全但复杂度高、性能不如Redis而且需要额外维护一套集群。Redis做分布式锁的核心优势有三个一是性能极高单实例QPS能到10万级别锁的获取和释放都是毫秒级二是数据有TTL机制天然适合做锁的自动过期三是Redis是绝大多数后端团队的基础设施不需要额外引入新组件。Redis的劣势也存在它的主从复制是异步的极端情况下Master宕机、数据未同步到Slave可能丢失锁信息。这事我放到后面集群部分详细说。2. 分布式锁核心实现从入门到落地2.1 加锁的命令演进网上很多老教程还在教SETNXEXPIRE的组合这是典型的反面教材。# 错误示例 SETNX lock_key lock_value # 设置成功返回1失败返回0 EXPIRE lock_key 10 # 再设置过期时间问题在于这两条命令不是原子的。如果SETNX执行成功了程序突然宕机或者网络抖动了EXPIRE没执行到这把锁就永远不会过期直接死锁。正确的写法是用Redis 2.6.12之后提供的SET命令扩展参数一条命令完成加锁和设置过期时间SET lock_key unique_value NX EX 10其中NX键不存在时才设置保证了互斥性EX 10键的过期时间为10秒防止死锁unique_value客户端唯一标识用来保证只能释放自己加的锁我用Java的RedisTemplate写一段真实项目中用过的工具类代码public boolean tryLock(String key, String value, long timeout, TimeUnit unit) { Boolean result redisTemplate.opsForValue() .setIfAbsent(key, value, timeout, unit); return Boolean.TRUE.equals(result); }setIfAbsent底层就是SET key value NX EX一个原子操作。注意一个细节判断加锁结果时不能用result直接判空因为在某些客户端里返回null表示失败需要用Boolean.TRUE.equals()兜底否则可能因为拆箱空指针。2.2 解锁为什么必须用Lua脚本加锁容易解锁才是最容易翻车的地方。很多人第一次写解锁代码是这样的if (redisTemplate.opsForValue().get(key).equals(value)) { redisTemplate.delete(key); }这个逻辑全部错在判断和删除不是原子的。假设线程A持锁执行到这里时锁的过期时间刚好到了锁自动释放。此时线程B抢到了锁写入了一个新的value。然后线程A继续执行delete(key)——它删掉的是B的锁。这就是经典的锁误删问题。正确做法是使用Lua脚本把判断value是否匹配和删除key两步合并成一个原子操作if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end对应Java代码private static final String UNLOCK_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public boolean unlock(String key, String value) { Long result redisTemplate.execute( new DefaultRedisScript(UNLOCK_SCRIPT, Long.class), Collections.singletonList(key), value ); return Long.valueOf(1).equals(result); }这个方案里value必须是全局唯一的通常用UUID或者业务ID 线程ID拼出来的字符串。每次加锁都生成一个新的这样才能保证锁的持有者身份无法被伪造。2.3 过期时间怎么定以及续期机制怎么实现锁的过期时间是个两难问题设短了业务没执行完锁就自动释放其他线程趁虚而入设长了持锁方真宕机了其他线程要傻等很久。设多长合适我的经验是先估算业务的最长执行时间然后在此基础上加一个合理的缓冲通常设为业务耗时的3到5倍。比如业务正常100ms完成锁的超时时间设置500ms到1s就够。但线上业务经常有抖动最稳妥的做法是配合自动续期机制。自动续期也就是Redisson里的watchdog看门狗机制加锁成功后启动一个后台定时任务每隔锁过期时间的1/3去刷新一次过期时间。只要业务线程还活着锁就不会提前过期。业务跑完了主动释放锁同时关掉定时任务。自己实现一个简易的续期逻辑也不难用一个ScheduledExecutorService即可ScheduledExecutorService scheduler Executors.newScheduledThreadPool(2); public void lockWithRenewal(String key, String value, long expireMillis) { redisTemplate.opsForValue().setIfAbsent(key, value, expireMillis, TimeUnit.MILLISECONDS); scheduler.scheduleAtFixedRate(() - { // 续期逻辑判断value仍然是自己就重新设置过期时间 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(expire, KEYS[1], ARGV[2]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(key), value, String.valueOf(expireMillis / 1000)); }, expireMillis / 3, expireMillis / 3, TimeUnit.MILLISECONDS); }这里有两个坑提醒你一是续期操作必须判断value还是不是自己的否则锁早就被别的线程拿走了你还在傻傻地续期会把别人的锁一直续下去二是续期的定时任务必须和业务线程生命周期绑定业务结束、锁被手动释放时一定要shutdown掉任务否则定时任务会空转甚至引发内存泄漏。2.4 可重入锁、锁粒度和锁 key 的划分所谓可重入就是同一个线程在持锁期间能重复加同一把锁而不会阻塞自己。比如一个方法加锁后内部又调用了另一个加锁方法。Redis分布式锁天生不区分线程所以需要自己处理可重入。常见做法是用一个ThreadLocal维护当前线程的加锁次数加锁成功一次1释放一次-1减到0才真正删key。Redisson内部也是这么干的。锁粒度的设计我多说一句。很多人在一个方法上直接整一个大锁比如下单接口全链路加锁结果并发量被限制在个位数。正确的做法是按业务维度拆锁比如秒杀场景锁stock_1001商品ID而不是锁order_create。锁的粒度越细系统能支撑的并发就越高但粒度太细也会带来锁数量膨胀的问题。实践中我通常建议先按资源维度分锁再按哈希分片把热点锁拆散到多个key上。2.5 Redis集群模式下锁会丢这问题怎么破这是分布式锁方案里最容易被忽略但又最致命的一个漏洞。Redis主从集群是异步复制的。假设线程A从Master节点获取了锁Master还没把数据同步给SlaveMaster就宕机了Redis触发主从切换Slave升级为新Master但新Master里没有A的锁数据。此时线程B来加锁一把锁被两个线程同时持有分布式锁的安全性被彻底破坏。解决这个问题的经典方案是Redis官方提出的RedLock算法向多个独立的Redis节点依次申请锁只有当超过半数节点加锁成功时才认为整体加锁成功。加锁和释放都发往所有节点。这样即使单个Master宕机只要多数节点还活着锁就不会丢失。但RedLock也一直有争议不少分布式系统专家尤其是Martin Kleppmann从理论角度指出它仍然存在时间跳跃、GC暂停等问题无法实现绝对的安全。我的务实建议是业务对锁丢失容忍度极低的场景比如资金操作别用Redis直接用etcd或ZooKeeper。一般互联网业务秒杀、任务调度Redisson的看门狗模式在单Master场景下是够用的出问题的概率极低。如果你非要上RedLock记住客户端必须有容错降级即RedLock失败时宁可让业务失败也不要无锁执行。3. 缓存异常三大难题穿透、击穿、雪崩3.1 缓存穿透查了个不存在的数据缓存穿透指的是查询一个数据库和缓存里都不存在的数据。因为每次查询在缓存里都拿不到于是每次都要去数据库查数据库压力陡增甚至被打垮。典型场景是恶意攻击者故意用大量不存在的ID比如负数ID、大随机数刷接口。每次请求都穿透Redis打到MySQL上缓存形同虚设。三种主流应对手段第一种是参数合法性校验。最简单但只能拦截明显不合法的请求。比如查询订单ID必须为正整数负数、小数、超大值直接返回参数错误不往缓存和数据库走。第二种是缓存空值。查询结果为空时把一个空值null或者特定的空标记写入缓存并设置一个较短的过期时间比如30~60秒。这样后续同样的查询直接命中空值不会打到数据库。代价是如果真有大量不存在的KeyRedis里会堆积一堆空值缓存占用内存。我的做法是空值的TTL设短另外限制空值key的个数防止被恶意刷爆。第三种是布隆过滤器。把业务中所有合法的ID比如商品ID、订单ID预先把hash值放入布隆过滤器。查询前先用布隆过滤器判断ID是否存在如果判断不存在直接返回根本不会去查Redis和数据库。布隆过滤器有一个特性判断不存在是准确的判断存在有一定概率误判。所以它适合拦截那些确定不存在的查询误判的那一小部分流量还是会走到数据库但数据库压力已经大幅下降可以接受。3.2 缓存击穿热点Key过期的那一瞬间缓存击穿和穿透很相似但本质完全不同。穿透是查不存在的数据击穿是某个热点数据过期后大量并发请求同时打到数据库。举个例子某爆款商品详情页被缓存了缓存过期时间是1小时。到期的那一刻如果同时有5000个用户请求这个商品详情所有请求发现缓存里没有数据于是5000个请求一起冲进数据库去查询。数据库瞬间被打爆。解决缓存击穿的核心思想是同一时间只放一个请求去数据库重建缓存其他请求等待或者快速降级。主流的方案有四种方案一使用分布式锁只让一个线程去查数据库、重建缓存其他线程等待缓存重建完成后直接读缓存。缺点是多了一次锁的获取和释放的开销以及等待时间。方案二逻辑过期。Redis里不设置物理过期时间而是把过期时间存在value里。查询时发现逻辑过期了先返回旧的缓存数据哪怕数据有点旧同时异步去刷新缓存。这样用户体验不受影响数据库压力被串行化。这个方案对数据一致性要求不高的场景特别实用。方案三本地缓存做兜底。每个应用节点放一个短TTL的本地缓存比如Caffeine5秒过期如果Redis缓存失效了先查本地缓存本地也没有再去DB查询然后刷新两级缓存。这能显著减少对DB的瞬时冲击。方案四热点Key永不过期。对已知的超级热点干脆不设置过期时间并在后台定时刷新。缺点是最热的数据如果设置了永不过期一旦数据库内容更新不及时会有短期的数据不一致。我实际项目里最常用的组合是方案一分布式锁 方案三本地缓存兜底后面在4.4小节我会贴一个完整可用的实现代码。3.3 缓存雪崩大面积Key同时失效缓存雪崩比击穿更严重。击穿是一个热点Key过期打挂数据库雪崩是大量Key同时过期或者Redis实例宕机导致大量请求直接穿透到数据库。大量Key同时过期的场景很常见你给一批商品缓存设置的TTL都是1小时当它们同时到期时如果系统流量本身就高数据库会受到瞬间的海量流量冲击。Redis宕机导致的雪崩更棘手Redis不可用了所有请求全部打数据库几分钟内数据库就会被拖垮。大量Key同时过期的应对方案很成熟TTL尽量随机化设置缓存过期时间时在基础时间上增加一个随机值。int baseTtl 3600; int randomTtl baseTtl new Random().nextInt(600); // 1小时 0到10分钟随机这样同一个批次写入的Key过期时间被打散就不会在同一秒集体消失。多级缓存在Redis前再加一层本地缓存即使Redis整个挂掉本地缓存还能扛住一部分流量。服务降级限流Redis不可用时针对非核心接口直接降级返回固定的兜底数据或者用Sentinel/Hystrix对数据库访问做限流熔断保住DB不被打死。Redis宕机的应对Redis Sentinel哨兵模式自动故障切换或者Redis Cluster集群模式是基础保障。但你要清醒地认识到故障切换也需要时间秒级在切换的几十秒里服务可能还是会降级。所以资深的架构师会在Redis之上再加一层本地缓存兜底尽量做到Redis挂了业务还能转一会儿。4. 缓存与数据库的一致性治理4.1 为什么会出现缓存不一致只要是用了缓存就逃不掉缓存和数据库数据不一致的问题。这里最典型的是Cache Aside旁路缓存模式读请求先查缓存不中则查库再回写缓存写请求先更新数据库然后删除缓存。大多数人第一次接触Cache Aside时都会有一个疑问为什么更新是删缓存而不是更新缓存原因有两个。一是更新缓存的操作成本高你更新一次数据库可能需要把该记录的多个缓存维度列表页、详情页、统计页全部更新一遍删除缓存只需要删一个Key。二是并发环境下更新缓存容易产生脏数据两个线程并发更新数据库先更新数据库的线程A可能因为网络慢反而后写缓存最后缓存里留下的是旧数据数据不一致。所以删缓存是更稳妥的策略宁可让缓存缺失后下次懒加载也不要冒写脏数据的风险。4.2 删除缓存也会出错延迟双删方案Cache Aside模式也有个经典漏洞出在更新数据库和删除缓存两步之间。假设线程A先更新了数据库还没删缓存。线程B此时读到了缓存里的旧数据并返回给用户。用户看到的是过期数据。更复杂的版本线程A更新数据库后删缓存但另一线程C在A删缓存之前又把自己刚查出的旧数据写回了缓存导致A的删除操作失效。业界最常用的务实方案是延迟双删。步骤是这样的先删除缓存再更新数据库休眠一小段时间比如500毫秒到1秒再次删除缓存第一次删除缓存是防止A更新数据库之前B读到旧缓存。休眠一段时间是为了留出时间让读缓存未命中后查询数据库写缓存的并发线程完成操作。第二次删除就是把这一步产生的脏缓存再删一次。延迟双删有一个关键细节休眠时间必须大于慢查询写缓存的时间。如果你不能确定我的经验值是休眠1秒左右或者用消息队列异步延迟删除避免同步休眠对接口耗时的影响。还有人会问如果第二次删除失败了怎么办我的工程实践是增加一个重试补偿机制把删除失败的任务发给MQ由独立的消费者不断重试删除。或者用Canal监听MySQL的binlog在数据库数据变化时自动清理对应的缓存这种方式最无侵入。4.3 缓存一致性选型没有银弹只有权衡聊到缓存一致性必须承认一个事实在分布式系统里强一致是有代价的大多数业务不值得付出那个代价。一致性要求高的场景比如用户余额、订单状态通常的做法是不依赖缓存读这类数据直接读数据库或者引入分布式事务/可靠消息保证数据库更新和缓存删除的最终一致性一致性要求中等的场景商品详情、库存数量用延迟双删 MQ重试就能覆盖大部分需求。一致性要求低的场景文章阅读数、点赞数、热搜榜单更新数据库的同时异步更新缓存允许出现几秒甚至几分钟的短暂不一致完全不影响业务体验。我见过很多团队在一致性上较真较到死写了一大堆复杂的同步代码最后不但没做好反而因为代码复杂导致各种线上故障。我的建议是先明确业务能容忍的一致延迟是多少再选择成本最低的方案。缓存本来就是用来牺牲一点一致性换取性能的想清楚这个本质很多纠结就迎刃而解。4.4 本地缓存 Redis 分布式锁一个完整示例最后我把前面提到的击穿、雪崩、一致性方案组合起来写一个实际项目中可以直接改改用的代码骨架。思路是查询接口先查本地缓存CaffeineTTL只设10秒再查Redis缓存TTL随机化都没有再通过分布式锁控住并发只让一个线程去加载数据库并重建缓存。public class GoodsService { Autowired private StringRedisTemplate redisTemplate; private final CacheString, String localCache Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.SECONDS) .maximumSize(10000) .build(); public Goods getGoods(String goodsId) { // 1. 本地缓存 String localValue localCache.getIfPresent(goodsId); if (localValue ! null) { return JSON.parseObject(localValue, Goods.class); } // 2. Redis缓存 String redisKey goods:detail: goodsId; String redisValue redisTemplate.opsForValue().get(redisKey); if (redisValue ! null) { localCache.put(goodsId, redisValue); return JSON.parseObject(redisValue, Goods.class); } // 3. 分布式锁——只放一个线程去查DB String lockKey lock:goods:detail: goodsId; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 双重检查可能上一个持锁线程已经重建了缓存 redisValue redisTemplate.opsForValue().get(redisKey); if (redisValue ! null) { localCache.put(goodsId, redisValue); return JSON.parseObject(redisValue, Goods.class); } // 查询数据库 Goods goods goodsMapper.selectById(goodsId); String goodsJson JSON.toJSONString(goods); // 随机TTL600 随机0~300秒防止缓存雪崩 int ttl 600 ThreadLocalRandom.current().nextInt(300); redisTemplate.opsForValue().set(redisKey, goodsJson, ttl, TimeUnit.SECONDS); localCache.put(goodsId, goodsJson); return goods; } finally { unlock(lockKey, requestId); // Lua脚本释放锁 } } // 4. 拿不到锁短暂自旋后重查缓存避免直接打DB try { Thread.sleep(50); } catch (InterruptedException ignored) {} String retryValue redisTemplate.opsForValue().get(redisKey); if (retryValue ! null) { localCache.put(goodsId, retryValue); return JSON.parseObject(retryValue, Goods.class); } // 5. 兜底锁没抢到缓存还没建立直接返回旧数据/默认值降级 return loadFallback(goodsId); } }这套代码并不是完美的但它在防击穿和防雪崩上做到了工程上够用的程度本地缓存兜底、Redis随机TTL、分布式锁控住回源并发、获取锁失败时自旋重试最后降级。实际落地时根据你的业务调整降级策略即可。5. 常见问题排查与实战经验总结5.1 分布式锁高频疑难杂症获取锁一直失败接口全是等待超时。先排查Redis连接池的配置常见原因是连接池太小、业务线程太多抢不到连接。此时调大maxTotal和maxWaitMillis通常能缓解。锁释放了但业务还在执行。这基本是过期时间设置不合理导致的。要么设置足够长的超时时间3~5倍业务耗时要么用看门狗机制续期。线上排查时可以在加锁时记录业务开始时间在释放时检查耗时是否接近锁过期时间并打WARN日志。Redis连接池里出现了大量空闲连接峰值。原因是业务代码频繁加锁/释放锁而释放锁的Lua脚本执行失败后没有重试连接一直不被归还。排查方法是看错误日志里是否有ERR或者SocketTimeout异常再检查解锁代码是否放在finally块里。锁误删导致并发数据错乱。线上出现一次就足够让人头大。的根本原因就是解锁时没有校验value或者判断和删除没有原子性。我强烈建议直接用前面写的Lua脚本方案不要自己写getdel。集群模式下锁同时被两个线程拿到。这类问题要从全局看先确认服务节点时钟是否一致NTP同步再确认锁超时时间是否大于GC停顿时间和网络耗时最后评估是否需要引入RedLock或etcd。说实话如果业务真的频繁遇到这个问题你们可能压根用错了工具。5.2 缓存异常的定位方法缓存穿透、击穿、雪崩初看症状类似都是数据库压力突然飙升但定位方法各不相同。我习惯从三个指标切入第一Redis命中率曲线。如果原本命中率在95%以上突然掉到50%以下同时DB压力上升很可能是发生了穿透或者大量缓存集中失效雪崩。打开Redis的INFO stats看keyspace_hits和keyspace_misses计算命中率曲线再结合时间点判断。第二异常请求的来源和特征。如果QPS中夹杂着大量同一个业务的查询且key都集中在某几个ID段大概率是穿透恶意攻击或存在大量无效参数。如果是多个业务线的key同时失效大概率是雪崩TTL设计有问题。如果只有某一个热点key失效后DB被打爆大概率是击穿。第三Redis内存与淘汰策略。如果大量无意义key被写入Redis比如空值缓存策略没限流可能导致内存不够触发volatile-lru/allkeys-lru淘汰把很多正常key也淘汰掉引发连环雪崩。日常要监控used_memory和evicted_keys两个指标设置合理的告警线。5.3 高频面试题速查清单不说废话直接把有价值的考点列出来。这些不只是面试有用也是日常设计方案的思维框架分布式锁怎么实现为什么不能用SETNXEXPIRE锁误删怎么解决为什么要用Lua脚本可重入分布式锁怎么实现锁的过期时间怎么定看门狗机制的原理和实现细节RedLock解决了什么问题它有哪些争议缓存穿透、击穿、雪崩分别是什么本质区别是什么布隆过滤器为什么能解决穿透精确度受什么影响延迟双删的触发时机和休眠时间怎么定第二次删除失败怎么办缓存一致性为什么难你们项目最终选择了哪种方案Redis集群主从切换为什么会导致锁丢失如何避免最后说一点个人体会。分布式锁和缓存治理这种东西网上资料非常多但真正值钱的是那些你不踩坑就不会知道的细节一个Lua脚本的原子性、一个休眠时间的估算、一个随机的TTL种子。设计一个方案不难难的是在极端场景下它能不能扛住。希望这篇文章能帮你少走点弯路尤其是那些坑别再踩了。