
做后端这么多年被问得最多的问题之一就是缓存击穿。面试时候背概念容易真正上线以后热点商品key失效的一瞬间几千个请求同时穿过Redis打到数据库连接池被打满慢查询把CPU拖到告警这套流程我完整经历过一次从那以后就不敢再轻视“缓存击穿”这个老三样问题了。今天要聊的“双重判定锁”就是在Redis缓存失效的瞬间用一把分布式锁把并发重建缓存的请求串行化同时通过两次缓存判空避免无谓的加锁等待属于我目前用过最实用的缓存击穿治理方案。这篇文章会从问题本质、设计思路、代码落地到踩坑记录完整拆解一遍适合正在做Spring Boot Redis的Java后端同学也适合架构设计阶段想给缓存层加固的团队参考。1. 缓存击穿问题的本质与双重判定锁的位置1.1 缓存击穿和穿透、雪崩的边界很多人把缓存穿透、击穿、雪崩三个概念混在一起其实它们对应完全不同的故障模式。穿透是指请求的数据在数据库里根本不存在比如恶意请求一个不存在的用户ID缓存查不到数据库也查不到导致每次请求都打到存储层。雪崩是指大量key在同一时间段集中过期或者Redis实例整体不可用流量像崩塌一样涌向后端。而击穿是其中最“精准”的一种只有一个热点key平时Redis能扛住绝大多数流量可一旦这个key过期所有访问这个key的请求会在同一瞬间绕过缓存直连数据库。这个区别很重要因为解决方案完全不同。穿透要靠布隆过滤器、空值缓存或者参数校验来挡雪崩要靠过期时间加随机抖动、多级缓存或Redis高可用来防而击穿的核心矛盾是“单点热点key失效后的瞬时并发”。双重判定锁就是专门针对击穿设计的它不解决数据不存在的问题也不解决整个Redis集群故障的问题它解决的是“缓存重建过程需要被并发控制”的问题。从故障时间线来看击穿的危害更隐蔽。雪崩和穿透通常流量大的时候立刻报警击穿却可能只发生在某个热点key过期的几十毫秒内等数据库连接池被打满时你看到的现象可能是各种超时而不一定是缓存异常。所以排查的时候先分清是哪一类问题否则一边加锁一边又去做穿透拦截方向就偏了。1.2 为什么“缓存重建”是并发灾难的中心缓存失效瞬间为什么危险根源在于“重建缓存”是一个有成本的操作。假设一个商品详情页的缓存key是product:detail:123过期时间为30分钟。正常时QPS 5000Redis平均响应0.5毫秒数据库零压力。当key过期后并发的5000个请求全部缓存未命中如果代码逻辑是“未命中就去数据库查”那么数据库瞬间就要承接5000个查询。一个查询哪怕只耗时20毫秒数据库连接池只有50个连接这一波流量会直接打满连接池后续请求全部排队形成线程阻塞继而拖垮整个应用。“缓存重建”这个动作在代码里就是一行读库和一行写缓存但在高并发场景下它的成本被并发数放大了。你优化的核心不是让数据库查询本身更快而是让同一时刻只有极少数线程真正去执行重建。双重判定锁的思路就是让第一个发现缓存缺失的线程去重建其他线程要么等待要么直接返回降级结果而不是每个人都去查一次数据库。你可以把缓存想象成一张答案纸数据库是老师。全班50个人同时举手问同一个问题如果老师挨个回答50遍那是灾难。双重判定锁做的事是让第一个举手的人问完以后把答案抄在黑板上其他人看到黑板上有答案就不再提问了。这个类比虽然简单但精准解释了为什么要给“查库写缓存”这段逻辑加锁而不是给整个业务方法加锁。1.3 双重判定锁解决什么不解决什么先摆结论双重判定锁解决的是“热点key失效瞬间的重复重建问题”。它通过两轮缓存查询来减少锁竞争通过一轮分布式锁来控制真正的重建过程。实际落地后你能看到两个明显变化一是数据库在缓存失效瞬间的压力从几千QPS降到个位数二是业务响应时间不会因为缓存重建而出现大面积毛刺。但它不是银弹。如果缓存穿透和击穿同时发生比如恶意请求大量不存在的key双重判定锁会因为每个key都要重建空结果而显得很低效这时候需要配合空值缓存或布隆过滤器。如果是大量key同时过期导致雪崩单纯靠一把锁控制单个key是不够的需要把过期时间打散。如果Redis自己挂了双重判定锁反而会成为新的风险点因为获取锁的Redis操作也会超时此时必须设置合理的获取锁超时时间并且有降级开关。理解了它的边界再用它就不会出大问题。下面进入正题拆解双重判定锁的设计细节。2. 双重判定锁的设计拆解两次判断、一次加锁2.1 第一层判定快速路径的意义双重判定锁的“双重判定”第一重发生在加锁之前。正常的缓存读取逻辑是先GET缓存key如果命中直接返回。如果未命中进入加锁重建逻辑。这层判定看起来平平无奇但它的意义在于让绝大多数请求走快速路径完全不触碰分布式锁。为什么这么设计因为在Redis正常工作的前提下GET操作的耗时尚不足0.5毫秒而获取一把分布式锁至少需要一次网络RTT加上锁竞争时的等待时间如果每个请求都先去抢锁再查缓存等于把原本O(1)的缓存访问强制升级成了有锁操作性能会明显下降。实际操作中我见过有些人把锁加在整个方法外层理由是“反正缓存没命中才需要锁”但这样会让所有请求包括那些本来缓存命中的请求都先进锁队列里走一圈。不是说不对而是没必要。双重判定锁的优势就在于它把“读缓存”和“抢锁”拆成两步一快一慢只在慢路径上付出锁的代价。2.2 加锁区间为什么必须锁“重建缓存”而不是“查库”锁的加锁区间决定并发控制的效果。第一次设计双重判定锁时我犯过一个典型错误把锁加在了“整个Controller方法”上结果缓存命中的请求也被串行化QPS直接掉了一个量级。正确做法是锁的范围应该精确覆盖“从发现缓存缺失到重建完成并写回缓存”这一小段临界区而不应该包括缓存命中的返回路径。在代码层面加锁区间内的逻辑通常是再次查询缓存确认依然未命中。查询数据库。将结果写入缓存。释放锁。为什么这里还要再查一次缓存因为从第一次缓存未命中到真正获取锁之间存在一个时间窗口。在这个窗口内可能已经有另一个线程完成了重建并写入了缓存。如果获取锁之后不做第二次判断直接查库那锁就白加了数据库还是会被同一份热点数据打穿。这个“二次判断”是双重判定锁的灵魂也是名字里“双重判定”的由来。那为什么不能只锁“查库”而不锁“写缓存”因为写缓存也是重建的一部分。如果A线程查完库还没写缓存时释放了锁B线程获取锁后查库又重复了一次查询。只有把“查库写缓存”作为一个整体锁住才能保证重建动作在任意时刻最多只有一个线程在执行。2.3 第二层判定并发窗口期的兜底第二层判定通常写在获取锁成功之后、查询数据库之前。它解决的是两个线程同时发现缓存失效的场景线程A先拿到锁线程B在尝试获取锁时失败被阻塞或进入自旋。此时A完成了数据库查询和缓存写入然后释放锁。B获取锁成功如果B不重新检查缓存它会理所当然地认为“我拿到锁了缓存肯定还没建”再次执行一次数据库查询这次查询就完全是多余的。有了第二层判定B拿到锁后会先看一眼缓存发现A已经写好了直接返回结果数据库压力从两次变成一次。这个优化在并发量极小的时候看不出来但在热点key失效瞬间能省下几百上千次无效查询。这里需要注意一个细节第二层判定拿到缓存后返回的数据类型和直接命中缓存的返回类型必须一致不要一个返回包装对象一个返回裸数据。否则一旦走慢路径调用方会因为类型问题踩坑排查起来还很费劲。2.4 锁粒度与Redis实现选择锁的粒度由缓存key决定。假设你的缓存key是goods:123那锁key最好是lock:goods:123这样每个商品都有自己的锁互不影响。千万不要做成一个全局锁比如lock:hot_key否则一个热点商品的重建过程会阻塞所有其他商品的缓存重建这把锁就成了全局瓶颈。实现方案上常见的有这么几种SETNX 过期时间最基础几乎所有Redis客户端都支持。Redisson的RLock自带看门狗续期解决锁过期问题。Lua脚本实现原子操作用于释放锁时校验避免误删。选型逻辑很简单如果只是缓存击穿场景并发量不是特别极端用Redis原生命令就够了如果你的系统已经有Redisson依赖直接用RLock更省心。核心不在于用什么库而在于锁的语义是否正确也就是“互斥”“超时”“可重入”这三件事。3. 基于Redis的落地实现代码示例与关键参数3.1 基础版SETNX 过期时间 双重检查我先给一个最基础但完整的示例基于 Spring Boot 和 Spring Data Redis。前提是你已经引入了spring-boot-starter-data-redis并且注入了StringRedisTemplate。Service public class CacheService { private final StringRedisTemplate redisTemplate; public CacheService(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public String getOrRebuild(String key, long expireSeconds, SupplierString dbLoader) { // 第一重判断快速路径 String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return cached; } String lockKey lock: key; String token UUID.randomUUID().toString(); Duration lockTimeout Duration.ofMillis(300); boolean locked Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent(lockKey, token, lockTimeout)); if (!locked) { // 获取锁失败等待一小段时间后重读缓存也可以用自旋 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } String retryCached redisTemplate.opsForValue().get(key); if (retryCached ! null) { return retryCached; } throw new RuntimeException(缓存重建中请稍后重试); } try { // 第二重判断获取锁后再次检查缓存 String doubleCheck redisTemplate.opsForValue().get(key); if (doubleCheck ! null) { return doubleCheck; } String value dbLoader.get(); redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(expireSeconds)); return value; } finally { // 释放锁这里要保证原子性见3.2 releaseLock(lockKey, token); } } }这个版本可以直接跑但我在代码里也留了三个值得优化的点获取锁失败的策略太粗暴、释放锁必须用Lua脚本、锁超时时间需要根据重建耗时评估。下面逐个展开。3.2 为什么不能用setIfAbsent后就立刻返回新手容易犯的错误是setIfAbsent返回true之后不管后面的业务执行情况最后直接delete(lockKey)。这在正常情况下没问题但一旦遇到锁超时就会产生严重事故。场景是这样的线程A获取锁锁超时时间为300毫秒。A查询数据库比较慢花了500毫秒。第300毫秒时锁自动过期了线程B拿到锁开始执行自己的查询。第500毫秒时A终于查完它把结果写入缓存然后执行delete(lockKey)此时它删掉的是B的锁。这意味着B还没执行完锁已经不存在了线程C也能拿到锁三个线程同时重建缓存整个互斥就失效了。解决这个问题需要在释放锁时校验value。我们可以在加锁时把value设置为一个唯一的token释放锁时先比较当前锁的value是否等于自己的token相等才删除。关键是要保证“比较删除”是原子操作所以要用Lua脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end对应的Java代码private static final DefaultRedisScriptLong UNLOCK_SCRIPT new DefaultRedisScript( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end, Long.class); private void releaseLock(String lockKey, String token) { redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), token); }这一点非常重要。没有token校验的锁在高并发下形同虚设。3.3 参数设计锁超时时间、缓存过期时间、等待重试策略参数不能拍脑袋定要结合自己的业务链路去算。锁超时时间至少大于“一次缓存重建”的最慢耗时。如果你是查MySQL正常耗时20毫秒慢查询100毫秒那锁超时设300毫秒是合理的。如果重建逻辑里还有远程RPC调用耗时会到秒级锁超时就要设到3到5秒。判断标准是看P99耗时不要看平均值。平均值50毫秒P99可能已经500毫秒了锁超时300毫秒必然触发上述误删问题。实践经验是锁超时设置为重建耗时的3到5倍甚至更长。如果担心持锁线程卡死导致阻塞可以配合Redisson的看门狗自动续期。缓存过期时间不要让所有key都用同一个固定值。比如统一设30分钟那同一批数据会在同一时刻过期人为制造雪崩。建议设为baseTime new Random().nextInt(300)比如30分钟基础值上加0到300秒的随机量让过期时间散开。获取锁失败的等待策略常见有三种自旋重试循环获取锁每次间隔30到50毫秒最多尝试3到5次。适合重建耗时短、锁竞争不激烈的场景。直接返回旧值如果业务允许短暂的脏读可以在缓存里保留一个逻辑过期时间过期后仍然返回旧值同时异步重建。这个方案最优雅但实现复杂度高。快速失败并降级获取锁失败后直接抛异常或返回默认配置适合对一致性要求高、不能返回脏数据的场景。自旋重试的代码可以这样优化for (int i 0; i 3; i) { String value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } Thread.sleep(30); } // 3次仍然没有拿到锁这里按业务降级3.4 高效版本Redisson 信号量/分布式锁的取舍如果项目里已经用了 Redisson我更推荐直接使用它的RLock因为看门狗机制能自动续期。默认情况下加锁后如果业务没执行完看门狗会每10秒为锁续期到30秒避免锁过期导致的重建风暴。使用方式很简单Autowired private RedissonClient redissonClient; public String getOrRebuildWithRedisson(String key, long expireSeconds, SupplierString dbLoader) { String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return cached; } RLock lock redissonClient.getLock(lock: key); boolean locked false; try { locked lock.tryLock(200, TimeUnit.MILLISECONDS); if (!locked) { // 拿不到锁快速失败或返回兜底 return fallbackValue(key); } // 二次判断 String doubleCheck redisTemplate.opsForValue().get(key); if (doubleCheck ! null) { return doubleCheck; } String value dbLoader.get(); redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(expireSeconds)); return value; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return fallbackValue(key); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }tryLock(waitTime, leaseTime, unit)里的waitTime是等待获取锁的时间如果超过这个时间还没拿到锁就不再等待。相比盲目自旋这种方式可控性更强。leaseTime如果传-1Redisson会启用看门狗自动续期如果你传了具体的租期看门狗不会介入所以一般建议不传租期让看门狗替你兜底。另外还有一种场景如果数据库能扛住少量并发其实不需要完全互斥只需要限制并发数。比如允许3个线程同时重建同一个key其他线程等待那就用RSemaphore而不是RLock。信号量的优势是牺牲一点一致性换来更快的缓存重建速度。我很少在缓存击穿场景用信号量因为“单点热点key”下互斥就够了但如果你有多个相同热点或者大结果集重建很慢信号量值得考虑。3.5 Spring Boot整合Redis的完整示例最后给一个相对完整的Spring Boot服务端示例。包括RedisConfig、工具方法和调用方式。首先是Redis配置我建议直接用StringRedisTemplatevalue以JSON字符串方式存储逻辑最简单也方便排查问题。如果非要存对象必须统一序列化方案避免本地和服务器之间key不一致。Configuration public class RedisConfig { Bean public RedisTemplateString, String redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, String redisTemplate new RedisTemplate(); redisTemplate.setConnectionFactory(factory); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new GenericToStringSerializer(String.class)); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(new GenericToStringSerializer(String.class)); redisTemplate.afterPropertiesSet(); return redisTemplate; } }然后是服务封装可以把双重判定锁的逻辑单独抽出来避免每个业务方法都重复写一遍。下面这个泛型方法很实用public T T executeWithDoubleCheckLock( String key, long expireSeconds, SupplierT dbLoader, ClassT clazz) { String cachedJson redisTemplate.opsForValue().get(key); if (cachedJson ! null) { return JSON.parseObject(cachedJson, clazz); } // 加锁、二次判断、查询并写缓存 // 省略重复代码逻辑与3.1一致 }调用方式就是一行Product product cacheService.executeWithDoubleCheckLock( product:detail: productId, 1800, () - productMapper.selectById(productId), Product.class);这个封装的优点是业务侧不需要关心锁的细节只需要提供key、过期时间、数据加载逻辑和返回类型。缺点是反射和序列化会有一点性能损耗但对于缓存击穿场景来说这点开销完全值得。4. 实操过程的踩坑记录与排查技巧4.1 锁过期导致的重建风暴我在上线初期就栽在锁超时上。当时锁超时设置的是200毫秒自认为数据库查询最多几十毫秒结果某个统计接口需要扫描大表P99耗时就到了300毫秒。于是线程A持有锁200毫秒后锁自动过期线程B拿到锁又过了200毫秒过期线程C再拿……一个热点key失效数据库直接被连续不断的锁重建查询打趴下。排查办法其实很简单Redis的lock key如果没有及时删除说明持有锁时间超过了TTL。我当时是给lock key加了一个监控每分钟采样一次TTL发现大量lock key在TTL接近0时仍存在立刻意识到锁超时不够。解决方式是结合慢查询日志把锁超时调整到重建耗时的3到5倍同时给每个重建方法加了耗时日志。后续我把锁超时统一调整为1秒数据库查询即使偶发慢查询也基本不会触发锁过期。这个坑提醒我锁超时不要拍脑袋要参考P99耗时并且要留足余量。4.2 删除锁时删掉了别人的锁如果你没有做token校验用delete(lockKey)释放锁那么只要触发了4.1的锁过期你必然会在某个时刻删掉别人的锁。典型的表象是Redis里锁key不断被重建但数据库压力并没有降下来。我在代码3.2里已经给出了Lua脚本这里再强调一下releaseLock时如果发现当前锁的value不等于自己的token说明锁已经被别人持有了此时一定不要调用delete。另外还有一个细节加锁时设置的value token不要复用同一个静态字符串。如果用1作为所有线程的token两个线程拿到的value一样同样无法区分谁持有锁。我用的是UUID.randomUUID().toString()每次加锁都生成本次会话唯一标识。4.3 缓存空值防止穿透但造成“永久空缓存”的坑双重判定锁通常和空值缓存一起使用。当数据库查询结果为空时也往Redis写一个空值并且设置比较短的过期时间比如30秒。否则同等请求还会反复穿透到数据库。这个方案本身没问题但要注意空值缓存对真实数据变更的影响。比如一个用户查询接口数据库中用户不存在时写入EMPTY到缓存过期时间30分钟。30分钟后用户真的注册了但如果缓存没被及时清理用户还是会在30分钟内查不到自己。解决方法是写库操作发生时主动删除对应的缓存key或者空值缓存的过期时间尽量短比如30到60秒。实际操作中我倾向于短TTL 写后删缓存双管齐下毕竟缓存击穿场景下的查询大多是读多写少空值写很短的TTL对数据库的冲击可控。4.4 多级缓存场景下双重判定失效的情形很多高并发系统不止Redis一层缓存应用本地还会用Caffeine或Guava Cache。这种情况双重判定锁的“互斥性”需要重新评估。假设你有5个服务实例每个实例本地缓存了同一个热点key本地缓存过期后5个实例同时向Redis发起读请求。Redis缓存也过期了于是5个实例同时尝试获取Redis分布式锁。这里注意锁是Redis层面互斥的最终只有一个实例能拿到锁去查询数据库。这个过程没有错误但因为每个实例都在本地缓存失效后重复去Redis走了一遍加锁逻辑Redis的请求量会放大5倍性能下降。更合理的设计是本地缓存设置较短的过期时间比如30秒同时开启Caffeine的refreshAfterWrite异步刷新。当本地缓存命中后返回旧值后台异步去Redis获取值并刷新本地缓存。Redis层继续使用双重判定锁这样本地缓存失效不会引发所有实例同时打Redis而是每个实例各自异步刷新。如果你们用了多级缓存记住一个原则越靠近用户越小的缓存更新频率越低不要让每个实例的本地缓存过期事件都变成一次Redis抢锁事件。5. 常见问题速查表与适用边界5.1 问题速查表为了方便排查我整理了一个速查表覆盖最常见的六类问题故障现象可能原因排查方向解决办法热点key失效后数据库QPS飙升双重判定锁未生效或锁粒度太大确认锁key是否是按业务key维度锁key加上业务前缀按key维度加锁多个线程同时进入重建逻辑拿到锁后没有做第二重缓存判断检查获取锁后是否再次查缓存在锁临界区内增加第二重判断锁被提前释放重建风暴持续锁超时小于重建耗时看Redis锁key在TTL内是否被删除锁超时设为重建耗时的3到5倍或使用Redisson看门狗某个线程删除了其他线程的锁释放锁时没有校验value检查释放锁的Lua脚本使用token配合Lua原子比较删除查不到数据时不断穿透数据库没有缓存空值观察Redis中是否存在空值的key对查询结果为空的情况缓存短TTL空值缓存和数据库数据不一致缓存过期后旧值被异步线程写入检查异步重建是否使用了锁确保写缓存动作发生在锁临界区内5.2 什么场景不适合用双重判定锁双重判定锁不是所有缓存场景的最优解。如果你的业务是高频写、低频读比如订单状态频繁变更那么每次缓存失效后重建的成本不高但加锁和释放锁的RTT成本相对明显更适合用“写时更新缓存”或消息队列异步更新而不是等读请求出发重建。如果数据一致性要求极高比如账户余额不能接受缓存短暂过期后返回旧值双重判定锁再完善也会存在重建窗口期的短暂空窗。此时应该考虑分布式事务、版本号或强制读数据库。如果热点key数量非常多重建动作频繁所有重建都走分布式锁会让Redis本身成为瓶颈。这种场景更适合用本地缓存扛流量或者提前缓存预热把热点key在活动开始前加载到Redis中减少失效瞬间的并发概率。还有一个特殊情况当Redis服务不稳定时所有锁操作都会增加超时等待反而拖慢请求。这种情况要设置较短的获取锁超时时间并在锁不可用时快速降级到本地缓存或者直接查库避免因为锁导致故障扩散。5.3 进一步优化方向如果要把这个方案做到极致可以从三个方向延伸。第一个方向是逻辑过期。不依赖Redis的过期机制而是让value里携带一个过期时间戳每次读取时判断是否逻辑过期。如果逻辑过期立即返回旧值同时异步获取锁、刷新缓存。这个方案对用户最友好但需要你接受短期内的数据不一致。第二个方向是重试策略的精细化。获取锁失败后不要固定等30毫秒而是根据上一次查询的耗时动态计算等待时间也可以用线程池隔离缓存重建任务避免重建慢查询阻塞业务线程。第三个方向是结合布隆过滤器把“穿透”挡在缓存前面。双重判定锁只解决“数据存在但缓存过期”的场景如果数据本身不存在无论锁多完善每次都重建空结果仍然不划算。布隆过滤器在缓存之前判断key是否存在不存在直接返回存在才走双重判定锁。这套组合基本能覆盖缓存穿透和击穿两个问题是我目前最推荐的缓存防护组合拳。我个人的体会是双重判定锁真正难的不是实现而是理解它的并发窗口。你只有把“第一次判空、加锁、第二次判空、重建、释放锁”这个流程在脑海里跑一遍把锁超时、token、Lua脚本这些细节都补齐它才会在线上成为一根可靠的顶梁柱。最后再分享一个小技巧上线后一定给“锁等待时间”和“缓存重建耗时”各加一个指标锁等待时间长期偏高说明竞争激烈重建耗时突然飙高说明数据库慢查询这两根曲线能帮你提前发现很多潜在问题。