ARTICLE DETAIL

资讯详情

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

Redis缓存穿透、击穿与雪崩:原理剖析与实战解决方案

Redis缓存穿透、击穿与雪崩:原理剖析与实战解决方案 做后端这几年Redis 缓存三大经典问题——缓存穿透、缓存击穿、缓存雪崩——几乎是绕不开的坎。只要系统扛过线上流量、聊过缓存治理或者准备过 Redis 面试题这三兄弟的名字一定听过。但把口诀背下来和真正动手解决过是两件完全不同的事。这篇文章我不打算只念概念而是结合项目里的实际处理方式把三个问题从成因、危害到落地方案完整拆一遍顺带把布隆过滤器、分布式锁、逻辑过期这些相关套路的使用边界说透。适合刚接触 Redis 的初级开发也适合准备面试复盘的进阶同学。1. 缓存三兄弟先搞清问题到底长什么样1.1 一个缓存系统的正确工作流程聊三大问题之前先把正常的缓存流程摆清楚。绝大多数业务系统用的是 Cache Aside 模式也就是旁路缓存读请求先查 Redis命中就直接返回没命中就回源查数据库查完再把结果写回 Redis并设置一个过期时间 TTL。写入时先更新数据库然后删掉或者更新缓存。这个流程看起来很简单但它的核心假设是缓存能挡掉绝大部分请求数据库只处理少量未命中的回源。只要命中率足够高系统就能扛住高并发。而三大问题的共通点都在于——大量请求在缓存里查不到数据同时涌向数据库。正常流量下可能只是偶发一旦业务量上来或者被人为恶意构造DB 的连接池、CPU、磁盘都要被打爆。理解了这条基线再去看穿透、击穿、雪崩其实就是三种不同的“缓存失效”形态一个是查了不存在的 key一个是单个热点 key 过期一个是一大片 key 同时失效。三者危害不同手段也完全不同。1.2 三大问题的核心区分与通俗类比先上一个对照表把三个问题一次性区分清楚问题触发条件缓存与 DB 的情况造成的影响缓存穿透查询一个缓存和数据库都不存在的 key缓存没有DB 也没有DB 承受大量无效查询可能被拖垮缓存击穿单个热点 key 过期的瞬间缓存没有DB 有大量并发打到 DB单点压力巨大缓存雪崩大量 key 同时过期或 Redis 整体不可用缓存大面积失效DB 有DB 瞬间被压垮甚至引发级联故障用生活化类比帮助记忆穿透像是有人去商店找一个根本不存在的商品店员每次都得跑到仓库翻一遍翻了一天才发现货架上压根没这东西。恶意请求只要循环刷不存在的 ID你数据库就一直在做无用功。击穿是一个爆款商品的招牌被傍晚六点撤走了结果门口排了几百人全都涌到仓库去问。这个 key 单个但流量极大过期那一瞬间就是空窗期。雪崩则是整个商场把所有促销商品的招牌统一下班时间撤掉所有顾客同时冲进仓库仓库直接瘫痪。面试里最常见的一个问题是“怎么区分穿透和击穿”我的判断标准很简单穿透是缓存和 DB 都没有击穿是缓存短暂没有但 DB 有且只有一个 key。雪崩则是击穿的“放大版”不是单点而是整片。判断清楚属于哪一种才不会用错方案。2. 缓存穿透查了个不存在的 key数据库被白嫖2.1 穿透的本质与典型场景缓存穿透之所以难缠是因为它绕过了缓存但又不触发什么异常每次请求看起来都“正常”实际上全打在数据库上。举个例子一个商品详情接口 QPS 1000攻击者循环用不存在的商品 ID比如负数、超大数、随机 UUID去请求Redis 查一次没有DB 查一次也没有每次请求都白跑一趟。时间一长DB 连接全被这些无效查询占满正常用户的请求反而排不上队。实际中我遇到过的穿透场景有三类正常业务里的空结果查询比如按手机号查用户如果用户不存在Redis 也不会存任何数据请求频繁重复时就会穿透。恶意攻击与爬虫攻击者批量构造不存在的 ID 来探测接口、绕开缓存这也是穿透最典型的场景。尤其电商、社交类业务被刷是常态。非主键条件的组合查询比如按某个业务字段组合过滤这种请求没有统一 key缓存基本形同虚设每次都会回源 DB。很多人觉得穿透危害不大无非是查不到而已。实际不是危害在于“无效流量”的放大效应。假设 DB 单机抗压能力是每秒 5000 查询攻击者只需要把无效请求打到 8000DB 就会响应变慢慢慢把线程池占满最终拖垮正常业务。2.2 方案一缓存空对象给 DB 前面加一道“短路阀”解决穿透最朴素的办法就是把“查不到”这件事也缓存下来。流程很简单如果查询 DB 的结果为空就向 Redis 写入一个空值占位符并设置一个较短的 TTL比如 60 秒。下次同一个 key 再进来缓存命中的是空值直接返回 null不再回源 DB。核心代码大概长这样Object value redis.get(key); if (value ! null) { return value; } // 回源数据库 Object dbValue db.query(key); if (dbValue null) { // 缓存空对象TTL 设短一点 redis.set(key, EMPTY_PLACEHOLDER, 60); return null; } redis.set(key, dbValue, ttl); return dbValue;注意几个细节空值的 TTL 不能太长否则 DB 里真正写入数据后缓存还会继续返回空结果。比如用户注册场景别人查一个还没注册的号码空值缓存 5 分钟这 5 分钟内该号码注册成功用户一查还是“不存在”体验极差。空值占位符要跟正常数据区分开避免把 null 当成真实数据返回。我一般用特殊字符串比如NULL:true之类的 JSON 标记。如果恶意 key 特别多这个方案的副作用也会很大大量不存在的 key 会占满 Redis 内存变成一种变相缓存污染。所以空值方案适合业务合法但查询结果偏少的场景不太适合被恶性刷接口的情况。2.3 方案二布隆过滤器查不到就干脆别查比空值缓存更“硬核”的方案是在缓存前面再架一道前置过滤器——布隆过滤器。它的原理简单说就是用一个巨大的 bit 数组配合多个哈希函数把数据库里所有存在的 key 指纹记进去。查询时先用过滤器判断这个 key“一定不存在”还是“可能存在”如果判断不存在直接返回连 Redis 都不查。布隆过滤器有三个关键特性空间特别省。100 万个元素1% 误判率大约只需要 1.14MB 的位数组13 万个元素内存占用也很小。查询结果是“概率性”的。它只会告诉你“一定不存在”或“可能存在”。因为哈希冲突的存在它可能把不存在的 key 误判为存在但绝不会把存在的 key 判成不存在。不支持删除。普通的布隆过滤器删掉一个元素会牵连到其他元素的位置一般通过重建或使用带计数的变种解决。用 Redis 落地时有两种方式如果项目已经用了 Redisson直接用RBloomFilter配置 expectedInsertions 和 falseProbability 就行API 封装得很干净。如果不想引包也可以自己用 Redis 的 Bitmap 和 pipe 批量 setbit/getbit 实现但要多维护几个 hash 函数工作量不太划算。布隆过滤器在实践里最大的坑是数据同步。应用启动时需要把存量数据的 ID 初始化到过滤器里之后每新增一条数据也要同步把新 ID 放进去。我之前遇到过一个真实事故商品中心上线了布隆过滤器但忘了在新增商品接口里同步 add结果新商品一律被过滤掉线上持续报商品不存在排查了很久才发现是过滤器数据没更新。所以布隆过滤器适合key 集合稳定、可以预热的场景比如商品 ID、用户 ID、短链接编码。如果业务新增数据频繁就要设计好增量同步的闭环。2.4 穿透方案对比与选型建议方案实现难度内存开销适用场景主要风险缓存空对象低较高无效 key 占内存查询合法但结果为空空值污染、数据延迟一致布隆过滤器中极低ID 类固定集合查询、恶意攻击防护新增数据同步、无法删除元素我的经验是能确定业务 key 集合相对稳定的优先上布隆过滤器业务上“某个 key 查不到”是正常现象的用空值兜底。最稳的组合是布隆过滤器挡掉绝大多数不存在的 key漏网之鱼再走空值缓存二次兜底两层过滤之后穿透基本能解决百分之九十五以上。3. 缓存击穿热点 key 过期的一瞬间请求全打在数据库上3.1 击穿的本质单个热点 key 过期缓存穿透查的是“本来就不存在”的数据缓存击穿则完全相反——数据在数据库里好好地躺着但唯一的热点缓存 key 恰好过期了。过期的那一瞬间缓存里是空的而在一个小窗口内大量并发请求同时发现 cache miss于是一股脑全冲进数据库查询同一个数据。典型的场景我列几个秒杀活动的商品详情key 设置 10 分钟过期秒杀开始瞬间 10 万请求同时进来正好撞上过期。热门新闻、榜单、热搜词条平时 QPS 不高但一旦爆发就是百万级。某个明星爆出大瓜时他的主页数据 key 瞬间成为热点。击穿最大的问题不是缓存失效本身而是**“重建缓存”这件事的并发放大**。假设重建缓存需要 50ms在这 50ms 内 1 万个请求全部回源DB 就要扛 1 万次查询其中 9999 次其实是重复劳动。3.2 方案一互斥锁只放一个请求进去重建缓存击穿的标准解法是互斥锁。核心逻辑当缓存 miss 时不让所有请求都去查 DB而是让它们去竞争一把锁只有拿到锁的那个请求真正查库并重建缓存其他请求要么短暂等待后重新查缓存要么直接走降级逻辑。落地方案流程图我直接写成步骤查询缓存命中就直接返回。未命中尝试获取互斥锁。Redis 里用SET key value NX EX 30实现NX 保证只有一个客户端能设置成功。拿到锁的线程再查一次缓存double check防止等待期间已经有其他线程重建过了。真正查询数据库回写缓存释放锁。没拿到锁的线程 sleep 一小段时间然后回到第 1 步重试。代码骨架String data redis.get(key); if (data ! null) return data; String lockKey lock: key; boolean locked redis.setIfAbsent(lockKey, 1, 30, TimeUnit.SECONDS); if (locked) { try { // double check防止重复重建 data redis.get(key); if (data ! null) return data; data db.query(key); redis.set(key, data, ttl); return data; } finally { redis.delete(lockKey); } } else { // 没拿到锁等待后重试 Thread.sleep(50); return getFromCacheWithLock(key); }这里有两个必须注意的坑锁的过期时间必须大于重建缓存的最大耗时。我见过有人锁设 5 秒结果 DB 慢查询跑了 10 秒锁先过期了第二个线程又拿到锁去查库等于白锁。线上建议结合监控给锁设一个安全余量或者直接使用 Redisson 的看门狗自动续期。多实例环境必须用分布式锁。单机环境下用 JVM 的 synchronized 没问题但一旦部署多台应用每个实例的本地锁互相不感知照样会有多份请求打到 DB。所以跨实例场景务必使用 Redis 分布式锁后面第 5 节细说。3.3 方案二逻辑过期让缓存“软过期”而不是真过期互斥锁解决了空窗期打 DB 的问题但依然存在一个小瑕疵缓存过期瞬间如果锁竞争激烈一部分请求还是要等待重建完成才能拿到数据。对于时效性要求没那么苛刻的场景可以换一个思路——让 key 永不真正过期在 value 里存一个逻辑过期时间。具体做法存入缓存的数据结构是{data: 商品详情, expireTime: 1718000000000}。查询时判断当前时间是否超过 expireTime没超过正常返回数据。超过后尝试获取互斥锁。拿到锁的线程去查库并更新数据、更新逻辑过期时间拿不到锁的线程直接返回旧数据不等待、不阻塞。这个方案的好处非常明显Redis 里永远不会出现 key 过期的空窗期所有请求都能拿到一份旧数据真正回源的只有极少数持有锁的请求。缺点也逃不掉数据会有短暂的不一致比如促销价格已经改了但缓存里还是旧价格只能靠下一次刷新覆盖。逻辑过期特别适合两类数据一个是极端热点数据比如秒杀商品另一个是允许短暂读到旧值的业务数据比如榜单、推荐位。如果业务对一致性要求极高比如库存扣减、支付金额就别用这个方案。3.4 热点 key 永不过期 主动更新实操中我更喜欢一个更彻底的思路对确定性的热点 key直接把过期时间去掉干脆不让它过期改成后台主动刷新。比如活动商品上线时就把 key 写进 Redis 且不设 TTL后台通过定时任务或者 MQ 消息在数据库更新后同步刷新缓存。这个方案从根上消灭了“过期瞬间”击穿问题自然不存在。但它也有自己的问题如果后台刷新任务出 bug缓存就一直停留旧值。补偿手段一般是配合“延迟双删”或者“版本号机制”在更新时对比版本低版本的写入直接丢弃。我的选型建议是这样追求实时一致性的用互斥锁能容忍短暂旧数据、峰值又极高的用逻辑过期或永不过期 主动更新。不要为了炫技而上一堆机制把最简单合适的方案用透就够了。3.5 击穿方案怎么选我给出一个速查表方案数据一致性实现复杂度适用场景互斥锁高中一般热点数据、一致性要求高逻辑过期低可容忍短暂旧值中极端热点、秒杀、榜单永不过期 主动更新中中高确定性热点、数据变更频率低4. 缓存雪崩大批 key 同一时间过期数据库被击穿4.1 雪崩的本质不是单个是一层缓存雪崩比击穿可怕得多因为击穿是“一个 key 出事”雪崩是“一整片 key 同时出事”。当大量 key 的过期时间相同会在某一个时间点集中失效这个时间点前后所有请求都会绕过缓存直达 DB或者 Redis 节点宕机整个缓存层不可用所有流量彻底打到数据库。前面说过击穿类比是爆款商品招牌被撤掉排队的顾客全冲进仓库。雪崩则是一条街上所有商店的招牌被统一下班时间撤掉全城顾客同时冲向仓库仓库工作人员直接就懵了。实际线上我亲眼见过一次事故运营在后台配置了大促所有活动商品统一设置了 24 小时后的相同过期时间结果第二天同一秒钟大量 key 过期数据库 QPS 从前一秒的几百瞬间飙到几万连接池被打爆整个商品服务雪崩最后靠限流和重启才慢慢缓过来。雪崩有两个截然不同的诱因对应的解法也完全不同分开说。4.2 应对大量 key 同时过期过期时间随机化、业务错峰如果雪崩的原因是“过期时间集中”解决思路就是把同时失效打散。最简单的办法是给 TTL 加一个随机扰动值。比如统一设置 TTL 为 1 小时实际落地时变成1小时 random(0~300秒)这样 3600 0 到 3600 300 秒之间key 分摊在 5 分钟窗口内失效不会出现齐刷刷失效的峰值。具体代码就一行int baseTtl 3600; int ttl baseTtl ThreadLocalRandom.current().nextInt(300); redis.set(key, value, ttl);除了 TTL 随机化业务上也可以做错峰。比如同类型的缓存 key为不同业务分组设置不同的基准过期时间再比如大促场景在流量低峰期统一预热让 key 在活动开始前就把数据准备好。另外一个从源头避免雪崩的思路干脆对核心数据不做物理过期改成逻辑过期或主动更新。这样就没有“集中失效”这个说法了第 3.3 和 3.4 节的手法在雪崩里同样适用。4.3 应对 Redis 宕机高可用架构、熔断限流降级、多级缓存如果雪崩是 Redis 节点不可用导致的那么 TTL 随机化就完全没用这时候要解决的是缓存层本身的高可用。具体分三层来做第一层架构高可用。Redis 不能单点部署至少做一主一从配合哨兵 Sentinel 自动故障切换大流量场景直接用 Redis Cluster 集群。同时打开 AOF 持久化降低故障切换时的数据丢失风险。我在中大型项目里比较推荐 Cluster 模式多个分片分摊热点单节点挂了流量自动切换到其他副本。第二层应用层自我保护。即使 Redis 做了 HA切换过程依然可能有秒级不可用而且弱网、慢查询也可能造成缓存不可用。所以应用必须做好熔断、限流、降级限流用 Guava RateLimiter 或 Redis 令牌桶把回源 DB 的请求限制在安全阈值内超出的直接快速失败。熔断当缓存不可用且 DB 错误率飙升时不再继续做无意义的重试直接抛出降级结果。这里我推荐 Resilience4j 或 Hystrix 的熔断器设置合理的错误阈值和熔断窗口。降级缓存不可用时返回默认值、静态内容或者本地缓存兜底。宁可给用户一个“稍后重试”的提示也别让 DB 被拖死。第三层多级缓存。在 Redis 前面加一层 JVM 本地缓存比如 Caffeine。热点数据先查本地缓存本地没有再看 Redis最后才查 DB。这样就算 Redis 整体抖动本地缓存依然能挡住大部分流量。多级缓存的代价是数据一致性更复杂一般只在单条数据体积小、热点明显的场景用。4.4 缓存重建的流量控制预热与请求合并雪崩后系统最怕的是“恢复瞬间”被二次打垮。Redis 恢复后因为缓存已经空了如果所有请求在那一刻同时涌入DB 又会挂掉。所以一定要控制缓存重建的流量。推荐两个手段缓存预热上线、大促、故障恢复前用脚本或定时任务把热点数据提前加载到 Redis而不是等用户请求来慢慢建。请求合并类似击穿用的互斥锁把大量并发请求合并成少量回源。在雪崩恢复场景下我甚至会刻意在应用层加一个全局“重建开关”由开关放行一部分请求去预热缓存其余请求直接返回旧数据或默认值等缓存建好后再放开全量流量。我对雪崩的总结是物理过期靠随机节点故障靠 HA流量爆炸靠限流降级恢复阶段靠预热和控流。四件事一起做才能把雪崩的冲击真正降下来。5. 组合拳分布式锁 缓存治理实战5.1 Redis 分布式锁在缓存重建中的应用前面击穿和雪崩方案里反复出现“互斥锁”在实际多实例部署时这个锁必须是 Redis 分布式锁而不是 JVM 本地锁。原因很简单我这边的服务一般至少部署了 3 个实例如果只用本地锁每个实例以为自己是唯一重建者实际上三个实例同时在查库等于没锁住。分布式锁在缓存重建里的标准用法我之前在项目里是这样落地 Redisson 的RLock lock redissonClient.getLock(lock:product: productId); boolean locked lock.tryLock(0, 30, TimeUnit.SECONDS); if (locked) { try { // double check 缓存 String cached redis.get(key); if (cached ! null) return cached; // 查库 回写缓存 Product p productDao.selectById(productId); redis.set(key, JSON.toJSONString(p), ttl); return p; } finally { lock.unlock(); } } else { // 没抢到锁直接查缓存并短暂等待 Thread.sleep(30); return getWithLock(key); }用 Redisson 的好处是它自带看门狗机制默认锁过期时间 30 秒持锁线程没执行完时看门狗会自动续期避免业务超时后锁提前失效、导致多个线程同时重建。手动用SETNX实现分布式锁也可以但前提是必须处理好三个细节加锁要加过期时间、释放锁要校验身份防止误删别人的锁、续期要么用定时任务要么用 Lua 脚本。有现成库能用我建议不要重复造轮子。5.2 缓存治理实战清单三大问题的根子往往不只是“某个 key 恰好过期”而是整个缓存体系治理不到位。我列一下日常项目里比较关键的治理项Key 命名规范。统一业务:模块:ID的格式比如mall:product:1001方便排查和可视化工具检索也方便按前缀批量清理。TTL 设计。区分冷热数据热点数据用逻辑过期或永久 key 主动刷新普通数据 TTL 随机化不重要的数据设短 TTL。最忌讳的是给所有缓存统一一个过期时间。容量与淘汰策略。给 Redis 设置maxmemory根据业务选allkeys-lru或volatile-lru避免内存写满后无限驱逐。监控与告警。盯缓存命中率、Redis 内存、慢查询、连接数。我用 Redis 自带的INFO stats看命中和未命中次数命中率低于 90% 时就要排查是否存在穿透或大 key 问题。可视化工具。开发环境排查建议用 Redis Desktop Manager 或 Another Redis Desktop Manager能够肉眼扫一遍大 key 和过期策略比命令行直观很多。生产环境就用 RedisInsight 做巡检。5.3 日常排查命令与工具再分享几个排查三大问题时会用到的命令都是实战里高频用到的# 扫描大 key注意低峰期执行 redis-cli --bigkeys # 查看慢查询 slowlog get 10 # 查看缓存命中与未命中统计 info stats # 查看内存和过期策略 info memory拿到未命中数据后进一步确认是不是穿透可以直接用抽样日志看 key 是否存在规律如果是攻击者构造的随机 key日志里通常能看出前缀统一但后缀无规律的特征。击穿和雪崩则重点看慢查询和 DB 监控一旦 DB QPS 在某个时间点出现脉冲式飙升基本就能对应到缓存集中失效。Redis 6.0 之后的实例可以开启hotkeys参数辅助定位热点 key这对击穿治理很有用。不过要注意大 key 扫描和 hotkeys 命令都会有性能开销线上执行一定要避开高峰期或者在从节点上跑。最后说点私心话。缓存三兄弟经常被当成面试八股但它真的不只是背诵材料。我踩过不少次坑布隆过滤器忘同步新增 ID导致新数据全被拦掉锁过期时间设置不科学大促时锁形同虚设做 TTL 随机化时只顾着分配过期时间忘了做预热结果恢复时二次雪崩。这三个问题看起来各自独立实际上都属于缓存治理的整体范畴——搞清楚了原理再结合自己业务的流量特征去选型比背十道面试题都管用。如果你正在做缓存优化建议先从监控和 key 分布入手把线上数据摸清楚再决定用哪种方案别一上来就把分布式锁和布隆过滤器全堆上去。
返回列表