ARTICLE DETAIL

资讯详情

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

缓存穿透、击穿与雪崩:原理、解决方案及实战避坑指南

缓存穿透、击穿与雪崩:原理、解决方案及实战避坑指南 1. 缓存三大难题穿透、击穿、雪崩到底在说什么只要是搞后端开发、尤其是做过高并发接口的人基本都绕不过这三个词穿透、击穿、雪崩。它们都和缓存有关但触发场景、危害程度、解决方案完全不一样。很多刚接触 Redis 的同学容易把它们混为一谈面试时答得含糊实际排查问题时更是容易被表象迷惑。我先用一句话分别概括帮大家把概念立住缓存穿透查一个一定不存在的数据请求绕过缓存直接打到数据库。正常缓存是没有就回源但穿透是每次都没有每次都回源数据库压力直接拉满。缓存击穿某个热点 key 的缓存刚好过期此时大量并发请求同时打过来缓存没命中全部穿透到数据库。缓存雪崩大量 key 在同一时间段集中过期或者 Redis 整个实例宕机导致海量请求瞬间涌向后端数据库。这三个问题本质上都是缓存失效后请求压力转移到数据库的变体但处理思路差异很大。如果你在系统设计阶段没有预判过这些风险等到线上告警发出来再临时补救那基本就是被动挨打的状态。从我的实际经验来看绝大部分中小团队对缓存的使用都停留在读的时候先查 Redis、查不到就查 MySQL、再写回 Redis这种简单模型上。这种模型在小流量下没有任何问题但一旦遇到热点活动、秒杀、榜单这类场景缓存层面的细节处理就变成了决定系统能不能扛住的关键。这篇文章我会把每个问题的成因、表现、解决方案、踩坑点全部拆开讲并且给出我实际用过的、可以直接落地的方案。不是为了面试背题而是让你回到工位上真的能处理这些问题。2. 缓存穿透数据库被不存在的数据打垮2.1 穿透的真正成因和典型场景缓存穿透核心特征是查询了一个在数据库中也不存在的数据。正常流程是这样的请求进来先查 Redis如果 Redis 里有就直接返回如果没有就去查数据库数据库有就把结果写回 Redis再返回给调用方。这个流程对存在的数据是有效的因为一旦查出来后续请求就能命中缓存。问题就出在不存在的数据上。如果一个 key 对应的数据在数据库里根本没有那缓存里永远不可能有每次请求都会穿透 Redis 直达数据库。如果有人恶意构造大量不存在的 ID 来请求接口比如 ID 用负数、用超长随机数、用 UUID数据库就会被这些无效请求反复冲击。我之前接手过一个真实案例某个开放接口的查询参数是用户传过来的 ID代码里没有做基础校验当时的 QPS 大概两千左右结果被人用脚本批量刷不存在的 ID数据库连接数瞬间被打满慢查询日志里全是SELECT * FROM xxx WHERE id ?单条耗时从 2ms 飙升到 2 秒多。这就是穿透的典型危害数据库本身能扛的并发有限当无效查询占了绝大多数时真正的正常用户请求反而被拖死。2.2 方案一缓存空值最通用但要注意过期时间解决穿透最直接的办法既然数据不存在那就把不存在这个结果也缓存起来。也就是说当数据库查询返回空时仍然把 key 写入 Redis只是 value 写一个特殊值比如 null 或空字符串并设置一个较短的过期时间比如 60 秒。这样后续同样的请求就能命中缓存直接返回空结果不再穿透到数据库。我用伪代码演示一下这个流程public Object get(String key) { Object val redis.get(key); if (val ! null) { return val; } // 缓存没命中查数据库 Object data queryFromDB(key); if (data null) { // 数据库也没有缓存空值设置较短过期时间 redis.set(key, EMPTY_VALUE, 60); } else { redis.set(key, data, 5 * 60); } return data; }这个方案实现简单、兼容性好适用于大多数业务场景。但有一个关键点空值的过期时间不能太长。因为数据不存在只是当前时刻的结论假设用户的 ID 是预先生成的60 秒后数据可能就创建了如果空值缓存时间过长会导致新数据无法被及时读到。我踩过这个坑当时有个场景是工单系统用户创建工单后会立刻查看详情而详情接口对不存在的工单 ID 缓存了空值过期时间设成了 2 小时。结果用户刚创建完工单去查命中的还是 2 小时前缓存下来的空值前端界面就显示工单不存在。排查了很久才发现是这个原因。所以实践建议是空值缓存的过期时间控制在 30-60 秒宁可让少量穿透发生也不要让业务数据出现长时间不一致。2.3 方案二布隆过滤器拦截大概率不存在的请求缓存空值方案虽然能解决大多数穿透问题但对于恶意攻击场景还有更前置的拦截手段布隆过滤器。布隆过滤器是一种概率型数据结构它可以非常高效地判断某个值一定不存在或某个值可能存在。它的原理是用多个哈希函数把元素映射到一个 bit 数组中查询时如果所有哈希位置都是 1说明元素可能存在注意是可能存在有误判率如果有一个位置是 0说明元素一定不存在。放到缓存穿透场景里我们可以在系统启动时把数据库中的合法 ID 全部加载到布隆过滤器中。请求进来后先查布隆过滤器如果判断 ID 不存在直接返回数据不存在根本不需要查 Redis 和数据库。public Object getOrder(String orderId) { // 先过布隆过滤器 if (!bloomFilter.mightContain(orderId)) { return null; } Object val redis.get(orderId); if (val ! null) { return val; } Object data queryFromDB(orderId); if (data ! null) { redis.set(orderId, data, 5 * 60); } else { // 兜底仍然缓存空值防止误判导致的穿透 redis.set(orderId, EMPTY_VALUE, 60); } return data; }这里有个很重要的细节布隆过滤器存在误判率它只会把存在的误判为不存在的不对恰恰相反布隆过滤器不会漏报如果元素真的存在查询结果一定是可能存在但会误报不存在的元素可能被判定为可能存在。所以在上面的代码里布隆过滤器说是不在就一定不在但说在不一定真的在还需要继续走缓存和数据库的流程。布隆过滤器适合用在数据总量可控、ID 变化不频繁的场景。如果数据量很大且经常新增需要定时全量重建布隆过滤器否则新增的 ID 无法被识别。2.4 其他辅助手段参数校验与限流除了上述两个核心方案还有一个经常被忽略的简单手段参数校验。很多穿透攻击实际上是靠非法参数实现的比如负数 ID、超长字符串、格式明显不对的参数。在接口入口处做白名单校验能拦截掉一大半无效请求。这个手段成本极低但收益很大。另外针对单 IP、单用户的高频异常请求可以用 Redis 做一个简单的计数器限流。比如一分钟内同一 IP 查询超过 100 次直接拒绝或者对非登录态的匿名请求做更严格的限制。从实战角度来说我的建议是参数校验 缓存空值是基础配置任何缓存系统都应该具备布隆过滤器用在数据量级较大、穿透压力明显的场景限流作为最后的兜底防御。这几个手段不是互斥的而是应该组合使用。3. 缓存击穿热点 key 过期的瞬间数据库被瞬间打穿3.1 击穿与穿透的本质区别穿透和击穿经常被搞混但它们的核心区别在于穿透查的是永远不存在的数据击穿查的是本来存在但缓存刚好过期的热点数据。击穿的特点是只有一个 key 出问题但这个 key 是热点 key瞬时并发极高。比如微博热搜榜的某一条新闻、电商大促时的某个爆款商品、直播间的某个商品链接这些 key 在缓存里的时候一切正常一旦到了过期时间缓存失效所有请求同时回源数据库数据库瞬间被打垮。这里有个量化概念假设某个热点 key 的 QPS 是 5000缓存过期后如果 5000 个请求全部打到数据库而数据库能扛的并发可能只有 200结果就是连接池被打满、接口大面积超时、整个服务雪崩。3.2 方案一互斥锁最强保证但有性能代价对付击穿第一反应应该是既然是大量并发同时回源那就让它们排队只有一个请求去数据库查数据并写回缓存其他请求等缓存写好了直接读缓存。这个思路的落地方式就是互斥锁。具体来说当缓存未命中时不是立刻查数据库而是先获取一把分布式锁Redis 里可以用 SETNX 实现拿到锁的线程去查数据库并回写缓存其他线程拿不到锁就短暂等待然后重新查询缓存。public Object getHotData(String key) { Object val redis.get(key); if (val ! null) { return val; } String lockKey lock: key; boolean locked tryLock(lockKey, 3000); // 尝试获取锁超时3秒 if (locked) { try { // 拿到锁后再次查缓存防止等待期间其他线程已经回写 val redis.get(key); if (val ! null) { return val; } // 查数据库 val queryFromDB(key); redis.set(key, val, 5 * 60); return val; } finally { releaseLock(lockKey); } } else { // 没拿到锁等待一段时间后重查缓存 Thread.sleep(50); return getHotData(key); } }特别注意代码里的二次查缓存这是很多人会漏掉的细节。线程拿到锁之前可能已经有另一个线程回写了缓存如果不二次查就直接查数据库等于锁白白加了。互斥锁能保证任何时刻只有一个请求在查数据库效果最可靠代价是牺牲了部分并发能力因为所有请求都要经历获取锁/等待锁的过程。这在高并发场景下会拖慢响应时间但至少数据库是安全的。3.3 方案二逻辑过期不设物理过期时间用主动更新互斥锁的缺点是会增加请求延迟而逻辑过期方案可以做到无锁、无等待。逻辑过期的做法是缓存中不设置物理过期时间TTL 设为 -1而是在 value 里额外存一个过期时间字段。当线程读取缓存时判断这个逻辑过期时间是否到了如果没到就直接返回如果到了就异步去更新缓存但当前请求仍然返回旧数据。public Object getHotData(String key) { CacheData cacheData redis.get(key); if (cacheData null) { // 缓存完全是空的比如首次加载走互斥锁逻辑或直接查库 return loadData(key); } if (cacheData.expireAt System.currentTimeMillis()) { // 逻辑过期时间未到直接返回旧数据 return cacheData.value; } // 逻辑过期了异步刷新缓存当前请求继续返回旧数据 asyncRefreshCache(key); return cacheData.value; }这个方案的好处是读请求永远不会被阻塞用户体验好坏处是数据一致性差因为过期后的一段时间内用户读到的仍然是旧数据。另外异步刷新逻辑需要考虑并发控制否则多个线程同时触发刷新又回到数据库压力的问题。实际项目中我会根据业务容忍度来选择如果数据允许短暂不一致比如首页 Banner、商品榜单、非交易类配置优先用逻辑过期如果数据必须强一致比如支付状态、库存数量用互斥锁更稳妥。3.4 击穿预防提前评估热点 key 的过期时间除了在故障发生时的应对措施还有一个非常实用的预防手段热点 key 不设置固定的过期时间而是设置为永久也不过期由后台任务定期更新。比如运营在后台配置了一个大促活动活动商品的信息可以提前放入缓存并让一个定时任务每隔 5 分钟从数据库拉取最新数据刷新缓存。这样这个 key 永远不会因为过期而失效也就永远不会发生击穿。这个方案实现简单、可预测性强但要注意定期刷新任务的失败处理如果刷新任务挂了缓存里的数据就一直不动了需要配合监控告警。我在实际项目中通常的组合策略是对已知的热点 key 使用定时刷新 逻辑过期兜底对未知的突发热点 key 使用互斥锁保护。4. 缓存雪崩大量 key 同时失效系统整体宕机4.1 雪崩是击穿的扩大版如果说击穿是一个热点 key 过期那雪崩就是大量 key 同时过期或Redis 整体不可用。为什么会有大量 key 同时过期最典型的原因是代码里批量设置缓存时过期时间写了一个固定值。比如把一批商品的缓存 TTL 都设置为 24 小时那么这些 key 会在同一时刻失效。00:00 一到所有请求一起回源数据库数据库瞬间被打满。另一种更严重的雪崩是 Redis 实例本身宕机。缓存层整体失效所有请求全部涌向数据库如果此时系统的降级机制不完善数据库连接池会在几十秒内耗尽整个后端服务进入不可用状态。4.2 过期时间加随机值最简单有效的预防手段解决大量 key 同时过期最经典的手段是过期时间设置一个基础值加上一个随机值。// 不推荐所有 key 同时过期 redis.set(key, value, 60 * 60 * 24); // 推荐过期时间加随机偏移 int baseExpire 60 * 60 * 24; int randomExpire baseExpire RandomUtil.randomInt(0, 600); redis.set(key, value, randomExpire);加随机值的效果是原本可能集中在同一个时间点失效的 key被打散到一段时间范围内数据库不会同时收到大量回源请求。这个方案简单粗暴几乎零成本是应对雪崩的基础配置。4.3 热点数据永不失效 后台定时刷新和击穿预防类似雪崩的另一个应对思路是核心业务数据不让它自然过期而是通过后台任务主动更新。我之前在一个电商项目中把商品详情、库存信息、用户购物车数量等核心缓存全部设置为不设置过期时间另起一个调度任务每 5 分钟从数据库同步一次热点数据。这样既保证了数据不会因为过期而瞬间失效又避免了缓存永远不更新的问题。但这个方案也有讲究定时刷新的粒度要控制好。如果每次全量刷新几千个 key刷新任务本身就会给 Redis 和数据库带来压力而且可能造成一批 key 的写入时间非常接近仍然存在批量失效的风险。我的做法是分批刷新单批 200 个 key批次之间间隔 10 秒尽量打散回写节奏。4.4 Redis 不可用时的降级与限流前面几个方案针对的是key 过期但雪崩还有一种触发条件Redis 整个宕机或者网络分区。这种情况下所有缓存直接不可用请求必然全部打到数据库。如果不做保护系统大概率直接挂掉。这时依赖缓存方案已经没意义了要做的是降级和限流。降级当检测到 Redis 不可用或响应超时启动降级开关接口直接返回默认值或错误提示不再查询数据库。比如首页推荐位降级为返回空列表商品详情降级为返回基本信息部分数据从数据库读取。限流在 Redis 不可用期间对所有数据库查询做严格的并发控制比如每秒只放行 100 个查询请求其余请求直接拒绝或进入排队。宁可丢弃部分流量也不能让数据库被打死。这里要注意一个细节降级要做到快速失败不能让请求在 Redis 上等待太长时间。Redis 客户端普遍有超时配置默认可能几百毫秒甚至几秒高并发下这些等待的请求会占满线程池。所以在设计降级时必须把 Redis 读超时设得很短一旦超时立刻进入降级逻辑。4.5 缓存集群的高可用建设最后从基础设施层面讲Redis 自身的可用性也是雪崩治理的一部分。单机 Redis 出问题的概率不低内存满了、CPU 过载、机器重启、网络波动任何一个都可能导致缓存不可用。如果系统只部署了单实例没有副本、没有哨兵、没有集群那 Redis 一挂就是全挂。我的建议是生产环境至少部署 Redis 主从结构主节点出问题时可以切换到从节点使用哨兵模式自动故障转移避免人工介入数据量较大时使用 Redis Cluster分片后单个节点故障只影响部分 key开启持久化RDB AOF防止重启后数据全部丢失但要清楚 RDB 和 AOF 的优缺点在高并发写入场景下 AOF 的同步策略要权衡好。5. 三者的对比与排查经验面试能说清线上能救命5.1 核心对比表很多同学面试的时候被问穿透、击穿、雪崩有什么区别答得含含糊糊本质上是没有从查询对象和失效范围两个维度去理解。我做了一张表把它们的差异整理清楚维度缓存穿透缓存击穿缓存雪崩查询对象一定不存在的数据一个热点 key大量 key触发时机每次请求都未命中热点 key 过期的瞬间大量 key 同时过期 / Redis 宕机缓存状态缓存中一直不存在缓存中一度存在过期限后消失缓存中大量存在但同时失效 / 实例不可用影响范围单个无效请求反复打库单个热点 key 打库系统整体被打垮核心方案缓存空值 / 布隆过滤器 / 参数校验互斥锁 / 逻辑过期 / 不设过期时间过期时间加随机值 / 定时刷新 / 降级限流 / 高可用架构实际排查问题时我一般先看查询的数据是否存在——如果数据库根本没有这个数据就是穿透再看是不是只有个别热点 key 出问题——是就是击穿如果大量 key 同时失效或者 Redis 整个不可用就是雪崩。5.2 线上排查流程实录这里分享一个我曾经处理的真实事故帮助大家把理论和实践串起来。那是某次大促的预热阶段运营把一批活动商品的价格配置到了后台前端展示层有一个接口按照商品 ID 批量查询价格。上线后不久监控告警显示数据库慢查询数量突然飙升99 线响应时间从 80ms 涨到 1.5 秒。当时的排查步骤是这样的先看 Redis 的命中率和 key 数量发现某一个商品 ID 的请求量异常高但 Redis 中对应 key 的 TTL 全部归零了。再查数据库日志发现大量重复的SELECT * FROM product WHERE id ?语句且都是同一个 ID。结合代码分析发现这批商品在配置后台被批量写入时过期时间统一写死为次日零点导致所有 key 在同一时刻过期。这个事故其实是典型的雪崩和击穿叠加大量热点 key 同时过期每一个都触发击穿。当时处理的紧急措施是先把 Redis 里这些 key 手动重新加载并设置不同的随机过期时间然后优化代码改为基础过期时间 随机偏移同时给这些热点商品单独设置定时刷新任务。事后复盘我总结了几条经验写在这里供大家参考所有批量写入缓存的逻辑千万不要用固定 TTL这是最大的隐患。热点 key 的缓存更新要主动不能完全依赖过期后回源否则等于把压力集中在过期瞬间。告警要盯住Redis 命中率下降 数据库慢查询上升这个组合这是缓存失效问题的典型信号。5.3 几个常见的面试追问与实战应答缓存治理这三点也是面试高频题我顺手整理了几个常见的追问角度以及我在实际项目中会怎么回答追问一布隆过滤器误判了怎么办误判只会导致不存在的请求放行到 Redis 和数据库影响有限。可以在布隆过滤器之后再加缓存空值做兜底这样即使误判也只是多一次 Redis 查询不会直接打库。追问二互斥锁会不会导致死锁会。所以加锁必须设置超时时间释放锁时要确认是自己加的锁用唯一的 token 标识防止误删其他线程的锁。Redis 分布式锁要慎用 SETNX DEL 这种非原子操作建议用 SET NX EX 或引入 Redisson。追问三逻辑过期方案数据不一致怎么办没有完美的方案只有适合业务的方案。如果业务对一致性要求高优先选互斥锁如果追求性能和可用性可以接受几秒钟的延迟逻辑过期更合适。核心原则是先保证系统不挂再谈数据一致性。追问四Redis 宕机了怎么恢复启动顺序很关键先确认 MySQL 等底层存储正常再启动 Redis等缓存预热完成后再放流量。如果直接放流量Redis 是空的所有请求都会打到数据库等于人为制造雪崩。5.4 常见问题速查表问题现象可能原因排查方法临时对策数据库慢查询突增Redis 命中率正常缓存穿透查了大量不存在的数据看 SQL 是否有大量无结果的查询缓存空值 参数校验单个 key 过期后接口瞬间超时热点 key 缓存击穿监控热点 key 的请求量和 TTL互斥锁或逻辑过期大量 key 同时过期导致批量回源过期时间写死检查批量写入逻辑的 TTLTTL 加随机值Redis 响应超时所有请求打库Redis 宕机或网络异常检查 Redis 监控和日志降级 限流 快速失败重启后缓存全空流量直接打库未做预热查看启动日志启动前预加载缓存6. 从一次实战改造看缓存治理的完整落地前面讲了很多方案和原理但实际编码的时候光知道方向还不够还要把方案串成一个完整的体系。这里我用一个曾经做过的改造案例把最终的落地形态展示出来。背景某个商品详情接口日均调用量 3 亿15% 的热点商品贡献了 85% 的流量。当时系统的问题是大促期间间歇性出现数据库 CPU 100%事后分析发现穿透、击穿、雪崩三种情况都发生过。改造后的缓存层结构大致如下请求入口层做参数校验非法请求直接返回不进入缓存流程。布隆过滤器加载所有商品 ID快速拦截不存在的数据拦截率大约 60%。Redis 缓存层热点商品使用不设物理过期时间 定时刷新普通商品使用基础 TTL 随机偏移数据库查询为空时缓存空值 30 秒。互斥锁兜底当缓存未命中且需要查数据库时使用分布式锁保证同一时刻只有一个线程回源。降级与限流Redis 响应超时超过 50ms 时接口直接降级返回基础数据数据库查询全局限流每秒最多放行 2000 个请求。这套体系上线后数据库的日常 QPS 从高峰期的 1.2 万降到了 800 左右大促期间再没有出现因缓存失效导致的数据库打满问题。从这次改造里我总结了四个指导性原则供大家在设计缓存治理方案时参考分层防御不要指望单点方案参数校验、布隆过滤器、空值缓存、互斥锁、限流降级每一层解决一个问题组合起来才能形成完整防线。区分数据热度差异化处理热点 key 和普通 key 用不同的缓存策略不能一视同仁。永远假设 Redis 会挂缓存是加速层不是数据层必须做好降级预案。用监控和告警驱动优化没有监控你永远不会知道缓存命中率在下降也不会知道数据库慢查询在上涨。最后再分享一个小技巧线上排查缓存问题时优先看 RedisINFO命令里的keyspace_hits和keyspace_misses这两个指标能快速反映缓存命中率。如果hits占比低于 90%说明缓存设计还有很大的优化空间需要进一步定位是哪些 key 没命中命中率恢复了数据库压力自然就下来了。
返回列表