Redis缓存三大经典问题:穿透、击穿、雪崩的成因与治理方案
做了这么多年后端Redis 缓存几乎成了高并发系统的标配但真正决定系统能不能扛住流量的往往不是 Redis 本身而是围绕缓存那三个经典问题——“缓存穿透、缓存击穿、缓存雪崩”有没有治理到位。面试的时候大家都能背出定义线上出事故的时候才会发现三个问题看起来相似处理思路完全不同搞混了不但救不了火还可能雪上加霜。这篇文章我打算从一个请求的完整链路开始讲把三个问题各自的发生场景、解决方案、优劣对比连同我实际排查线上故障的过程一起分享出来。无论你是刚接触 Redis 的新人、准备面试的开发者还是正在做缓存治理、想给系统加防护的老手都能在这篇里找到能直接抄作业的思路。1. 先把三个问题放回请求链路里看1.1 一次完整的缓存查询到底经历了什么一个典型的后端查询链路是这样走的请求进来先查 Redis如果命中了就直接返回这一步通常能在毫秒级完成如果没有命中就继续去数据库查查到了再把结果回填到 Redis方便后续请求直接命中。这套设计之所以能扛住高并发核心在于把“绝大多数读请求”都拦截在 Redis 这一层数据库只在缓存缺失时被访问。然而问题恰恰就出在“缓存缺失”这个环节上——穿透、击穿、雪崩本质上都是缓存失效后请求涌向数据库造成的但它们在“为什么失效”和“失效范围多大”上面区别巨大。如果把 Redis 比作前台接待数据库比作后厨的厨师那一次查询就是客人来点菜。正常情况下菜单上有的菜直接从前台拿现货出餐不需要惊动后厨但当前台没现货时就得让后厨现做这时候大量客人同时来点同一道菜或者点一批菜单上根本不存在的菜后厨就会直接被压垮。1.2 一张表看清三个问题差在哪很多人背概念时容易把三个问题混在一起其实用一句话就能区分缓存穿透查了一个数据库里压根不存在的数据比如用一个不存在的商品 ID 反复请求缓存永远不可能命中所有请求都直连数据库。缓存击穿某个热点 key 在过期的一瞬间大量并发请求同时发现缓存没数据于是一起涌向数据库。缓存雪崩要么大批 key 在同一时间段集体过期要么 Redis 实例整体不可用导致数据库中短期内涌入远超正常水平的流量。我用一个表把三者最核心的差异列出来方便对照问题缓存有没有数据数据库里有没有数据失效范围典型后果缓存穿透没有没有单个 key 或一批不存在的 keyDB 被无效请求拖垮缓存击穿瞬间没有有单个热点 key热点数据打爆 DB缓存雪崩大量失效或全失效有大批 key 或整个缓存层系统整体不可用理解了这三个区别后面的所有方案就都有了落脚点。2. 缓存穿透所有请求都打在“空”上2.1 穿透是怎么发生的缓存穿透最常见于两类场景一类是恶意攻击或者爬虫遍历攻击者故意构造大量不存在的 ID 来请求接口另一类是业务自身的异常数据比如前端传了一个错的参数或者新数据还没录入数据库里暂时没有这条记录。不管是哪种场景本质都一样**数据库里没有这条数据所以缓存永远也无法被写入请求就会一直打到数据库。**如果只是偶尔一次数据库完全扛得住但如果是高并发下持续不断地查询不存在的 ID数据库连接池很快就会被耗尽后续所有正常请求也会跟着被拖死。我记得有次线上排查某个列表接口的 DB QPS 突然冲到每秒几万Redis 的 QPS 却几乎为零。查了监控之后发现请求里全是“product-123456789”这种连续递增但根本不存在的商品 ID明显是有人在用脚本遍历接口。2.2 方案一缓存空值给“没有”也留个位置穿透最直接的解法就是把“查不到”这个结果也缓存起来。数据库查不到数据时往 Redis 里写一个空值并设置一个较短的过期时间比如 60 秒或 120 秒。这样同一个不存在的 ID 在短时间内再来请求命中的就是空缓存不会再打到数据库。SET product:123456789 EX 120这个方案的优点是实现极其简单几乎零成本缺点是无效 key 会占用一部分内存而且如果某个 ID 在缓存过期后真的入库了缓存空值期间请求依然拿不到真实数据。所以实操上我一般会做两个细节处理一是空值 TTL 不要设置太长通常 30 到 120 秒就够既能挡住短时间内的大量重复请求又不会让空值长期占坑二是对空值做一个标记比如 value 存一个特殊字符串避免和正常数据混淆也方便监控统计。缓存空值能解决大部分穿透问题但对那种每次都用不同随机 ID 来攻击的场景它的效果就有限了因为攻击者可以绕过缓存不断地制造新的“空”。2.3 方案二布隆过滤器用极小的空间拦住非法 Key如果业务量很大空值缓存就不太够用了这时候布隆过滤器是更靠谱的选择。布隆过滤器的原理可以这样理解它是一个很长的位数组配合多个哈希函数。数据写入时用 k 个哈希函数算出 k 个位置把位数组上这些位置都置为 1。查询时同样算出 k 个位置只要有一个位置是 0就说明这个数据一定不存在如果 k 个位置全是 1那只能说可能存在——因为哈希碰撞可能让别的数据把这些位置填上 1。这个“一定不存在、未必存在”的特性非常契合缓存穿透场景我们可以把所有合法数据的 ID 提前写入布隆过滤器请求进来时先判断 ID 是否“可能存在”如果布隆过滤器判断不存在就直接拦截返回压根不给缓存和数据库增加压力。布隆过滤器最让人关心的两个参数是位数组长度 m 和哈希函数个数 k。m 越长误判率越低k 越多误判率也越低但计算开销会增大。常用估算公式是m -n * ln(p) / (ln2)^2k m / n * ln2其中 n 是预期要存储的数据量p 是期望的误判率。举个例子假设有 1000 万条商品 ID期望误判率 1%那么位数组长度 m 大约是 10000000 * 4.6 / 0.48算下来约 9580 万 bit也就是约 12MB 内存哈希函数个数 k 算下来约 7 个。对 1000 万量级的数据用十几 MB 内存换来对数据库的强力保护性价比非常高。我在实际落地时会用 Redis 官方的 BF 模块一条命令就能创建过滤器并设置容量和误判率BF.RESERVE product_whitelist 0.01 10000000 BF.ADD product_whitelist product:1001 BF.EXISTS product_whitelist product:9999如果没有 BF 模块也可以用 Redis 的 SETBIT/GETBIT 自己实现或者引入 Guava 的 BloomFilter 在应用层做判断。无论用哪种实现有一点必须注意布隆过滤器误判只会把不存在的 ID 误判为存在不会把存在的 ID 误判为不存在所以即使误判最多是多放行几个非法请求到数据库去兜底不会误杀正常用户。因此我通常把布隆过滤器当作第一道拦截真正漏过去的再靠空值缓存和数据库兜底。2.4 穿透方案的取舍与兜底思考在实际项目中缓存空值和布隆过滤器不是二选一而是可以组合使用。我的习惯是先用参数校验把明显非法的请求挡在入口比如 ID 格式不正、不在合法范围之内的直接拒绝再用布隆过滤器拦截不存在的数据最后对少量漏网之鱼用短 TTL 的空值缓存兜底。这个组合方案里还有两个容易被忽略的点一是布隆过滤器初始化时要覆盖全量历史数据如果只是新数据增量写入老数据全被误判为不存在就会出现大量误杀二是过滤器数据要定期重建因为业务数据会越来越多固定容量的过滤器随着已写入数量接近预设容量误判率会明显上升。所以我会在后台加一个定时任务比如每天凌晨用离线数据重建一次过滤器重建期间用旧的过滤器继续提供服务。3. 缓存击穿热点 Key 的“瞬间缺口”3.1 一个热点 key 过期能带来多大风浪击穿这个词很形象缓存就像一面盾牌正常时候整面盾牌保护着数据库但如果某一个关键的点破了个洞所有压力就会瞬间从这个洞灌进去。热点 key 就是那个“关键的点”。比如一个爆款商品的详情页平时每秒可能有几万次请求数据缓存在 Redis 里顶住了绝大部分流量。可一旦这个 key 到了过期时间缓存失效紧接着的一批并发请求同时发现缓存没数据就会一起去查数据库。数据库在那一瞬间要承受几万甚至几十万的并发连接池和 CPU 很容易被打满。击穿和穿透最本质的区别是**击穿时数据库里有数据只是缓存恰好在那一瞬间失效了穿透时数据库里压根就没数据。**解决击穿的核心思路也就很明确要么保证缓存重建的过程中只有一个请求能去查数据库要么让缓存即使过期也尽量不影响读请求。3.2 互斥锁方案只让一个请求去重建缓存互斥锁的思路是把“重建缓存”这个动作变成单线程的。当某个 key 在缓存里查不到时先去获取一把分布式锁拿到锁的那个线程才允许去查数据库并写回缓存拿不到锁的线程不查数据库而是短暂等待后再次查缓存。具体实现上Redis 的 SETNX 命令天然适合做这种事情SET lock:product:1001 1 EX 5 NX在 Java 里配合 Spring Data Redis 或者 Redisson代码大致是这样String value redis.get(key); if (value ! null) { return value; } String lockKey lock: key; boolean locked redis.setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS); if (!locked) { // 没拿到锁说明有别的线程正在重建缓存先等一会儿再读 Thread.sleep(50); return redis.get(key); } try { // 拿到锁之后要再查一次缓存防止锁等待期间别人已经重建好了 value redis.get(key); if (value ! null) { return value; } // 去数据库查回填缓存 value queryDb(key); redis.set(key, value, 2, TimeUnit.HOURS); return value; } finally { redis.delete(lockKey); }这段代码里有几个细节很关键。一是锁一定要设置过期时间防止拿到锁的线程在查数据库时挂了导致锁永远不释放后续所有请求全部阻塞。二是加锁成功后要二次查询缓存因为在高并发下可能存在“A 线程刚释放锁B 线程才拿到锁”的窗口期如果 B 不检查缓存就直接查库等于白白浪费一次数据库查询。三是拿不到锁的线程不要无限等待sleep 一段时间后重新读缓存通常就够了一般几十毫秒内重建请求就能完成。在实际项目中如果服务是分布式的手写 SETNX 要额外处理锁的续期、释放校验等问题我建议直接用 Redisson 的 RLock它内置了看门狗自动续期能省掉不少坑。这也正好解释了为什么 Redis 分布式锁会成为高频问题——它不只是缓存击穿的解决方案本身就是一个需要严谨设计的工具。3.3 逻辑过期方案用旧数据换可用性互斥锁有个天然缺点:如果热点数据重建比较慢或者并发量实在太大大量没有拿到锁的线程都阻塞在等待上接口的响应时间会整体拉长。对读多写少、对实时性要求不那么苛刻的场景逻辑过期是更优雅的做法。逻辑过期的核心设计是缓存不设置物理过期时间而是在缓存 value 中额外存一个逻辑过期时间戳。读取的时候判断时间戳是否过期如果没过期直接返回数据。如果已过期先尝试获取分布式锁。拿到锁的线程去数据库查询并重建缓存重建期间其他请求不需要等待直接返回旧的缓存数据。用一个简单的结构来表示// value 里存的是数据和过期时间的组合 class CacheWrapper { Object data; long expireAt; }读取逻辑CacheWrapper wrapper redis.get(key); if (wrapper null) { // 缓存完全没有此时还是需要加锁重建 return rebuildWithLock(key); } long now System.currentTimeMillis(); if (now wrapper.expireAt) { return wrapper.data; } // 逻辑上已过期尝试加锁异步刷新 boolean locked redis.setIfAbsent(lockKey(key), 1, 5, TimeUnit.SECONDS); if (locked) { executor.submit(() - { try { Object dbValue queryDb(key); redis.set(key, new CacheWrapper(dbValue, now 2 * 3600 * 1000)); } finally { redis.delete(lockKey(key)); } }); } return wrapper.data; // 先返回旧数据这个方案的精妙之处在于缓存永远不会被“物理清空”读请求在任何时刻都能拿到数据只是偶尔拿到的是旧数据。对商品详情、榜单这类对秒级延迟不敏感的业务这个代价完全可接受对金额、库存这类强一致数据则不适合这样做。这里还需要注意序列化问题CacheWrapper 里既有业务字段又有过期时间字段如果使用 JDK 默认序列化存进 Redis 的是一大串二进制可读性很差也容易在类结构变更时报错。我建议统一用 JSON 序列化并在反序列化时显式指定类型避免出现类型转换异常。像 Another Redis Desktop Manager 之类的可视化工具里用 JSON 存储的数据也更容易排查问题。3.4 互斥锁和逻辑过期怎么选这两个方案没有绝对的好坏关键看业务对一致性和可用性的要求我通常按下面几个维度来决策维度互斥锁逻辑过期数据一致性高缓存重建期间请求会等待低过期后可能短暂读到旧值响应时间重建期间可能变慢始终稳定实现复杂度简单好理解中等需要额外的过期时间维护适用场景一致性敏感、数据量大的场景读多写少、实时性要求不高的场景如果数据库查询本身很快几十毫秒以内我倾向互斥锁因为即使几十个请求在等待整体影响也不大如果查询耗时超过几百毫秒或者接口峰值 QPS 极高那逻辑过期能明显降低接口耗时抖动。4. 缓存雪崩一锅端的灾难4.1 雪崩的两层含义要分清缓存雪崩比前两个问题严重得多它有两种典型成因。一种是批量 key 同时过期比如系统里大量缓存都设置了同一个过期时间某天某个整点大家一起失效瞬时流量全部压向数据库另一种是 Redis 实例整体宕机或者网络分区整个缓存层彻底不可用数据库被所有流量直接打穿。很多聊雪崩的文章只讲到了第一种但实际线上最怕的是第二种。Redis 作为缓存层的核心一旦不可用后面数据库几乎必然过载数据库一挂整个服务链路的故障就会像滚雪球一样蔓延。所以治理雪崩不能只盯着 TTL 做文章还要从缓存层自身的可用性出发做高可用设计。4.2 拆散过期时间从源头上避免“集体阵亡”批量 key 同时过期这个问题的解法最直接不要让大量 key 的 TTL 相同人为加入随机偏差。假设某个业务把所有缓存的 TTL 都设计成 2 小时如果有 100 万个 key 在同一个时刻写入缓存那么 2 小时后的同一分钟就会有 100 万个 key 一起过期。解决方法是设置 TTL 时加一个随机值比如// 基础过期时间 2 小时随机追加 0 到 300 秒 int baseExpire 2 * 3600; int randomExtra new Random().nextInt(300); redis.set(key, value, baseExpire randomExtra, TimeUnit.SECONDS);这样一来这批 key 的过期时间被分散到 2 小时之后的 5 分钟范围内同一时刻过期的 key 数量大幅减少数据库的压力曲线就平滑多了。随机范围的选择需要结合业务并发展开我的经验是随机值占基础 TTL 的 5% 到 10% 比较合适范围太小没有分散效果范围太大会让部分 key 过早过期增加不必要的数据库查询。另外一个进阶做法是“热点 key 不过期”。对真正核心的热点数据干脆不设置过期时间由后台定时的任务主动刷新缓存。这里要注意的是刷新任务的频率和数据库压力要平衡比如每 5 分钟批量刷新一次热点缓存既能保证数据新鲜度又不会把数据库压垮。如果怕单个 key 长期不过期导致数据错误可以采用双 key 策略主动更新的 key 用旧数据返回新数据先写入备用 key切换后再更新。4.3 多级缓存把压力层层堵住即便我们把过期时间打散了还是无法解决 Redis 整体不可用时的雪崩风险。这时候多级缓存的价值就体现出来了在 Redis 前面再挡一层本地缓存。常见的分层是应用本地缓存比如 Caffeine- Redis - 数据库。本地缓存放在应用进程内读取速度最快不依赖网络Redis 挂了它也照常工作。像浏览器缓存、CDN 也是同样的思路——靠近用户的地方先挡一波流量越往下层压力越小。我之前在一个电商项目里就是这么做的商品详情先查 Caffeine 本地缓存TTL 设置为 60 秒本地缓存没命中再查 RedisTTL 设置为 2 小时Redis 也没命中才查数据库。由于大部分读请求直接命中本地缓存Redis 一旦宕机虽然本地缓存只有 60 秒的有效期但这 60 秒足够让监控告警触发、运维介入或者让降级开关生效不会让数据库在第一波冲击中被打垮。多级缓存的核心代价是数据一致性变差了。本地缓存的数据可能比 Redis 旧几十秒但只要业务能容忍短暂的不一致这个代价就是可控的。还需要注意本地缓存是每个应用节点各存一份的节点数量越多重复缓存的数据量越大对内存的消耗也要在设计时估算进去。4.4 高可用集群和限流降级最后一层防线多级缓存是在 Redis 挂了之后降低影响但前提是 Redis 本身要尽量少挂、挂了也要能快速恢复。这就要靠部署层面的高可用方案了主从复制加哨兵是最常见的架构主节点写入从节点同步主节点宕机后哨兵自动把从节点提升为主节点数据量更大、并发更高的场景可以上 Redis Cluster用分片把压力和故障面分散到多个节点上。关于主从搭建网上搜 docker 安装 redis 主从的资料很多我自己的建议是生产环境不要贪图省事直接跑单节点容器至少要做成一主一从或者一主两从的哨兵架构。开发环境用 Docker 快速起一个单节点验证思路没问题但线上环境的主从切换、持久化策略、故障演练都要提前验证不能等到出事才想起测试。有了高可用还不够雪崩时如果数据库还是扛不住我们就需要主动放弃一部分请求来保护核心业务这就是限流降级。做法上可以在网关层对查询接口设置 QPS 阈值超过阈值直接返回降级数据或者提示稍后重试对商品详情这类读接口可以配置服务端限流数据库查询的并发数控制在一个安全范围内。有些团队用 Hystrix 或 Sentinel 做线程池隔离和熔断当数据库调用失败率超过阈值时熔断器打开后续请求不再查数据库而是直接走降级逻辑。这个动作的本质是“牺牲一部分体验保住整个系统不崩”。5. 监控与排查提前发现而不是事后救火5.1 三个监控指标能救命缓存出问题往往就是几分钟的事等接口报错再去看日志就晚了。我建议核心系统至少盯住下面三个指标并且配置好告警阈值Redis 命中率命中的请求量除以总请求量。正常情况下应该稳定在 90% 以上如果某段时间命中率突然掉到 60% 以下说明缓存大面积失效不是被穿透就是被批量过期。数据库 QPS正常情况下数据库 QPS 是平稳的如果出现脉冲式暴涨和 Redis 命中率下跌同时发生基本可以判定是缓存层出了问题。Redis 键过期数量在 Redis 里可以用 INFO stats 查看 expired_keys 的变化速率。如果某个时间段过期的 key 数量突增说明 TTL 设置可能集中了。有了这三个指标至少能在用户感知之前先发现问题。配合可视化管理工具比如 Another Redis Desktop Manager可以快速查看一批 key 的 TTL 分布定位是不是有大量 key 集中在同一时间过期。5.2 一次疑似雪崩的排查实录有次凌晨大促监控突然报警数据库主库的 CPU 冲到 90%我当时的第一反应就是看 Redis 命中率——果然从正常的 95% 掉到了 40%。官方监控图上Redis 过期的 key 数量在那个时间点出现了一个尖锐的峰值。然后我立刻连上 Redis 去查 TTL 分布情况。用 SCAN 命令采样了一万个 key发现大量 key 的剩余过期时间都在同一分钟内归零几乎清一色都是 2 小时之前整点写入的。这就确认了是批量过期导致的雪崩而不是黑客攻击。当时的临时对策是先把热点接口的降级开关打开让数据库查询限流同时对存量 key 设置一个随机的 TTL 延长把集中过期拆散。之后用 5.3 的公式把所有批量写入缓存的 TTL 都加上了随机偏差再配合本地缓存挡一层后面大促时段数据库 QPS 的尖峰明显平滑了。这次排查给我的启发很大雪崩不是一瞬间爆发的之前一定有很多预警信号只是我们没有盯住。命中率监控 TTL 分布检查 过期速率观察这三个动作组合起来能覆盖绝大多数缓存失效类故障。5.3 常见问题速查表平时团队里新同学遇到缓存问题我经常直接把下面的表格丢给他们按图索骥比翻书快得多现象可能原因排查手段处理办法Redis QPS 正常但 DB QPS 猛增大量 key 同时过期查看过期 key 数量峰值、TTL 分布TTL 加随机值、热点 key 不过期、多级缓存DB QPS 猛增且查询的是不存在的 ID缓存穿透分析请求参数、看缓存空值是否存在参数校验、布隆过滤器、缓存空值同一个热点 key 过期瞬间 DB 被压垮缓存击穿查看该 key 的访问量和过期时间互斥锁、逻辑过期Redis 完全不可用、服务大面积报错缓存雪崩宕机检查 Redis 节点状态、连接数主从哨兵、Redis Cluster、多级缓存、限流降级缓存数据频繁不一致多级缓存 TTL 冲突对比本地缓存和 Redis 的数据调整各级 TTL、主动失效时同步清理本地缓存这个表格看起来简单但在线上排查时却非常实用。遇到缓存问题先按表格确诊是哪个问题再决定用哪套方案不要一上来就试各种手段方向错了反而会扩大故障。做缓存治理这几年我最大的体会是Redis 本身很稳定出问题的永远是我们怎么用它的策略。穿透、击穿、雪崩这三个问题本质上都是在回答“缓存失效时系统该怎么办”。把布隆过滤器、空值缓存、互斥锁、逻辑过期、TTL 随机化、多级缓存、高可用集群这些工具用对地方你的系统才能真正在流量洪峰里坐得住。最后再分享一个小技巧上线缓存方案之前可以自己写个脚本模拟一波热点数据失效的并发压测把数据库连接池监控打开亲眼看看峰值曲线比读十篇博客都管用。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →