ARTICLE DETAIL

资讯详情

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

缓存穿透、击穿、雪崩:原理剖析与高并发场景下的工程解法和选型

缓存穿透、击穿、雪崩:原理剖析与高并发场景下的工程解法和选型 后端开发做久了Redis 绕不开一个灵魂拷问缓存穿透、缓存击穿、缓存雪崩到底怎么区分怎么解决。我最早听到这三个词是在一次线上事故复盘。凌晨流量一上来数据库连接数瞬间被打满接口超时一大片最后定位到的原因很简单——一批恶意请求在用不存在的商品 ID 疯狂打接口Redis 里查不到MySQL 被拖下水整个商品服务直接打挂。那次之后我把这三个问题从前到后研究了个遍网上资料说得都挺全但真正落到代码和线上排查时还是有不少细节值得捋一捋。今天这篇就专门聊缓存篇最核心的三大难题穿透、击穿、雪崩的原理、解决方案以及我在实际项目里的选型取舍。不管你是准备面试还是刚接手高并发项目这套思路都能直接拿过去用。1. 三大缓存问题先分清谁是谁很多人在刚接触这三个概念时会绕晕因为名字太像了而且都和“缓存失效”沾边。我的经验是先把场景区分清楚后面的方案自然就记住了。1.1 穿透、击穿、雪崩名字相近但死法不同用生活里的场景打个比方。缓存穿透就像你家楼下有张门禁表上面登记了住户名单结果总有骗子报一个并不存在的住户名字保安每次都认真去楼里找一圈才告诉你没这人骗子多了保安累死。落到系统里就是“查询的 Key 在缓存和数据库里都不存在”每次请求都穿过了缓存这道防线直接打到数据库。缓存击穿场景更具体一些。某个热点 Key 正被人疯狂访问比如秒杀商品、微博热搜词条结果这个 Key 在某个瞬间过期了于是大量并发请求同时发现缓存没有全部冲进数据库去查。数据库本来扛得住单次查询但扛不住几万个查询同时涌入。缓存雪崩则是范围更大的灾难。它不是某一个 Key 过期而是一大批 Key 在同一时间段内集中失效。比如你用定时任务给所有商品设置 30 分钟过期时间那么每隔 30 分钟就会有一波“集体过期”如果这个时间点恰好赶上了流量高峰数据库会被瞬间打满。更极端的场景是 Redis 服务本身宕机连缓存都不可用了全部流量直接打到下游存储。1.2 一张表看懂三者的核心差异我面试候选人的时候经常让他们用一句话说清三者的区别。如果只谈模糊印象说明没吃透。这里用表格直接对比维度缓存穿透缓存击穿缓存雪崩故障原因查询的数据根本不存在单个热点 Key 突然过期大量 Key 集中过期或缓存服务不可用影响范围通常是无序的单个请求集中在某一个高热度 Key大面积数据同时失效数据库表现QPS 被不存在的数据打高单 Key 对应的 DB 查询并发暴涨整体 DB 负载瞬间飙升本质缓存没起到拦截作用缓存重建缺少并发控制缓存失效时间设计不合理解决重心拦截无效请求控制并发重建打散失效时间 服务降级这个顺序也正好对应了从“缓存设计”到“系统架构”的递进。穿透考验的是你对非法请求的识别能力击穿考验的是并发控制手段雪崩考验的是整体容灾思路。下面逐个展开讲。2. 缓存穿透请求根本不存在的 Key穿透是最常见、也最容易复现的问题。很多团队一开始没在意等线上出事才发现原来空 Key 也会打死数据库。2.1 穿透的根因与危害穿透的根源是缓存没有命中数据库也没有这个数据于是这个“查无此物”的请求每次都完整地走了一遍主链路。正常业务里这种情况不致命因为量少。但两类场景会被放大。一类是恶意攻击外部调用方想拖垮你的服务就专门构造不存在的 ID 来刷接口比如订单号、手机号这种规律性强的字段另一类是业务逻辑存在缺陷例如用户上传了一个错误的 ID或前端缓存了已删除的商品信息导致请求持续打到后端。危害很明显数据库连接是稀缺资源每一条 SQL 都要占用连接、CPU、磁盘 IO。缓存穿透时Redis 查不到并不意味着 MySQL 无压力恰恰相反因为 Redis 只是快速放行MySQL 才是真正被轰炸的对象。如果数据库连接池或者慢查询有瓶颈一个接口被刷就可能连累整个应用。2.2 能拦就拦接口层的参数校验面对穿透问题我的排查顺序从来都是第一步先看参数校验中间层有没有漏。比如商品 ID 有固定规则如纯数字、最小 1、最大 100 万那就应该在 Controller 层或网关层直接拦截掉非法的 ID。有些系统还会给 ID 加上签名、hash 或时间戳校验根本不给伪造请求进入服务的机会。参数校验的好处是零成本、零延迟还能顺带防止不少安全攻击。但真正的有效做法还在后面因为恶意攻击者可以伪造“合法格式”的 ID比如 99999999 这种位数正确但实际不存在的值。所以参数校验只是一道前置过滤不能作为穿透的兜底方案。2.3 常规兜底缓存空值参数校验挡不住的情况下业界最常规的做法是把空值也缓存起来。写过 Redis 代码的同学应该很熟悉这类逻辑public String getProduct(Long id) { String redisKey product: id; String value redisTemplate.opsForValue().get(redisKey); if (value ! null) { return value; } // 缓存中没有去数据库查 String product queryDbById(id); if (product null) { // 数据库也没有缓存一个空值并设置较短过期时间 redisTemplate.opsForValue().set(redisKey, , Duration.ofMinutes(3)); return null; } redisTemplate.opsForValue().set(redisKey, product, Duration.ofMinutes(30)); return product; }这段代码看起来简单但有几个细节值得说道。第一空值的 TTL 一定不能太长建议 35 分钟否则大量不存在的 Key 会占满 Redis 内存第二要判断“整个商品数据都不存在”还是“商品存在但内容为空”不要混淆不然会把有效数据误判成空第三如果系统里空 Key 特别多可以考虑将空 Key 单独放一个 Redis 逻辑库或一个专门前缀方便后续清理。缓存空值适合绝大多数中小团队优点是实现简单、几乎不改架构缺点也明显如果攻击方构造的 ID 无规律且数量巨大空值缓存会持续堆积内存占用不可控。所以它处理的是“低频空查询”扛不住“恶意流量轰炸”。2.4 高并发下的布隆过滤器当穿透流量大到一定程度就需要换一种思路从源头判断某个 Key 到底存不存在。布隆过滤器就是为这个场景设计的。布隆过滤器的原理可以用一句话概括一个很长的位数组加上多个哈希函数。插入数据时把数据经过多个哈希函数算出多个位置把这些位置置为 1查询时只要发现任何一个位置是 0就说明这个数据一定不存在如果所有位置都是 1说明数据可能存在。你可能会问为什么不是“一定存在”因为多个数据可能哈希到相同位置产生冲突导致一个不存在的 Key 把所有位置都撞成 1这个就是误判率。布隆过滤器允许这种误判但它的优点在于绝对不会漏判——判断为不存在的数据是真的不存在。工程上最好用的方式是使用 Redisson 自带的布隆过滤器或者 Redis 的 RedisBloom 模块。如果项目里已经有 Redisson用起来很顺手RBloomFilterString bloomFilter redissonClient .getBloomFilter(productBloomFilter); // 预估数据量为100万期望误判率为1% bloomFilter.tryInit(1000000L, 0.01); // 项目启动或商品ID变更时把有效的商品ID都初始化进过滤器 for (Long id : allValidProductIds) { bloomFilter.add(String.valueOf(id)); } // 查询接口中先用布隆过滤器判断 public String getProduct(Long id) { if (!bloomFilter.contains(String.valueOf(id))) { // 这个ID肯定不存在直接返回 return null; } // 后面走正常的 Redis - DB 查询逻辑 }布隆过滤器最怕两件事一是初始化阶段没把全量数据灌进去二是数据真删除了但过滤器里仍然保留着旧 ID导致这部分删除数据始终能穿透到数据库。通常的做法是布隆过滤器只负责“大概率拦截”同时配合空值缓存做二次兜底既挡掉大部分攻击流量又把误判产生的少量穿透用短 TTL 的空值给抚平。2.5 我实际项目里的选型我接手过的几个项目里一开始都用的缓存空值方案原因是简单、代码量小。后来有一个项目被黑产盯上对方专门用递增 ID 扫我们商品库空值缓存积累了一个量级。我们最后切换成布隆过滤器加缓存空值的组合效果非常明显数据库 QPS 降了一个数量级。如果你在评估方案我的建议是业务量不大、没有明显恶意流量时空值缓存完全够用如果存在接口被刷的风险或者你们已经遇到过一次穿透事故那直接上布隆过滤器别犹豫。还有一个小细节布隆过滤器要预留足够的容量否则随着数据增长误判率会迅速上升过滤器形同虚设。3. 缓存击穿热点 Key 过期的那几毫秒如果说穿透是把“不存在”的数据拦截在缓存之外那么击穿面对的则是“存在但缓存刚好没电”的瞬间。问题是这个瞬间往往就是流量最高的瞬间。3.1 击穿的本质和触发条件击穿发生的三要素某个 Key 是热点数据、这个 Key 有固定过期时间、在过期那一刻有大量并发请求。典型的场景是微博热搜。一个词条冲上热搜短时间内被读取几十万次缓存里明明有数据突然到了 TTL 时间缓存过期了。此时如果有几百个线程同时去数据库查询该词条的信息数据库单表查询确实很快但连接数和并发线程数会瞬间飙升慢查询和连接池耗尽随之而来。很多人把击穿和穿透混淆区别就在这里击穿的数据是真实存在的数据只是缓存恰好在高并发时刻失效了穿透的数据是根本不存在的 Key。3.2 互斥锁让数据库只被请求一次击穿的核心矛盾是多个线程同时发现缓存为空于是同时去数据库查询而这些查询是重复的。最好的办法是在“发现缓存为空”和“去数据库查询”之间加一把锁保证同一时刻只有一个线程去查数据库并回填缓存其他线程等待。Redis 里的 setnx 就是天然实现分布式锁的工具Sring 项目集成 RedisTemplate 后可以这样写public String getProduct(Long id) throws InterruptedException { String redisKey product: id; String value redisTemplate.opsForValue().get(redisKey); if (value ! null) { return value; } // 尝试获取分布式锁30秒后自动过期 String lockKey lock:product: id; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(30)); if (!locked) { // 没拿到锁说明别的线程正在重建缓存休眠后重试 Thread.sleep(50); return getProduct(id); } try { // 拿到锁后二次检查防止拿到锁之前缓存已经被重建 value redisTemplate.opsForValue().get(redisKey); if (value ! null) { return value; } // 真正回源数据库查询 value queryDbById(id); redisTemplate.opsForValue().set(redisKey, value, Duration.ofMinutes(30)); return value; } finally { // 释放锁 redisTemplate.delete(lockKey); } }这段代码有几个注意点。第一拿到锁后一定要做“二次检查”因为可能在等锁的过程中第一个线程已经把缓存回填了。第二锁的过期时间要大于“数据库查询 回填缓存”的总耗时如果查询很慢锁提前过期后面线程又会涌进来击穿问题就退化成穿透问题。第三释放锁时要校验是不是自己加的锁防止自己的锁被别人删掉。我的习惯是 setnx 的 value 放一个 UUID释放前对比一下再删除。互斥锁方案的优点是强一致性缓存一定是在数据库查到最新值后回填不会出现旧数据缺点是集中式的锁机制在高并发下可能成为性能瓶颈而且如果热点特别放大大量线程都在等锁。3.3 逻辑过期缓存里存的不只是数据还有一种用得越来越多的方案叫逻辑过期。它不在 Redis 层面设置 TTL而是把过期时间作为一个字段存进缓存对象由业务代码自己判断这个缓存是否过期。大致思路是这样public class CacheDataT { private T data; // 真正的业务数据 private long expireTime; // 逻辑过期时间戳 }查询的时候public String getProduct(Long id) { String redisKey product: id; CacheDataString cacheData getFromRedis(redisKey); if (cacheData null) { // 不是逻辑过期而是缓存彻底不存在说明Key没建过或被手动删除 String value queryDbById(id); saveToRedis(redisKey, value, 30 * 60 * 1000L); return value; } if (cacheData.getExpireTime() System.currentTimeMillis()) { // 逻辑上还没过期直接返回 return cacheData.getData(); } // 逻辑过期这里有两个选择 // 1. 直接返回旧数据同时异步更新缓存 // 2. 尝试获取锁由持有锁的线程去更新缓存 executor.execute(() - rebuildCache(id)); return cacheData.getData(); }逻辑过期最大的好处是可用性。缓存里的数据即使逻辑过期了用户拿到的还是旧数据不会出现缓存击穿导致数据库压力暴涨的情况。代价是会短暂读到过期数据适合对一致性要求不算极端的场景比如商品详情、用户资料、文章页。实际开发中逻辑过期常和“缓存写更新”配合。比如后台改了商品价格先更新数据库再主动刷新 Redis 里的缓存把逻辑过期时间顺延。这样缓存中的数据理论上是新的逻辑过期时间只作为一个兜底红线。3.4 永不过期 异步刷新有同学会问干脆把热点 Key 的 TTL 设置成永不过期行不行我告诉你真实项目里很多人就是这么干的但方法是“物理不过期逻辑会过期”。具体做法是把 Redis 里的 Key 不设置 TTL同时用定时任务或延迟消息定期去更新这个 Key。比如每 5 分钟自动刷一次热点数据数据更新后主动推掉缓存。这类方案的优点是彻底规避击穿问题因为缓存永远存在不会出现并发回源缺点是热点数据更新的实时性依赖刷新任务的频率如果刷新任务挂了缓存内容就会一直旧下去。所以线上一般会加一层监控预热任务失败时触发告警。我见过最稳定的配置是“永不过期 双写 兜底刷新”。“双写”指收到数据变更消息后立即刷新缓存“兜底刷新”指定时任务周期性地把数据库里的最新数据回填到缓存中。这个组合可以覆盖大多数业务场景。3.5 三种方案怎么取舍方案一致性可用性实现复杂度适用场景互斥锁强一致一般中一致性要求高缓存重建较快逻辑过期短暂不一致高中偏高允许短时间读旧数据高并发热点永不过期 异步刷新取决于刷新频率最高高核心热点数据数据变化不频繁我的经验是大部分项目首选互斥锁因为它能保证“不漏过数据库”而且代码审计容易过当性能压测发现锁竞争激烈时再考虑逻辑过期或永不过期方案。如果你在面试时被问到最好把这些方案都讲一遍然后结合项目场景给出自己的选择理由。4. 缓存雪崩大批 Key 在同一时刻失效如果说击穿是“单兵作战”雪崩就是“全面崩溃”。当天量的 Key 同时失效数据库会在短时间内涌入平时几十倍的流量大概率直接被打挂。4.1 雪崩与击穿的分界线很多初学者分不清击穿和雪崩我这里给一个判断标准看影响的 Key 的范围。击穿是单个热点 Key雪崩是一大批 Key甚至整个缓存服务不可用。雪崩的产生通常有两个层面。一个是缓存层自身的设计问题设置了固定过期时间导致大量 Key 在同一时刻批量过期或者定时任务在整点更新一批数据引发了“集体失效”。另一个是基础设施层面的问题Redis 集群宕机连接全部失败高并发系统里的缓存保护变成空谈所有流量直冲数据库。第二个层面已经超出了“缓存过期策略”的范围属于高可用架构要解决的事所以我在 4.4 会重点讲服务降级。4.2 过期时间打散最便宜的一招不管雪崩怎么升级最基础的手段永远是给 TTL 加上随机偏移量。这也是我入职任何项目最先检查的点。看这段代码// 不推荐每隔30分钟集中失效 int expire 30 * 60; redisTemplate.opsForValue().set(redisKey, value, Duration.ofSeconds(expire)); // 推荐TTL基础上加060秒的随机值 int expire 30 * 60 new Random().nextInt(60); redisTemplate.opsForValue().set(redisKey, value, Duration.ofSeconds(expire));原理很简单让每个 Key 的过期时间不完全相同把集中失效的时间点打散。不要小看这个随机值它可以在几乎零成本的情况下让数据库的最高瞬时压力降一个量级。如果需要多级缓存这一招同样适用。比如“本地缓存 Redis”组合如果本地缓存是 5 分钟失效Redis 是 30 分钟失效也要注意不要让本地缓存全部在同一秒失效否则会引发缓存穿透。业界有一种“双失效时间”策略本地缓存设上限比如 5 分钟但每次取缓存时随机加一个 160 秒的慢启动时间保证缓存不会团灭。4.3 多级缓存层层设防只靠 Redis 一层风险还是太大。更稳妥的做法是引入本地缓存如 Caffeine、Guava Cache 或 Ehcache让一部分请求只经过 JVM 内存根本不去访问 Redis。我的策略是两层缓存都检查先查 Caffeine再查 Redis最后才是数据库。// 本地缓存最大5分钟容量10万 CacheString, String hotCache Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.MINUTES) .maximumSize(100_000) .build(); public String getProduct(Long id) { String key product: id; // 第一层本地缓存 String localValue hotCache.getIfPresent(key); if (localValue ! null) { return localValue; } // 第二层Redis String redisValue redisTemplate.opsForValue().get(key); if (redisValue ! null) { // 回填本地缓存 hotCache.put(key, redisValue); return redisValue; } // 第三层数据库 String dbValue queryDbById(id); redisTemplate.opsForValue().set(key, dbValue, Duration.ofMinutes(30)); hotCache.put(key, dbValue); return dbValue; }多级缓存带来的直接好处是Redis 挂了之后还有本地缓冲顶着数据库不会被瞬间打穿。本地缓存的命中率可能不高但对于热点数据来说效果立竿见影。需要注意的是本地缓存和 Redis 之间会存在数据一致性问题。同一个应用实例里的本地缓存是独立的数据更新时要双写一个线程更新数据库另一个线程同步清掉 Caffeine 里的 Key或重置 TTL。如果更新不及时用户可能会读到旧数据。所以本地缓存更适合读多写少的场景。4.4 限流、降级与熔断保命的三件套当雪崩真的发生Redis 也好、多级缓存也好都可能已经沦陷。这个时候最核心的任务不是追求数据完整而是保证系统不挂给用户一个可接受的响应。这时候就要用上流量治理三板斧限流、降级、熔断。我用阿里的 Sentinel 或者 Spring Cloud 体系的 Hystrix 都能实现思路是一致的。限流负责控制进入数据库的流量比如只允许每秒 1000 个请求通过超过的直接返回一个“系统繁忙”或兜底数据熔断负责在下游数据库异常时快速失败避免请求长时间占用线程池降级则是主动放弃非核心功能比如展示一个静态页或默认文案保证核心链路可用。最常见的做法是在缓存查询失败或超时后面加一个兜底方法public String getProductFallback(Long id) { // 当Redis不可用或查询超时时返回本地预制的默认数据 // 或者从ES、HBase等其他存储读取也可以直接返回null return localStaticMap.getOrDefault(defaultProduct, 商品信息加载失败); }我见过不少事故其实数据库并没有挂只是流量太大导致连接池满了然后慢查询堆积最终把数据库拖死。提前加上限流和降级能在雪崩初期就掐住最危险的流量入口。这是所有高并发系统最后一道防线一定不能省。4.5 缓存预热大促前必修课雪崩很多时候是“自己踩出来的坑”。比如大促开始前开发同学往 Redis 里写入一批商品的 30 分钟缓存结果大促一开始这批缓存齐齐失效数据库就遭殃了。缓存预热的本质是在流量高峰到来之前把可能要访问的数据提前加载到缓存中并设置好合理的过期时间。同时要在预热阶段检查是否有“同一时间过期”的情况。大型活动上线前我的标准操作是这样的梳理活动期间可能被高频访问的 Key 的清单分批写入缓存每一批的过期时间加一个随机偏移量使用脚本统计这些 Key 的过期时间分布确保不会集中在同一秒在流量峰值前的 10 分钟再统一刷新一次热点数据容量预估后决定是否要提前扩容 Redis 集群。这些动作看起来琐碎但往往能避免一场事故。预热不是简单地把缓存填满而是把“过期时间设计”和“流量预期”都考虑进去。5. 线上排查实录与避坑指南理论讲完实操才是关键。很多同学背了一堆方案但线上真出现问题连是哪种缓存问题都判断不出来更别提定位原因了。5.1 出问题后怎么快速定位是哪一种我一般会这样排查第一步看监控指标。如果数据库 QPS 瞬间飙升但 Redis 的 QPS 没有明显变化说明大量请求没有走缓存逻辑或者穿透到了数据库。如果 Redis 的 QPS 也飙升但 Redis 的 Key 数量没有同步增长很可能是热点 Key 过期引发击穿。第二步看日志。穿透的日志特征是“同一类不存在的 Key 反复请求”击穿表现为“某个热点 Key 短时间内出现大量回源日志”雪崩则是“大面积 Key 的 miss 率突然拉高”。第三步看 Redis 慢日志和 Key 分布。用 redis-cli 执行 SLOWLOG GET 可以获取慢命令这些慢命令往往能帮你找出是不是某些超大 Value 导致网络传输阻塞。再用 SCAN 命令统计过期 Key 的数量和时间分布能快速确认是不是集体失效。现象可能原因重点排查对象数据库 QPS 高Redis QPS 无明显波动缓存穿透参数校验、不存在的 Key 比例某一个业务接口在固定时间点延迟飙升缓存击穿该接口对应的热点 Key 过期情况大量接口同时超时Redis 连接报错缓存雪崩过期时间策略、Redis 集群状态5.2 最容易踩的两个坑第一个坑是缓存重建耗时太长。互斥锁方案里如果数据库查询需要 2 秒而锁的过期时间设置成 1 秒后面的请求就全跟着遭殃。我们曾有业务场景是商品详情接口需要聚合多个服务的数据回填缓存可能要 500 毫秒如果锁过期时间只设 300 毫秒压测时直接废掉。经验值是锁过期时间 正常回源耗时的 2 倍并加一个 500 毫秒的 buffer。第二个坑是忽略“有 Key 但 Value 为空”的情况。很多同学针对穿透场景只做了布隆过滤器没做空值缓存结果布隆过滤器误判的请求还是全打到数据库。正确的做法是两道防线一起上布隆过滤器挡掉肯定不存在的空值缓存抚平误判造成的少量穿透。还有一个小坑容易被忽略用opsForValue().set(key, value)不设置过期时间。这样 Redis 内存会持续增长等到内存被打满触发淘汰策略或 OOM那就不是缓存问题了是整个 Redis 服务不可用。5.3 面试中怎么答这部分内容如果你在准备面试建议按照“一句话定义 → 触发场景 → 解决方案 → 项目实践”的顺序来回答。不要上来就背布隆过滤器先把场景讲清楚让面试官确认你理解到位。我面试时会重点考察候选人是否理解“穿透、击穿、雪崩三者的本质区别”。比如问到击穿我一定会追问为什么互斥锁里要做二次检查为什么锁要用 UUID 来标识这些细节比背概念更能体现真实经验。回答时如果能把“缓存空值 TTL 不能太长”“布隆过滤器有误判率”“锁过期时间要大于回源耗时”这类细节带出来面试官基本会认定你是有实战经验的人。最后分享一个我个人的习惯。每次上线前我会把项目里的缓存 Key 过期时间都扫一遍是否加了随机值、是否存在热点 Key 定时失效、缓存重建是否加锁。这三个问题只要都检查到位就能绕开 90% 的缓存事故。缓存设计本身不复杂但细节决定了线上能不能安稳运行。希望这篇总结能帮你少踩几个坑。
返回列表