ARTICLE DETAIL

资讯详情

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

缓存穿透与缓存雪崩:成因、解决方案与代码实现

缓存穿透与缓存雪崩:成因、解决方案与代码实现 Redis 做缓存最怕的就是缓存穿透和缓存雪崩。这两个词在所有 Redis 面试题里几乎都是必考题但真实生产环境中它们的杀伤力比面试题里描述的还要大——直接表现就是数据库被打挂、接口超时、系统整体雪崩式瘫痪。我做了这么多年后端光是处理这类问题就不下十次每次排查链路都很相似先看 Redis 命中率再看数据库慢查询最后定位到缓存策略上的漏洞。这篇文章我把两类问题的成因、判断方法、修复方案和落地代码一次讲透顺带提一下经常被混为一谈的缓存击穿。想直接抄作业的朋友重点看第 2、3 章想做全面加固的可以直接参考第 4 章的完整示例。1. 先搞清楚缓存穿透和缓存雪崩到底是什么1.1 缓存穿透查询一个一定不存在的数据缓存穿透说的是请求绕过了缓存直接打到了数据库而且打的还是那些数据里压根不存在的东西。举个最常见的例子用户查询商品详情接口接收一个商品 ID流程是“先查 Redis没命中再查 MySQL查到了写缓存”。如果用户传了一个不存在的商品 ID比如-1或者一个随机生成的 UUIDRedis 里查不到MySQL 里也查不到接口返回空结果同时这一轮查询没有在缓存里留下任何东西。下一次同样的请求进来Redis 依然查不到MySQL 依然要再查一遍。单个这样的请求没什么杀伤力但如果是下面这几种场景问题就放大了恶意攻击者写脚本遍历 ID比如从 1 到 1000000 挨个试其中大部分都是不存在的 ID。用户通过非法入参反复触发查询比如把 ID 置空、置负、置成超长字符串。某个接口上线了新的过滤条件旧数据 ID 已经失效但前端还在用旧 ID 轮询。这些请求的共同特点是每一次都会穿透 Redis 直达 MySQL而且 MySQL 每次都是无意义的空查。数据库连接池被大量无效查询占满之后正常用户的查询也会跟着变慢甚至直接报连接超时。最麻烦的是由于 Redis 缓存永远无法命中这类 key这个漏洞只要存在就一直在被打。1.2 缓存雪崩大批缓存同时失效引发的连锁反应缓存雪崩是另一种“缓存没拦住”的情况但成因完全不同。它指的是缓存层中大量 key 在同一时间段集中失效或者 Redis 实例整体不可用导致原本应该由缓存扛住的请求一瞬间全部落到数据库上。最典型的触发场景是大批量 key 设置了相同的过期时间。比如不少业务喜欢把“凌晨零点”作为数据刷新点商品库存、活动状态、用户排行榜这些数据都设置成当天零点过期然后在零点之后由定时任务统一重新加载。表面上没问题但如果定时任务启动稍慢或者凌晨的流量并不低很多活动就是凌晨放量那么零点一到成千上万个 key 同时过期数据库瞬间承受所有查询慢查询飙升连接池被打满接着就是一系列的接口超时。还有一种更严重的情况Redis 实例本身挂了。不管是物理机宕机、内存被打满触发 OOM还是网络分区导致客户端连不上 Redis只要 Redis 不可用所有请求都会失去缓存这层保护直接穿透到数据库。这种场景的破坏力比 key 集中失效更大因为不光是“热数据”失效而是整个缓存层归零。1.3 容易被混在一起的兄弟缓存击穿说到缓存穿透和缓存雪崩很多人会问“缓存击穿”是不是第三种情况。确实容易搞混但击穿和前面两个有本质区别穿透查的是不存在的数据缓存和数据库都没有。雪崩大量 key同时失效或者 Redis 整体不可用。击穿某个热点 key 在过期的一瞬间被大量并发请求同时访问。数据本身是存在的只是缓存刚好到期了导致这瞬间所有请求都打到数据库。打个比方穿透是“你家根本没这个东西但外面一直有人敲门问”雪崩是“你家的存粮全部同一天过期”击穿是“最畅销的那款商品刚好在高峰期断货一秒钟”。击穿的处理方式和穿透也不太一样常见手段是互斥锁只让一个线程去查数据库重建缓存其他线程等待或者逻辑过期时间。这篇文章主要讲穿透和雪崩击穿之后我会在方案里顺带提几嘴。2. 缓存穿透的根因分析与方案设计2.1 穿透的根因不是“Redis 不够快”这么简单很多人一遇到穿透第一反应是“加缓存”。但穿透的本质问题在于缓存本身没有能力表达“这个数据不存在”这一状态。正常的缓存逻辑是存在就返回数据不存在就查数据库。可对于数据库中压根不存在的 key每次查询的结果都是空而按常规写法“查询结果为空就不写缓存”那这个 key 相当于永远无法被缓存命中。所以问题的根源不在 Redis 的性能而在缓存策略没有覆盖“空结果”这个分支。换个角度理解缓存穿透其实是一种不完整的状态建模。我们在缓存里只保存了“有数据”的状态却没有保存“无数据”的状态那每次请求都只能回到源头去确认一次。只要把“无数据”也表示出来穿透就解决了一大半。理解了这一点再看各种解决方案就会觉得非常自然要么把“不存在”也缓存起来空值缓存要么在查询缓存之前就判断“这个 key 到底有没有可能存在于数据源”布隆过滤器要么根本不让非法请求进入查询流程参数校验。2.2 方案一空值缓存最简单直观的第一道防线空值缓存的思路极其朴素查不到数据也往 Redis 里写一个空值并设置一个较短的过期时间。这样下一次同样的 key 进来Redis 能直接返回“查不到”的结果不会再去 MySQL 空查一遍。伪代码大概是这样的public Object getProduct(Long id) { String key product: id; Object cache redis.get(key); if (cache ! null) { return cache; } // 模拟查询数据库 Object dbResult queryFromDB(id); if (dbResult ! null) { redis.set(key, dbResult, 3600); } else { // 空值也缓存但过期时间要短 redis.set(key, EMPTY_PLACEHOLDER, 300); } return dbResult; }几个关键细节空值的过期时间一定要短。一般建议 3~5 分钟。如果设置太长数据源里真正写入这条数据后空值缓存还没过期用户看到的还是“查不到”直到缓存过期才能恢复。这个时间窗口对用户体验的伤害极大。空值占位符要能跟真实数据区分开。不要直接存一个null字符串了事建议用一个特殊常量或者干脆用Boolean.FALSE这种对象读取时检查类型。要对空值缓存的总量做控制。如果恶意攻击者构造大量随机 key空值缓存会在短时间内膨胀把 Redis 内存打满。生产环境里我会配合 Redis 的maxmemory-policy allkeys-lru淘汰策略使用同时加一层计数器空值 key 数量达到阈值就停止写入空缓存。空值缓存最大的优点是简单任何团队都能在一小时内落地。但对于高强度的恶意扫描它只能缓解不能根治——因为每次新 key 还是会穿透到 DB。所以它更适合作为第一道兜底而不是唯一的方案。2.3 方案二布隆过滤器从源头拦截不存在的 key布隆过滤器是解决穿透问题的经典方案也是我认为的“根治型方案”。它的核心思想是在查询缓存之前先判断这个 key 是否“可能存在”。布隆过滤器本质上是一个 bit 数组加若干个哈希函数。插入一个 key 时用 k 个哈希函数算出 k 个位置把这些位置都置为 1查询一个 key 时同样算出 k 个位置如果有一个位置是 0就可以断定这个 key 一定不存在如果全部是 1只能说“可能存在”因为存在哈希冲突有一定误判率。这句话信息量很大展开说一下不漏报如果布隆过滤器说“不存在”那这个 key 一定不存在。这一特性直接堵死了穿透。会误报如果布隆过滤器说“可能存在”那实际上可能并不存在。误判率 p 可以通过参数控制比如 0.01 或 0.001。不能删除标准布隆过滤器不支持删除某个 key因为多个 key 的妈妈位置会重叠。想支持删除就得用带计数器的 Cuckoo Filter 或者布隆过滤器的变体。在缓存穿透场景里布隆过滤器的作用是把数据库里所有合法的 ID 集合提前加载进过滤器查询时先过过滤器如果判定“不存在”直接返回空连 Redis 都不查。这样即使攻击者构造一万个随机 ID布隆过滤器能拦截 99.9% 以上的请求。落地时的方案设计大致有两种全量加载服务启动时把 MySQL 里的所有主键 ID 一次性装入布隆过滤器适用于 ID 总量可控比如百万级、千万级的场景。增量更新每次有新增 ID 时同步写入布隆过滤器。这个增量更新可以在写库里完成也可以监听 MySQL binlog 来做。示例代码Java Guava BloomFilter大概长这样BloomFilterLong bloomFilter BloomFilter.create( Funnels.longFunnel(), 100_0000, // 预估元素数量 0.01 // 误判率 ); // 查询前先过滤 if (!bloomFilter.mightContain(id)) { return null; } // 再走 Redis - DB 的正常链路布隆过滤器最大的优势是内存占用极小。一亿个 key、误判率 1%大约只需要 114MB 内存比直接缓存空值的性价比高得多。但没有银弹布隆过滤器也有自己的坑数据新增了忘记更新布隆过滤器会导致合法用户被误杀。数据删除了布隆过滤器里还留着这个 key只是多了一次穿透查询问题不大。布隆过滤器和 Redis 是独立组件Redis 可以走主从高可用布隆过滤器如果部署在应用本地多个实例之间需要同步更新这个同步链路也要设计好。2.4 方案三接口层参数校验最便宜的一道防线这个方案看起来太简单以至于经常被忽略但我强烈建议每个接口都要做。很多穿透流量其实是可以提前拦截的ID 必须是合法的正整数负数和 0 直接拒绝ID 必须满足长度限制超长字符串直接返回参数错误ID 必须在枚举范围内如果有明确的业务范围对明显高频的无效请求加接口级限流举个实际例子我遇到过某个接口接受字符串类型的 ID结果攻击者往里传了一个几百 KB 的超长字符串数据库压根没法用这个字段做索引查询直接导致慢查询。后来在网关层加了正则校验和长度限制这类流量立刻降为零。参数校验不属于“高深方案”但它是性价比最高的一层。穿透治理一定要做分层防御参数校验挡掉最傻的攻击布隆过滤器挡掉大部分不存在的 key空值缓存兜住漏网之鱼三层叠加之后基本上可以做到“数据库感知不到穿透流量”。3. 缓存雪崩的保护方案与落地细节3.1 雪崩的两大类诱因要分开治缓存雪崩有两种完全不同的诱因处理方式也完全不同混在一起想方案会很别扭。第一类key 集中失效。大量 key 设置了同一个过期时间点到期后集体失效数据库瞬时压力飙升。这类问题的特征是Redis 本身健康缓存命中率在某一个时间点断崖式下跌随后慢慢恢复。处理思路是“错峰兜底”核心手段有随机过期时间、多级缓存、延迟预热。第二类Redis 不可用。进程崩溃、内存爆掉、主从切换失败、网络分区等等导致整个缓存层短时间内无法服务。这类问题的特征是所有请求都拿不到缓存数据库瞬间全量承压。哪怕 key 的过期时间设计得再合理只要 Redis 挂了该死还是得死。处理思路是“高可用降级熔断”核心手段是部署架构上的主从/哨兵/集群以及业务侧的降级开关。搞清楚是哪一类才能对症下药。下面几个小节把这两类的具体做法都说一下。3.2 过期时间错峰给 TTL 加一个随机因子针对“key 集中失效”最经典的办法就是让过期时间不要整整齐齐。比如手机验证码统一 5 分钟过期那 5 分钟内进来的验证码全都在同一个时刻过期如果这是登录高峰时段瞬间的缓存 miss 会很吓人。改成“300 秒 0~60 秒随机”之后过期时间点在 300~360 秒之间均匀分布到期请求被摊平对数据库的压力均值化。代码上非常简单int baseTtl 300; int randomExtra ThreadLocalRandom.current().nextInt(60); String key captcha: phone; redis.set(key, code, baseTtl randomExtra);不同业务线的过期时间可以设不同的基值。比如用户基础信息设 1 小时加随机排行榜设 10 分钟加随机库存信息设 30 秒加随机。避免所有 key 的过期时间只在同一批数字附近打转。但要注意一个取舍错峰会牺牲数据的强一致性。有些业务确实要求缓存必须某个时间点统一过期以便拉到最新数据。比如活动开始前需要预热缓存此时就不能搞随机过期改了逻辑顺序先不设过期时间写入预热缓存活动开始后由后台任务在合适时机统一更新。这种情况靠“错峰”解决不了得靠“按业务语义设计缓存生命周期”不能一刀切。3.3 多级缓存在 Redis 前面再加一道挡板Redis 挂掉后有没有可能让数据库少承受一些冲击答案是有的——在 Redis 和数据库之间再插一层本地缓存也就是所谓的多级缓存。Java 生态里常用 Caffeine它和 Jedis/Lettuce 不冲突。应用先从本地缓存找数据找不到再查 Redis再找不到才查数据库。Redis 挂了本地缓存还在虽然数据可能略旧通过 TTL 控制但至少能挡住相当一部分请求。多级缓存的核心难点不是会用 Caffeine而是数据一致性。本地缓存分布在每个应用实例里更新时不能只更新 Redis 再等过期要用版本号或者消息通知让每个实例主动失效本地缓存。简单的做法是写入或更新数据时生成一个版本号写入 Redis 并发布一条消息各实例收到消息后删除本地 key读取时先比较本地版本和 Redis 版本不一致就放弃本地缓存。多级缓存的代码框架大致是// 本地缓存 CacheString, Object localCache Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(60, TimeUnit.SECONDS) .build(); public Object getProduct(Long id) { // 第一级本地缓存 Object local localCache.getIfPresent(id); if (local ! null) return local; // 第二级Redis String key product: id; Object remote redis.get(key); if (remote ! null) { localCache.put(id, remote); return remote; } // 第三级数据库 Object db queryFromDB(id); if (db ! null) { redis.set(key, db, randomTtl()); localCache.put(id, db); } return db; }这套方案虽然写起来多几行代码但对数据库的意义非常大。尤其在高并发读多写少的场景下本地缓存能消掉至少 30%~50% 的读请求即便 Redis 瞬时不可用应用也能靠本地缓存撑过故障窗口。3.4 高可用与降级兜底别让数据库裸奔针对“Redis 不可用”的情况光靠业务侧的缓存策略不够还得做两件事第一部署层面高可用。单机 Redis 不做任何容灾挂了就挂了。正规一点的做法是主从复制加哨兵至少保证自动故障转移数据量大、QPS 高的场景直接上 Redis Cluster分片存储单点故障的爆炸半径更小。除此之外还建议开启 Redis 的持久化RDB AOF这样即使实例重启缓存数据不至于全部丢失。关于 Redis 部署本身很多人会卡在安装和配置上。我使用的经验是macOS 上直接用 Homebrew 装 Redis 最省事brew install redis一条命令搞定服务器上用 Docker 跑主从也不难docker-compose 里定义两个 Redis 容器主节点开一个端口从节点通过replicaof配置指向主节点。需要可视化工具的话Another Redis Desktop Manager 和 RedisDesktopManager 我都用过前者比后者更活跃界面也干净一些。第二业务层面降级熔断。无论 Redis 做了多好的高可用总有极端情况。所以业务侧一定要有一个“Redis 不可用时怎么办”的预案。我这里提几个常用策略快速失败如果业务对实时性要求不高Redis 异常时直接返回旧的本地缓存或默认值而不是把请求打到数据库。熔断通过 Sentinel 或 Resilience4j 实现熔断器当 Redis 的异常率超过阈值时自动切到降级逻辑避免无意义的重试。数据库限流用信号量或分布式限流组件控制对数据库的并发访问量。比如每秒最多放行 2000 个查询到 MySQL其余请求进入等待或直接返回“系统繁忙”。这些方案组合起来才能在 Redis 挂掉时做到“数据库不裸奔”。我特别强调这一点是因为很多团队只做了主从复制却忘了写降级逻辑一旦主从切换失败整个服务直接瘫痪这是我在生产项目里真实见过的教训。4. 一个完整示例从设计到落地代码4.1 场景设定与方案组合为了把前面讲的方案串起来我拿一个非常常见的场景来举例商品详情页查询。假设商品表product有 500 万条数据ID 是自增的Long类型。查询接口/api/product/{id}需要做成高并发友好的。我选择同时做三件事参数校验ID 必须大于 0不能超过 99999999。布隆过滤器把全部有效 ID 加载进去查询前先过滤。空值缓存 随机过期兜底拦截漏网之鱼同时给缓存时间加随机因子防止大量商品同秒过期。这三个方案是分层的参数校验挡非法输入布隆过滤器挡不存在的合法 ID空值缓存兜底那 1% 的误判。最终数据库几乎承受不到穿透流量。4.2 核心代码查询链路完整实现我用 Java Spring Boot 写一个简化的实现代码结构如下Service public class ProductService { Autowired private RedisTemplateString, Object redisTemplate; private BloomFilterLong productBloomFilter; PostConstruct public void init() { // 预估 500 万元素误判率 0.011% productBloomFilter BloomFilter.create( Funnels.longFunnel(), 500_0000, 0.01 ); // 模拟启动时从数据库加载全部 ID for (Long id : loadAllProductIdsFromDB()) { productBloomFilter.put(id); } } public Product getProduct(Long id) { // 第一层参数校验 if (id null || id 0 || id 99999999) { return null; } // 第二层布隆过滤器拦截 if (!productBloomFilter.mightContain(id)) { return null; } String key product: id; // 第三层Redis 查询 Object cache redisTemplate.opsForValue().get(key); if (cache ! null) { if (cache instanceof EmptyPlaceholder) { return null; } return (Product) cache; } // 第四层数据库查询 Product product queryProductFromDB(id); if (product ! null) { int ttl 3600 ThreadLocalRandom.current().nextInt(600); redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(ttl)); } else { redisTemplate.opsForValue().set( key, EmptyPlaceholder.INSTANCE, Duration.ofSeconds(300) ); } return product; } }注意几个细节EmptyPlaceholder是我自定义的一个单例对象专门用来标记空值避免和真实数据混淆。布隆过滤器在PostConstruct里加载如果这里的 ID 抽取比较耗时可以改成异步加载但不能影响服务启动。空值缓存使用了 300 秒 TTL这是为了在“商品真实新增”后能尽快恢复可见性。4.3 布隆过滤器参数选择怎么算更合理布隆过滤器两个关键参数是元素数量n和误判率p。它们共同决定位数组长度m和哈希函数个数k位数组长度m - (n * ln p) / (ln 2)^2哈希函数个数k (m / n) * ln 2拿 500 万元素、1% 误判率来算ln(0.01) ≈ -4.60517m ≈ - (5000000 * -4.60517) / 0.693147 ≈ 33,222,157 bit ≈ 4 MBk ≈ (33,222,157 / 5,000,000) * 0.693147 ≈ 4.6向上取整用 5 个哈希函数也就是说500 万商品 ID 的布隆过滤器只需要不到 4MB 内存换来的是几十倍的内存节省。如果换成空值缓存每个 key 就算只存 30 字节500 万个也要 150MB差距非常明显。实际开发中我一般直接用 Guava 的BloomFilter它在构造时已经封装了这层计算不需要手写 bit 数组和哈希函数。如果要支持删除操作就得换带计数的过滤器结构不过对“商品 ID 是否存在”这种不可变集合来说标准布隆过滤器足够了。5. 生产环境问题排查与避坑指南5.1 如何快速判断当前是穿透还是雪崩排查策略比修 bug 更重要。我总结了一套“三看”的快速判断法一看 Redis 命中率。如果命中率从 95% 掉到 30% 以下说明大量请求没有命中缓存。先别急着调代码看命中率曲线是持续低位还是瞬时下跌后回升。持续低位大概率是穿透瞬时下跌后回升大概率是雪崩或击穿。二看请求参数分布。用日志或网关流量分析工具看失败的请求参数是不是非常集中。如果大量请求都是同一个不存在的 ID那是穿透如果是各个业务方的 key 同时失效那是雪崩。三看 Redis 实例状态。用redis-cli info stats看keyspace_hits和keyspace_misses的比值再配合redis-cli --bigkeys看看有没有异常膨胀的 key。如果 Redis 连接数爆满、QPS 异常高还要看是不是其他服务把 Redis 当成了中间件滥用。另外补充一句接口报错信息里如果出现RedisCommandTimeoutException这类客户端超时异常比如 Spring Boot 中使用 Lettuce 常见说明 Redis 可能已经不健康了不要再继续重试否则会加剧雪崩。5.2 关键监控指标与告警阈值我把生产环境里我认为最有价值的 Redis 监控指标列成了一张表供大家直接抄指标指标含义建议告警阈值说明缓存命中率命中次数 / (命中未命中)低于 90% 告警低于 90% 就要查原因Redis 内存使用率used_memory / maxmemory超过 80% 告警内存打满会导致淘汰和数据丢失数据库慢查询MySQL 慢查询数量与基线对比突增 2 倍告警穿透和雪崩都会导致慢查询突增接口 P99 耗时99% 请求的响应耗时超过 500ms 告警缓存失效后 P99 会明显上涨Redis 连接数当前连接客户端数量超过 80% 上限告警连接数爆了通常意味着阻塞除了监控告警日志也要打好。每次“Redis miss DB 命中”的关键路径打一条 WARN 日志每次“DB miss 空值缓存写入”打一条 INFO 日志。有了这些日志排查时就不用靠猜。5.3 我用过的常见坑和避坑经验写到最后分享几个我在真实项目中踩过的坑有些还挺隐蔽。坑一空值缓存 TTL 设置过长。之前有个同事把空值缓存设成了 30 分钟结果业务方在后台新增了一条数据用户在 30 分钟内一直看不到排查了很久才发现是空缓存没失效。我现在的经验是普通业务空值缓存不要超过 5 分钟宁可让少量请求穿透也不要把“可见性延迟”放大。坑二只做了布隆过滤器漏了增量更新。在一家电商公司布隆过滤器只在服务启动时加载了一遍后续新增商品没有同步进去导致新商品全部“被穿透”。这个问题排查起来很隐蔽因为线上接口明明是正常返回就是没有数据。后来改成在商品发布接口同步调用布隆过滤器更新才算解决。坑三随机过期时间用错粒度。有些团队为了错峰直接把 TTL 写成Math.random() * 3600范围太大。比如一条 1 小时有效的活动数据随机后变成 5 分钟过期活动还没结束缓存就没了。正确的做法是确定一个基值在基值上加一个 5%~10% 的扰动比如3600 random(0, 300)。坑四序列化和反序列化不一致。用 RedisTemplate 存对象时如果序列化器配置不一致会出现“存进去的是 JSON读出来的是二进制”这种蛋疼问题。更隐蔽的是同一个 key 先被 A 服务以 JDK 序列化写入后来被 B 服务以 JSON 序列化读取直接反序列化失败。这些情况会让缓存命中率突然下降表现出来的样子跟“穿透”一模一样但实际只是序列化问题。排查时记得看 Redis 里的 value 是不是可读的字符串如果用 redis-cli 看到的值是一串\xAC\xED\x00开头的乱码那基本就是序列化器不统一。坑五高并发热点 key 瞬间失效被误判成穿透。某个商品做秒杀一条 key 的读 QPS 到了五万Key 过期的瞬间所有请求同时打入数据库数据库直接崩了。这其实是击穿而不是穿透处理方式不一样。后来我加了互斥锁只让一个线程去查库重建缓存其他线程短暂等待后拿到新缓存问题解决。坑六Redis 高可用方案不完整。生产环境只配了主从没配哨兵主库挂了以后从库不会自动提升Redis 直接不可用。我在排查这类故障时最常用的是redis-cli info replication查看复制状态。更稳妥的做法是直接上 Redis Cluster把 slot 分布做好某个节点挂了其他节点照样提供部分服务。最后说一个我个人的体会缓存穿透和雪崩的治理功夫在平时。不要等到数据库被打挂了才开始设计缓存策略而是在每次接口设计评审里就把“缓存 miss 时数据库能否承受”作为一个必答题。平时多积累监控基线关键时刻才不会抓瞎。Redis 只是工具真正决定系统稳不稳的还是看你有没有把“缓存失效”这件事当成一等公民来设计。
返回列表