ARTICLE DETAIL

资讯详情

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

Redis缓存穿透与雪崩实战:从事故剖析到防护方案

Redis缓存穿透与雪崩实战:从事故剖析到防护方案 1. 一次真实的线上事故缓存层为什么会变成“帮凶”先说一个我自己处理过的事故。之前在某电商平台做订单服务某个大促活动上线当天的下午订单详情接口的数据请求量从平时每秒500左右直接飙到3000多。一开始团队按惯性加了几台机器结果数据库CPU照样被顶到99%服务端大批量超时告警。后来查监控才发现问题的根源根本不在机器数量而在于Redis缓存层被绕过了。那天Redis的命中率从99%突然掉到65%慢查询日志里反复出现同一条订单详情SQL。顺着请求链路追下去发现一批客户端拿着伪造的订单号发起请求这些订单号在数据库里压根不存在。Redis查不到键回源去查MySQLMySQL也查不到但系统里又没有把“查不到”这件事告诉缓存于是每个伪造请求都实打实打穿了一次数据库。一句话概括数据库在被无效流量硬扛。这个场景就是典型的缓存穿透。而在同一年晚些时候我又碰到了另一种症状——某天凌晨一批活动商品恰好在同一时刻过期缓存大面积空窗数据库读流量瞬间翻了好几倍。这两个问题看起来都表现为缓存未命中率升高但形成机制、表现曲线、处置手法完全不同。这篇文章我就从这两个真实场景出发把穿透和雪崩的原理、触发条件、常见解决方案一次性讲清楚顺便把很多人都分不清的“缓存击穿”也带上免得你的缓存治理方案一开始就是残缺的。1.1 那两场事故的排查链路处理缓存问题最忌讳上来就动手改配置。我的经验是先看三类数据Redis的命令监控、缓存命中/未命中曲线、数据库慢查询日志。穿透的特征是未命中量里夹杂大量无效key而且这些key有明显规律比如超长ID、负数ID、批量生成的无意义字符串雪崩的特征则是某条时间线上命中率断崖式下跌同一时间窗口大量key的“过期事件”同时出现。那一次排查穿透时我用redis-cli连上实例先看INFO keyspace确认keyspace变化再通过MONITOR命令抽样观察实时请求发现大量GET order:8xxxx这样的读命令全部落空。而在排查雪崩时看到的却是缓存整体TTL分布过于集中同一批写入的数据设置的是同一个过期时间一到点集体失效。这两种问题的排查入口看起来像但一旦定位到根因解决方案完全不在一条路上。1.2 穿透和雪崩的边界到底划在哪里我用一句话来区分缓存穿透是“单个数据不存在每次请求都绕过缓存直击数据库”缓存雪崩是“大量数据在同一时刻不可用缓存整片失效”。穿透强调纵向的单点问题雪崩强调横向的成片问题。这个区分非常重要。很多人一看到数据库被打爆就笼统地说“缓存雪崩了”其实处置手段差了十万八千里。穿透的核心解法是“让不存在的key也留在缓存里”或者“在缓存之前就把无效流量挡掉”雪崩的核心解法是“让过期时间打散”或者“即使缓存失效数据库也不能被打垮”。接下来分别展开。2. 缓存穿透查询不存在的数据每次请求都直达数据库缓存穿透的本质不难理解。正常流程下一个请求先查Redis命中了直接返回没命中则读数据库再把结果写进Redis。但问题出在“数据库里没有结果”的场景读库之后返回空系统顺手把这个空结果也缓存了下一次请求就能命中如果系统没有对空结果做缓存那么每一次请求都重复走“Redis未命中→查数据库→数据库也没数据→不写缓存”的完整链路。在攻击或异常流量场景下攻击者只要持续构造数据库里不存在的ID就能让缓存形同虚设数据库被无效查询淹没。业务数据量越大、接口对未授权参数校验越松这个洞越容易被利用。2.1 穿透的触发场景与攻击面穿透不只来自恶意攻击日常业务里也很常见。比如用户传入一个已被删除的商品ID或者前端传错了一个不存在的状态码再比如分页查询时传了一个超出范围的下标后台查出来是空列表。这些情况如果没处理都可能变成穿透流量。我做系统排查时会先从请求参数入手。最简单的优化是加参数校验订单号若非纯数字并且长度不对直接在网关或Controller层拒绝负数、0、超长字符串统一拦截。这一步成本极低能挡住相当一部分无效流量但挡不住精心构造的合法格式参数。所以还需要后两层防护空值缓存和布隆过滤器。2.2 空值缓存让“不存在”也留在Redis里最直接的做法是当数据库查询结果为空时把空值也写进缓存并设置一个较短的TTL。这个方案的好处是实现简单、几乎零改造效果立竿见影。我通常会给空值设30到60秒的过期时间太长会导致数据库新增的数据无法及时可见太短则挡不住高并发下的连续穿透。public OrderDTO getOrderById(String orderId) { String cacheKey order: orderId; Object cacheValue redisTemplate.opsForValue().get(cacheKey); if (cacheValue ! null) { return .equals(cacheValue) ? null : (OrderDTO) cacheValue; } OrderDTO order orderMapper.selectById(orderId); if (order null) { // 缓存空值TTL设短一点既防穿透又不影响数据新鲜度 redisTemplate.opsForValue().set(cacheKey, , 30, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue().set(cacheKey, order, 300, TimeUnit.SECONDS); return order; }看起来简单但有几个细节值得注意。第一空值也得是可区分的类型我自己习惯缓存空字符串取出来的时候单独判空别跟正常值混在一起。第二写空值缓存前最好做一次幂等去重不然高并发下同一个不存在的key会有很多线程同时查库、同时写空值虽然危害比穿透小但会产生大量重复查询。第三空值TTL的重要性不亚于正常缓存——如果数据库里后来真的插入了这条数据而空值缓存还没过期用户就会看到一条“明明存在却查不到”的脏数据。所以空值TTL我个人最长不超过60秒。2.3 布隆过滤器数据库前面加一道“存在性门卫”空值缓存能解决问题但有一条尴尬的边界如果穿透流量每次都用不同的不存在ID空值缓存不断被写入Redis里会积累大量无意义的空键白白占用内存。对于这种情况更优雅的方案是布隆过滤器。布隆过滤器的原理可以这样理解它用几个哈希函数把key映射到一个很长的位数组上查询时检查这些位是否全部为1如果全是1就说“可能存在”只要有0就“一定不存在”。它最吸引人的特点是空间效率极高——存100万个key只需要几MB内存代价是存在一定的误判率会把“不存在的key”误判成“存在”但绝不会把“存在的key”误判成“不存在”。在实际项目里我通常用Guava的BloomFilter来维护有效订单号的集合在数据初始化时把数据库里的所有合法ID刷进去。请求进来后先过BloomFilter未通过的直接拒绝回源BloomFilterString bloomFilter BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), expectedSize, 0.01); // 启动或定时任务里加载数据库全部ID for (Long id : orderIdService.listAllIds()) { bloomFilter.put(String.valueOf(id)); } // 查询前先判断 if (!bloomFilter.mightContain(orderId)) { return null; // 直接拦截不打缓存不打库 }用BloomFilter还有个额外收益因为误判方向是“可能存在”所以即使某个合法ID被漏掉导致缓存未命中它也不会被误杀在门口。即使有极小概率放进来一个不存在的key背后还有空值缓存兜底。两套方案组合使用穿透问题基本可以封死。布隆过滤器也并非没有坑。最明显的是“删除”问题布隆过滤器不支持删除操作因为某个位可能是多个key共同占用的直接把位置0会误伤其他key。业务上如果有频繁删除ID的需求建议用带计数器变体或者周期性重建布隆过滤器。另一个坑是扩容数据量涨过预期后误判率会上升需要及时重建。我在生产环境里是每周用定时任务重建一次核心ID集合保证过滤器始终和数据库大体一致。3. 缓存雪崩大面积Key同时失效的连锁反应缓存雪崩说的是另一类事故大量缓存的key在同一时间段内集中过期或者Redis实例本身发生故障导致大量请求同时涌入数据库数据库连接数被打满进而引发更广范围的故障。如果说穿透是“针尖大的洞”雪崩就是“整面墙塌了”。我记得处理过的双十一大促前运营同学在后台一次性导入了一批商品的预热信息代码里写死了缓存过期时间统一设为24小时。结果24小时一过这批商品集体过期配上晚高峰流量数据库压力瞬间翻了三倍。问题的根源不是Redis坏了而是“过期时间太均匀”。3.1 雪崩的三种典型成因我把雪崩的成因归成三类排查时先对号入座第一是集中过期。大量同批数据设置了完全相同的过期时间到点一起失效流量峰值直接砸向数据库。这是最常见的人为设计失误也是本节代码里用随机化能解决的问题。第二是Redis实例宕机或者主从切换。实例不可用期间所有请求都查不到缓存全部落库。这种成因已经超出“缓存设计”范畴了属于高可用问题需要靠主从哨兵、Redis Cluster、多副本等手段兜底。第三是缓存重建风暴。某个key过期后高并发下大量线程同时发现缓存未命中同时去数据库重建缓存数据库被打穿。这个场景严格来说更接近“击穿”但实际线上往往是“热点key过多同时过期”共同作用看起来也像雪崩。3.2 过期时间随机化从源头打散失效节奏针对集中过期业界最常用的手段是给TTL加随机偏移量。比如原本固定3600秒现在改成3600秒加上一个0到300秒的随机数。这样即使同一批数据写入失效时刻也会被摊开数据库受到的压力从“一个尖峰”变成“一片缓坡”。int baseTtl 3600; int ttl baseTtl ThreadLocalRandom.current().nextInt(300); redisTemplate.opsForValue().set(cacheKey, value, ttl, TimeUnit.SECONDS);这个小改动看起来微不足道实际收益非常大。我在团队里推行的是“所有批量写入缓存的定时任务TTL必须随机化”并且把这一条写进了代码规约。除了随机化还有两种变体值得参考一种是不设置过期时间改成物理上定期手动更新但需要额外的治理流程另一种是给每个key维护“逻辑过期时间”把过期时间放在value里读的时候判断逻辑是否过期过期后异步刷新缓存物理上永远不过期。后一种方案跟“缓存击穿”的逻辑过期方案是同一个套路后面会细说。值得提醒的是随机化TTL并不能解决所有雪崩场景它只处理“人为集中过期”这一类。如果你遇到的是Redis宕机引发的雪崩随机化就毫无意义你需要的是高可用架构和降级开关。3.3 多级缓存与高可用雪崩时的最后防线我做的订单服务之所以能在两次事故里快速恢复靠的不只是把TTL打散还有一层“本地缓存Redis”的多级缓存结构。在应用进程内用Caffeine维护一批热点数据的本地缓存本地命中的请求根本不会走到Redis更不会走到数据库。虽然本地缓存有数据一致性问题需要设置较短的过期时间比如1到2分钟但作为雪崩时的缓冲带它能兜住大量读流量。多级缓存的读链路是先查本地缓存Caffeine毫秒级进程内未命中再查Redis再未命中才查数据库。当Redis故障时本地缓存仍然能顶上一部分流量给数据库腾出喘息空间。实测下来一个配置合理的本地缓存通常能挡住20%到40%的读请求这在大促场景里就是活命的分母。对于Redis自身的高可用我的建议是从单节点升级到哨兵模式或者Cluster模式。尤其要注意的是主从切换期间的请求重试问题。Lettuce客户端在节点切换时如果配置不当会出现RedisCommandTimeoutException这类超时错误给本就紧张的服务雪上加霜。所以配置里必须加上合理的重试策略和超时时间别让Redis故障把应用线程全部拖死。数据库侧的兜底同样不能少。连接池上限要收敛避免请求直接把数据库连接数打爆同时准备降级开关——当数据库压力超过阈值服务先返回缓存的旧数据或者预设的默认数据宁可让部分请求拿到稍微过时的结果也不能让整个服务不可用。这个降级策略在线上大促时被验证过很多次虽然不完美但能保证系统的“最低存活能力”。4. 缓存击穿很多人分不清的“表兄弟”文章标题写的是穿透和雪崩但我必须把缓存击穿也拉出来讲清楚。因为在真实工作里面试和线上排查中“击穿”和“雪崩”被混用的频率非常高团队里经常有人把“一个热点key过期导致数据库被打爆”称为雪崩。严格来说它们不是一回事。4.1 击穿、穿透、雪崩的三角关系击穿的特指场景是某个非常热门的key比如爆款商品的详情、大促活动的配置在某个时刻过期同一毫秒内有成千上万个请求发现缓存未命中全部涌向数据库。它和穿透的区别在于——穿透查的是“不存在的数据”击穿打的却是“真实存在且极其热门的数据”它和雪崩的区别在于——雪崩是大量key成片失效击穿是单个key的“单点失效事故”。三个问题的表现和核心解法我平时是这样给团队整理的场景触发原因典型表现核心解决思路缓存穿透查询数据库不存在的数据无效请求持续打穿缓存直达DB参数校验、空值缓存、布隆过滤器缓存击穿单个热点key过期单一key回源风暴DB压力瞬时升高分布式锁、逻辑过期、热key永不过期缓存雪崩大量key同时过期或实例故障缓存整片失效DB压力持续高位TTL随机化、多级缓存、高可用、限流降级4.2 分布式锁用串行化换缓存重建的稳定性缓存击穿的处理思路核心是“让同一个key的重建过程串行化”。既然高并发下不允许所有线程都去查库那就让第一个发现缓存未命中的线程去查库写缓存其他线程要么等待要么直接返回旧值。我最常用的是基于Redis的分布式锁。简单说就是缓存未命中时先尝试用SETNX获取一个针对该key的锁拿到锁的线程去查库重建缓存拿不到锁的线程短暂等待后重新读缓存。这里分享一份可以直接落地的代码public OrderDTO getOrderById(String orderId) { String cacheKey order: orderId; Object cacheValue redisTemplate.opsForValue().get(cacheKey); if (cacheValue ! null) { return (OrderDTO) cacheValue; } String lockKey lock:order: orderId; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(locked)) { // 拿不到锁说明有其他线程正在重建缓存短暂休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getOrderById(orderId); } try { // 二次检查防止锁等待期间缓存已由其他线程重建 Object again redisTemplate.opsForValue().get(cacheKey); if (again ! null) { return (OrderDTO) again; } OrderDTO order orderMapper.selectById(orderId); redisTemplate.opsForValue().set(cacheKey, order, 300, TimeUnit.SECONDS); return order; } finally { redisTemplate.delete(lockKey); } }这段代码里有两个容易踩的坑。第一个是锁的过期时间。如果业务查库加写缓存的时间超过锁的10秒有效期锁会提前释放后续线程就能同时进入重建逻辑分布式锁形同虚设。解决思路有二把锁有效期设得比最慢的查询还长或者引入“续期线程”守护。第二个坑是锁释放的归属问题finally里直接delete可能误删其他线程刚拿到的锁严谨做法是在释放前用Lua脚本校验这把锁是不是当前线程持有的。生产级别的实现建议直接用Redisson它把看门狗续期和原子释放都封装好了别自己在SDK层面造锁省得造出事故。4.3 逻辑过期方案物理上不过期逻辑上会过期如果热点key非常顽固分布式锁带来的等待体验也不能忍受那就尝试“逻辑过期”方案。做法是缓存写入后不设物理过期时间让key永久存活但value里塞进一个业务字段例如{data: {...}, expireTime: 1710000000000}。读取时判断当前时间是否超过了expireTime未超过正常返回数据已超过当前线程仍然返回旧数据同时触发一个异步任务去数据库重建缓存并更新expireTime。这个方案的好处是读到过期数据的用户不会感知到延迟数据库重建压力被异步化为低并发的更新任务不会出现大量线程同时回源的情况。需要管理的是异步线程池的容量和重建频率防止更新任务堆积。我在做商品详情页时对权重最高的几个SKU就是用这个方案效果比锁方案更平滑唯一的代价是数据在极端场景下会短暂过期几秒业务要能接受这种粒度。5. 一线落地我常用的缓存防护组合与监控指标前面把三个问题的原理和单点方案讲完了但真实系统从来不是靠某一个方案包打天下。我自己在项目里是把这些措施组合成分层防护网每一层职责不同层层拦截。5.1 分层防护总体设计我习惯把缓存治理架设在四层结构上从外到内依次是网关/接入层限流、参数校验、黑白名单。拦截畸形ID、超长参数、高频恶意请求这是成本最低的一道闸。应用进程内Caffeine本地缓存为热点数据提供毫秒级的本地命中同时作为Redis故障时的缓冲带。Redis缓存层TTL随机化、空值缓存、布隆过滤器前置判断、热点key的逻辑过期处理。数据库层连接池上限收敛、读写分离、慢查询告警必要时启用降级开关直接返回兜底数据。这套组合拳的好处是即便某一层被突破下一层还能把伤害挡住。比如攻击者突破了网关的简单校验那BloomFilter会拦住大多数不存在的key万一布隆误判放行了空值缓存还能兜住就算真的有个合法热点key过期了分布式锁或逻辑过期方案会保证数据库不被同一时间的大量回源压垮。5.2 监控和告警没有指标再好的方案都是盲打方案落地后监控是让一切变得可追踪的关键。我至少会盯这几个指标任何一个出现异常都能第一时间定位到是穿透、击穿还是雪崩Redis命中率常态维持95%以上出现低于90%的持续下降就要警惕异常流量或大范围过期。Redis各命令QPS重点关注GET命令的未命中数量如果未命中集中在少量key更像击穿如果分散在大量不同key更像穿透或雪崩。数据库连接池活跃数与慢查询数连接池瞬间打满往往是数据库侧压力的最直接信号。缓存过期事件分布通过慢日志分析或者埋点统计同一时间窗口内过期key的数量判断是否有集中过期。本地缓存命中率这块很多人会忽略。本地缓存命中率下降往往是全局命中也同步下降的前兆。告警阈值要根据业务正常基线去定不能拍脑袋。我自己处理大促订单服务时会把Redis命中率下降超过5%的告警设为P1数据库活跃连接数超过池容量70%的告警设为P1这两个指标基本能在事故酿成大祸之前给出预警。5.3 踩坑记录与经验心得最后整理几个我在实际项目里反复踩、也一直被问到的坑希望你绕开。第一Redis的key设计会影响BloomFilter和分布式锁的成败。在Redis Cluster下要保证同一个业务key比如同一个订单ID的缓存key和锁key哈希到同一个槽位给key加统一的hashtag前缀是常见做法否则锁的粒度和缓存分散在不同节点逻辑会出问题。第二空值缓存别滥用。如果业务本身允许大量短暂不存在的查询比如搜索词的即时校验空值缓存会把内存吃掉这时候用BloomFilter更合适。反过来如果业务数据极少不存在简单加空值缓存就够了不必上BloomFilter增加维护成本。第三逻辑过期方案和缓存击穿锁方案的选择取决于业务对“瞬间过期导致的旧数据短暂可见”的容忍度。我做交易订单时用锁方案因为订单数据不能有几秒的过期视图做商品详情页时用逻辑过期因为商品信息偶尔旧几秒用户根本感知不到。方案没有高低之分只有是不是匹配你的业务场景。第四限流、降级这类“抛弃型”策略一定要有预案。雪崩发生的时候一味追求数据准确往往适得其反。提前定好“当数据库压力超过阈值接口返回默认兜底数据”的规则并在压测中验证过这条路能走通比事故发生时临场讨论靠谱得多。缓存穿透、击穿、雪崩这三个问题原理都不深难的是在真实流量下把防护体系搭得足够严实并且让监控能第一时间告诉你“哪一层被突破了”。按这套组合方案去落地再把监控指标对准那几条关键曲线至少能保证你的Redis不会在关键时刻变成数据库的“帮凶”。
返回列表