
做后端这几年缓存是我又爱又恨的东西。爱它能在数据库面前挡住 99% 的压力恨的是高并发下缓存穿透、雪崩、击穿这三个问题每一个都让系统在流量上来时跪得猝不及防。这篇文章把三个问题一次性讲透同时给出完整可落地的终极防御手段布隆过滤器、互斥锁、多级缓存。适合正在做缓存方案选型、或者被线上缓存事故折磨过的后端开发、架构师阅读我会从问题本质讲起再给参数计算、代码示例和踩坑经验保证看完能直接用在项目里。1. 三大问题的本质用一次查询链路讲清差异1.1 缓存为什么会同时存在三个坑大多数业务系统的读写链路都是一条线请求先进缓存一般是 Redis缓存没命中才查数据库查完再把数据回填到缓存。这个机制本身很朴素但它天然留下三个漏洞查一个根本不存在的数据缓存永远没有每次都会穿透到数据库这就是穿透。大量 key 在同一时间段集中过期缓存大面积失效请求瞬间全部压到数据库这就是雪崩。某个热点 key 恰好过期一瞬间千万线程发现缓存没了同时去查数据库这就是击穿。一句话总结穿透是数据压根没有雪崩是大量 key 一起失效击穿是单个热点 key 在错误的时间失效。这三个场景的防御思路完全不同所以得分开讲。1.2 三个问题在业务场景里的特征对比光看定义容易混我建议直接按查的什么数据和失效范围两个维度来区分。下面这个对比表我压测和排查问题时反复用过基本能一眼定位问题属于哪一类维度缓存穿透缓存雪崩缓存击穿查询的数据数据库中不存在存在但大量同时过期存在且是热点但恰好过期失效范围单次请求或固定不存在数据大量 key 同时失效极少数热点 key时间特征随时可能发生集中在某一时刻集中在过期瞬间对 DB 的伤害持续小流量打库洪峰式全量打库瞬时超高并发打库典型防御布隆过滤器、空值缓存过期时间随机化、多级缓存互斥锁、逻辑过期我在实际项目里最常见的处理误区是有人把击穿当成穿透来处理给所有请求加布隆过滤器——结果热点数据明明存在布隆过滤器反而拦不住问题依旧。识别问题类型是第一优先级方案只是第二优先级。2. 缓存穿透防御从空值缓存到布隆过滤器2.1 空值缓存为什么只是入门方案最直观的穿透防御是查数据库发现没有也把结果缓存起来只不过缓存一个空值并设置一个较短的过期时间。这样同一个不存在的数据短时间内不会再次打到数据库。// 缓存空值给 60 秒过期时间防止穿透 Object value redisTemplate.opsForValue().get(key); if (value null) { value queryFromDB(key); if (value null) { redisTemplate.opsForValue().set(key, , 60, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue().set(key, value, 1200, TimeUnit.SECONDS); return value; }这个方案简单但只能算入门。问题有三个第一恶意攻击者可以构造大量随机 ID空值缓存会被塞满内存第二空值也有过期时间过期后仍然会短暂穿透第三把空值和正常数据都放在同一个缓存层里代码逻辑会越写越脏。所以空值缓存只适合业务上确实偶尔查不到数据的低危场景真正要防恶意穿透得上布隆过滤器。2.2 布隆过滤器原理与参数计算布隆过滤器本质上是一个很长的位数组加一组哈希函数。存入一个数据时用 k 个哈希函数算出 k 个位置把位数组对应位置置为 1。判断数据是否存在时同样算 k 个位置如果这 k 个位置全都是 1就认为数据可能存在只要有一个位置是 0就确定数据不存在。这里最关键的一点是布隆过滤器只会把不存在误判为存在绝不会把存在判为不存在。这意味着它特别适合放在缓存前面当准入闸门——数据库里没有的数据大概率被直接挡住不会穿透到后端。而这个大概率取决于位数组大小和哈希函数数量工程上常用两个公式位数组长度m -n * ln(p) / (ln2)^2哈希函数数量k (m / n) * ln2其中 n 是预估要存放的数据量p 是期望误判率。举个例子假设我们要存储 100 万个商品 ID期望误判率 1%那么m -1000000 * ln(0.01) / (0.693)^2 ≈ 1000000 * 4.605 / 0.480 ≈ 959 万 bit约 1.2 MBk (959 万 / 100 万) * 0.693 ≈ 6.6取整为 7 个哈希函数也就是说1.2 MB 的内存就能挡住 100 万数据规模下 99% 的不存在请求。相比空值缓存塞内存布隆过滤器在空间效率上有压倒性优势这也是它在高并发场景下不可替代的原因。2.3 布隆过滤器三种落地方式与误判处理选型上我见过三种常用方案按公司基础设施不同有不同取舍方案优点缺点适用场景RedisBloom 插件BF.RESERVE / BF.ADD / BF.EXISTS不需要本地内存跨实例共享需要额外安装 Redis 模块公司允许动 Redis 实例数据量大Guava 的 BloomFilter接入简单性能极高JVM 重启后数据丢失多实例不同步数据量小可接受启动时初始化自研 BitSet 实现可自定义持久化策略维护成本高有特殊存储需求我常用的做法是用 RedisBloom因为它在 Redis 里天然共享所有应用实例看到的是同一份过滤器。基础命令只有三个# 创建过滤器指定误判率和容量 BF.RESERVE product_whitelist 0.01 1000000 # 添加元素 BF.ADD product_whitelist 10001 # 判断是否存在 BF.EXISTS product_whitelist 10001这里要特别警惕误判的线上处理。布隆过滤器说可能存在不代表一定存在业务侧必须留兜底逻辑先查 Redis 缓存缓存没有再去查数据库数据库也没有就直接返回空结果。因为误判率通常控制在 1% 左右多跑一次数据库查询的成本完全可以承受。如果项目里用的是 Guava 本地布隆过滤器还要注意 JVM 重启会导致过滤器清空我踩过这个坑——重启后所有请求瞬间穿透到 DB数据库直接报警。解决办法是启动时从数据库全量加载数据重建过滤器或者把过滤器数据定期持久化到 Redis启动时反序列化回来。3. 缓存雪崩防御过期时间随机化和多级缓存联合兜底3.1 雪崩的两大触发源雪崩有两个完全不同的触发源处理方式南辕北辙。第一种是大量 key 集中过期。常见于运营活动、商品批量上架这类场景后台一次性往缓存写入大量 key过期时间都是同一个值到点之后大家一起消失数据库瞬间迎来所有请求。这种雪崩是人为制造的也是防御成本最低的。第二种是Redis 不可用。不管是 Redis 进程崩溃、节点宕机还是网络分区所有请求直接绕过缓存涌向数据库。这种雪崩破坏性更大单靠缓存侧的优化解决不了必须配合高可用架构。3.2 让过期时间不齐整随机化和业务错峰针对集中过期最简单有效的办法就是给过期时间加随机偏移量。我的习惯是基础过期时间 1200 秒再加一个 0 到 300 秒的随机值把过期时间打散。别小看这个操作它几乎零成本却能把一个瞬间崩溃变成平稳过渡。// 过期时间 基础时间 随机偏移避免缓存同时过期 long baseTtl 1200L; long randomOffset ThreadLocalRandom.current().nextLong(0, 300); redisTemplate.opsForValue().set(key, value, baseTtl randomOffset, TimeUnit.SECONDS);如果是定时任务批量写入的热点数据还有一个更细腻的思路把同一批 key 的过期时间按业务语义错峰。比如商品详情缓存可以按商品上下架时间分桶每个桶设置不同的过期基准而不是所有商品都用同一套 TTL。3.3 多级缓存把命脉从 Redis 手里接过来针对 Redis 不可用引起的雪崩光在 Redis 上做文章不够核心思路是引入多级缓存让业务不再把全部命脉押在 Redis 上。典型结构是三层第一层本地缓存 Caffeine放在应用进程内访问最快第二层Redis 集中缓存跨实例共享第三层数据库兜底。读取顺序是 Caffeine → Redis → DB逐级回填。即使 Redis 彻底挂掉本地缓存仍然能扛住一部分请求给 DB 争取喘息时间。下面是一个简化的多级缓存读取代码public Object getProduct(String productId) { // 第一层本地缓存 Object localValue caffeineCache.getIfPresent(productId); if (localValue ! null) { return localValue; } // 第二层Redis String redisKey product: productId; Object value redisTemplate.opsForValue().get(redisKey); if (value ! null) { // 回填本地缓存设置短过期时间 caffeineCache.put(productId, value); return value; } // 第三层数据库 Object dbValue queryFromDB(productId); if (dbValue ! null) { redisTemplate.opsForValue().set(redisKey, dbValue, 1200, TimeUnit.SECONDS); caffeineCache.put(productId, dbValue); return dbValue; } return null; }这种设计的代价是本地缓存更新不一致的问题。我的处理方式比较务实本地缓存统一设置 60 秒左右的短过期时间业务上允许最多 1 分钟内读到旧数据。要严格一致的话就需要在更新数据库后通过消息队列广播缓存失效通知让所有实例主动清掉本地缓存这个可以根据业务对实时性的要求取舍。3.4 Redis 不可用时的限流降级兜底多级缓存能缓解雪崩但扛不住 Redis 挂掉后所有请求都去查 DB 的洪峰。真正上线前还要做两件事限流和降级。限流的思路是给数据库查询接口加信号量或令牌桶超过阈值直接返回失败或走降级逻辑宁可损失一部分请求也不能把数据库打挂。降级的思路是给核心接口准备降级数据比如商品详情页可以用静态化页面或者上次成功响应的缓存结果临时顶上等 Redis 恢复后再回到正常流程。Redis 侧则至少要配置哨兵模式保障主从自动切换核心业务直接上 Cluster 集群避免单点故障成为雪崩导火索。我在压测时验证过一个数据没有多级缓存时Redis 宕机 30 秒数据库 QPS 直接冲到 10 万加上 Caffeine 本地缓存后同样场景数据库 QPS 降到了 5000 以内。这个差距足以说明多级缓存对雪崩防御的价值。4. 缓存击穿防御互斥锁与逻辑过期方案的取舍4.1 击穿与穿透的区别别把两兄弟认错击穿和穿透在名字上只有一字之差网上很多文章也混着讲但它们的防御手段完全不同必须严格区分。穿透查的是数据库中不存在的数据布隆过滤器可以精准拦截击穿查的是数据库中存在的热点数据只是这个热点 key 在过期瞬间没有缓存布隆过滤器完全帮不上忙。我遇到过最典型的场景是爆款商品秒杀一个商品详情被几万人同时访问缓存的过期时间一到这几万个请求同时发现缓存 miss全部涌向数据库。这类请求的并发量是长尾请求的上百倍处理不好 5 秒内就能打挂数据库。4.2 互斥锁方案让重建缓存的线程只有一个击穿的经典解法是互斥锁当某个 key 在缓存中不存在时不是让所有线程都去查 DB而是只让一个线程重建缓存其他线程等待重建完成后再从缓存读取。核心逻辑如下public Object getProductWithLock(String productId) throws InterruptedException { String key product: productId; Object value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } // 尝试获取分布式锁只允许一个线程重建缓存 String lockKey lock: key; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (locked) { try { // 拿到锁后二次检查防止重复重建 value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } value queryFromDB(productId); redisTemplate.opsForValue().set(key, value, 1200, TimeUnit.SECONDS); return value; } finally { // 释放锁用 Lua 保证判断和删除的原子性 releaseLock(lockKey); } } // 没有拿到锁等待一小段时间后重试 Thread.sleep(100); return getProductWithLock(productId); }这里有三个细节非常关键。第一setIfAbsent必须同时设置过期时间否则线程崩溃会导致锁永不释放形成死锁。第二获取锁之后要二次检查缓存因为可能在等待锁的过程中其他线程已经重建完缓存避免重复查库。第三释放锁的判断 value 再删除操作要保证原子性我一般用 Lua 脚本处理否则可能出现锁已经被其他线程获取、却被当前线程误删的问题。生产环境我更推荐直接用 Redisson 的RLock它内部有看门狗机制会不断续期避免业务执行时间超过锁过期时间导致锁提前释放。但要注意互斥锁方案有一个固有缺陷缓存 miss 后所有请求都在等待锁释放会有短暂的串行化和额外延迟对极端热点场景可能不够。4.3 逻辑过期方案让旧数据先顶着用针对互斥锁的等待问题业界还有一种更优雅的方案——逻辑过期。思路是缓存里永远不设置真实过期时间或者设置得很长而是在 value 里额外存一个逻辑过期时间。读取时发现逻辑时间已过就触发异步重建拿到分布式锁的线程负责更新缓存没拿到锁的线程直接返回已经过期但还没被更新的旧数据。用户的请求永远不需要等待锁释放代价是极短时间内返回的数据可能不是最新。public DataPackage getProductWithLogicalExpire(String productId) { String key product: productId; CacheItem cacheItem redisTemplate.opsForValue().get(key); if (cacheItem null) { return null; } // 逻辑缓存未过期正常返回 if (cacheItem.getLogicalExpireTime() System.currentTimeMillis()) { return cacheItem.getData(); } // 逻辑缓存已过期尝试重建失败则返回旧数据 String lockKey lock: key; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (locked) { try { // 二次检查避免重复更新 cacheItem redisTemplate.opsForValue().get(key); if (cacheItem.getLogicalExpireTime() System.currentTimeMillis()) { return cacheItem.getData(); } new Thread(() - { // 异步重建缓存 DataPackage newData queryFromDB(productId); CacheItem newItem new CacheItem(newData, System.currentTimeMillis() 60000); redisTemplate.opsForValue().set(key, newItem); }).start(); } finally { releaseLock(lockKey); } } // 未拿到锁的线程直接返回旧数据 return cacheItem.getData(); }4.4 到底选互斥锁还是逻辑过期决策表两个方案各有适用场景我给团队做过一个决策表核心看三个维度实时一致性要求、并发热点程度、实现复杂度。维度互斥锁方案逻辑过期方案数据实时性高重建后立即返回最新数据低过期瞬间可能返回旧数据极端热点抗压性一般有锁等待延迟高请求无需等待实现复杂度低容易理解高需要额外维护逻辑过期时间典型场景一致性要求高的订单、库存读多写少的商品详情、资讯内容我的建议是如果 key 的热度没有夸张到几万 QPS 同时打过来优先用互斥锁逻辑更简单、不容易出错只有明确压测发现锁等待成为瓶颈时再升级成逻辑过期方案。不要把方案搞复杂复杂就意味着新的故障点。5. 组合实战把布隆过滤器、互斥锁、多级缓存放进同一条查询链路5.1 场景假设8 万 QPS 的商品详情页前面的防御手段单独用都能解决一部分问题但真实的高并发系统一定需要组合拳。我以一个自己设计过的商品详情页为例接口峰值 QPS 8 万背后挂着 MySQL商品数量 100 万热点商品会参与秒杀活动。这个场景同时存在穿透恶意刷不存在的商品 ID、雪崩批量商品缓存同时过期、击穿秒杀商品缓存过期瞬间三个风险。5.2 防御阵型的四层结构整个查询链路按顺序编排成四层防御每一层解决一类问题层级组件解决的痛点第一层布隆过滤器RedisBloom拦截不存在的商品 ID防穿透第二层Caffeine 本地缓存Redis 不可用时兜底防雪崩第三层Redis 集中缓存跨实例共享热数据防数据库压力第四层互斥锁 随机 TTL防热点 key 击穿错峰过期读取流程是请求先经过布隆过滤器不存在直接返回空再查 Caffeine 本地缓存本地没有查 RedisRedis 也没有则走互斥锁逻辑查数据库回填。每次回填 Redis 时TTL 都加随机偏移从根源上错开批量过期时间。public Object getProductDetail(String productId) throws InterruptedException { // 第一层布隆过滤器拦截不存在的 ID防穿透 if (!bloomFilter.isExist(productId)) { return null; } // 第二层本地缓存防 Redis 不可用导致的雪崩 Object local caffeineCache.getIfPresent(productId); if (local ! null) { return local; } // 第三层Redis 缓存 String key product:detail: productId; Object redisValue redisTemplate.opsForValue().get(key); if (redisValue ! null) { caffeineCache.put(productId, redisValue); return redisValue; } // 第四层互斥锁重建缓存防击穿 随机 TTL 防雪崩 String lockKey lock:product:detail: productId; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (locked) { try { Object doubleCheck redisTemplate.opsForValue().get(key); if (doubleCheck ! null) { caffeineCache.put(productId, doubleCheck); return doubleCheck; } Object dbValue queryFromDB(productId); if (dbValue ! null) { long ttl 1200L ThreadLocalRandom.current().nextLong(0, 300); redisTemplate.opsForValue().set(key, dbValue, ttl, TimeUnit.SECONDS); caffeineCache.put(productId, dbValue); return dbValue; } return null; } finally { releaseLock(lockKey); } } // 未抢到锁等待后重试 Thread.sleep(100); return getProductDetail(productId); }这套组合链路里每一层都有独立的回填逻辑任何一个缓存层短时间内失效都不会直接把请求打满数据库。布隆过滤器挡住 99% 的不存在请求本地缓存兜住 Redis 故障互斥锁控制热点 key 的重建并发随机 TTL 避免批量失效四个手段互相配合而不是互相替代。5.3 缓存与数据库一致性的更新策略防御阵型搭好之后缓存更新也得配套相应的策略。我的习惯是先更新数据库再删除缓存等下一次读取时再懒加载回填。这个顺序能最大限度降低脏数据窗口。如果更新完数据库之后直接更新缓存并发场景下容易出现旧数据覆盖新数据的问题。删除缓存失败是必须考虑的场景。我常用延迟双删先删除缓存更新数据库再延迟几百毫秒删除一次缓存确保读请求不会把旧数据重新写回缓存。如果对一致性要求更高可以订阅数据库 binlog通过消息队列异步删除对应缓存避免应用代码里手动处理删除失败的补偿逻辑。这套东西看上去复杂但在商品详情这种量级下能保证缓存中 99% 以上的数据是最新的。6. 实战踩坑记录与压测验证6.1 布隆过滤器误判不是玄学我最初上线布隆过滤器时想当然地认为误判率 1% 没什么影响。结果活动当天布隆过滤器把一批不存在的商品 ID 误判为可能存在一批请求穿透到 Redis 和数据库数据库 QPS 比预期高了不少。排查后确认是因为我把过滤器的容量 n 设置成了当前商品数量但实际查询请求中包含大量历史下架商品 ID过滤器被塞入了远超容量的元素误判率急剧上升。教训是BF.RESERVE的容量必须按所有可能被查询的数据量来预估而不是当前有效数据量。我现在统一按数据库表主键的最大规模 一定冗余来计算宁可多占几百 KB 内存也不让误判率失控。6.2 互斥锁的死锁与锁粒度失控互斥锁方案我在生产上也踩过坑。第一次加锁时没有设置过期时间代码里查数据库的逻辑稍微慢了一点就触发了死锁告警——一个线程持锁崩溃其他线程全部卡在等待重试上。后来加上过期时间又遇到第二个问题业务查询超过锁过期时间锁提前释放多个线程同时进入重建逻辑击穿又回来了。最后靠 Redisson 的看门狗自动续期解决或者把锁过期时间调大到业务最大耗时的两倍以上。锁粒度也容易失控。有人为了省事用一把全局锁保护所有缓存重建结果不同 key 的缓存 miss 也会互相排队接口整体延迟直接翻倍。正确做法是把锁的粒度精确到 key 级别锁 key 包含业务 ID例如lock:product:detail:10001这样才能保证只有同一个商品的请求才会互相等待。6.3 压测验证防御前与防御后的数据对比我每次做完缓存方案都会做一轮对比压测。模拟穿透是构造大量不存在的 ID 打接口模拟击穿是设置单个热点 key 过期后并发打接口模拟雪崩是批量写一批同时过期的 key。记录三组数据数据库 QPS、接口平均延迟、缓存命中率。实际压测结果很直观不加防御时穿透场景数据库 QPS 大约 3 万接口成功率掉到 60% 左右加上布隆过滤器后数据库 QPS 降到 1000 以内接口成功率回到 99.9% 以上。击穿场景也是类似互斥锁打开前数据库瞬间 QPS 冲到 5 万打开后被控制在几百接口 P99 延迟从 800ms 降到 150ms。这些数据每次都提醒我缓存层的价值不是让请求更快而是让数据库活得够久。6.4 监控指标建议即便防御体系完整线上也必须有可观测的指标否则出了问题很难定位是哪个环节失效。我平时重点盯四个指标缓存命中率正常情况下应该在 95% 以上显著下降说明某个缓存层失效。数据库回源 QPS这是防御体系的核心 KPI任何一次穿透、雪崩、击穿都会在这里暴露。分布式锁获取失败率锁失败率突然上升说明热点 key 击穿正在发生。布隆过滤器拦截率拦截请求占总请求的比例突然下降要么是容量不够导致误判率上升要么是过滤器数据没有正确加载。这四个指标配合告警规则就能覆盖三大问题的主要发生场景。真出问题时先看数据库回源 QPS 的时间曲线和当时缓存层的状态基本能快速定位是穿透、雪崩还是击穿。最后再分享一个小技巧排查缓存故障时不要一上来就盯代码逻辑先把四层链路布隆过滤器、本地缓存、Redis、DB的命中日志打上时间戳哪个环节 miss 的累计耗时最长问题就在哪里。高并发下的缓存问题99% 都是可以在设计阶段提前防住的真正到线上补救成本至少高出三倍。