ARTICLE DETAIL

资讯详情

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

Redis缓存雪崩全解析:从根因到实战防御方案

Redis缓存雪崩全解析:从根因到实战防御方案 凌晨两点值班手机被报警震醒线上数据库连接数从200一路冲到3000客服群已经开始刷屏。打开监控后台一看Redis集群的命中率从前一天的99.2%直线掉到不足10%缓存过期回收曲线上出现了一根笔直向上的尖峰。不用再看第二眼就能判断缓存雪崩了。这不是什么小众边缘场景。只要你的系统用Redis做缓存并且给key设置了过期时间就总有一天会撞上这个问题。缓存雪崩是Redis面试题里的常客也是生产环境事故报告里的常见主角。这篇文章不聊虚的直接把这个事从根上拆开讲清楚雪崩是怎么发生的以及我实测过、在线上验证过的几套应对方案从过期时间设计到多级缓存从熔断降级到预热恢复一条条给到你。全文面向的是有一定Redis使用经验、正在为系统稳定性头疼的开发者。如果你刚接触Redis先弄懂缓存的基本用法再回来看效果更好。1. 先把雪崩的“病根”找到到底是什么在崩溃1.1 穿透、击穿、雪崩别再当成一回事很多人把缓存穿透、缓存击穿、缓存雪崩混为一谈面试的时候答不清楚排查事故的时候也会走弯路。这三个问题看着像本质完全不同应对方案也完全不同。缓存穿透指的是请求的数据在缓存里没有在数据库里也没有每次请求都直接打到DB上等于缓存白挂了。这种情况通常出现在查一个不存在的ID或者恶意攻击者故意构造大量不存在的key。应对思路一般是布隆过滤器或缓存空值把“查不到”的结果也缓存起来。缓存击穿指的是某一个热点key在失效的瞬间大量请求同时打到数据库。注意这里说的是“一个”key。比如某个爆款商品的详情大家都在刷这个key一过期后面所有的请求都穿透到DB上数据库瞬时压力巨大。应对思路是互斥锁、逻辑过期核心是“单点保护”。缓存雪崩才是我们今天的主角。它说的是“一大批key”在同一时间失效或者Redis服务本身挂了导致大量请求绕过缓存直接冲击数据库。它不是单点问题是面状问题后果也更严重——数据库直接被拖垮之后即使缓存恢复了数据库也未必能立刻缓过来整个系统可能陷入长时间不可用。很多人不知道的是雪崩的杀伤力不是来自“缓存没数据”而是来自“瞬间流量集中”。一个key击穿的时候流量再大也只是对一个key的查询而雪崩是整个业务面的请求全部回源数据库连接池、CPU、磁盘IO、网络带宽全方位被打满恢复起来要花很长时间。1.2 两条触发路径一个因key一个因Redis本身我把缓存雪崩的触发路径分成两类方便排查时快速定位。第一类是key集中失效导致的雪崩。典型场景是业务上线时批量写入了一批缓存统一设置了相同的过期时间比如都设置成1小时。那么1小时后这一批key会同时过期同一时刻的缓存未命中率瞬间飙升请求全部回源到数据库。这种情况一周能遇到好几次很多人还以为是数据库变慢了查了半天才发现是key集中过期。第二类是Redis服务不可用导致的雪崩。Redis节点宕机、网络分区、内存满了触发淘汰策略都会让缓存从“有”变成“无”。这种情况比key集中失效更可怕因为Redis是不可用了不是个别key失效所有依赖Redis的请求都会回源。如果系统没有做降级数据库面对的就是全部流量的直接冲击。我在线上见过最典型的例子是业务方为了省内存把maxmemory-policy设置成了allkeys-lru高峰期内存被打满之后Redis开始疯狂淘汰key命中率掉了一半数据库连接数跟着暴涨。这就是典型的不设防导致的雪崩场景。所以排查问题的时候先分清是key层面的问题还是Redis节点层面的问题方向对了方案才有意义。1.3 数据库的“崩溃临界点”到底在哪聊应对方案之前先说清楚一个核心数字数据库能扛住的并发远低于缓存的并发。一个单机Redis实例读QPS做到10万甚至20万都不稀奇一个单机MySQL正常业务下能稳定扛住几千QPS就算不错了一写入复杂SQL、一上锁几百就可能打满CPU。假设你有100万个key同时失效如果这些请求全部在1秒内打到数据库就是100万QPS压过来别说MySQL再高配的数据库也扛不住。但如果把这一百万key的失效时间摊开到1小时里平均每秒只有不到300个请求回源大部分数据库都能轻松应对。这个简单的换算其实就是缓存雪崩应对方案的核心思想削峰填谷。所有的方案要么是把流量的波峰削掉要么是给流量找一个不会被打挂的去处要么是让数据库本身变得更抗揍。下面几章的内容都是围绕这个思想展开的。2. 第一道防线从源头把“同时失效”干掉2.1 过期时间随机化不是简单加个随机数先给出最基础、性价比最高的方案过期时间随机化。思路很简单就是在设置缓存过期时间的时候不要给所有key设置同一个固定值而是在基础值之上加一个随机偏移量。目的就是不让key在同一个时刻集体失效。网上很多文章会给你这样的示例// 错误示例这样问题不大但不够精细 int expire 3600 new Random().nextInt(600); redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);这段代码能解决一部分问题但我实际用下来发现有两个坑。第一个坑是new Random()在并发场景下性能很差而且随机种子如果初始化不当生成的序列可能并不均匀高并发下会加剧碰撞的概率。第二个坑是随机范围太小时效果不明显比如固定1小时加0到600秒这批key的过期时间仍然集中在1小时到1小时10分钟这个很窄的窗口里一旦业务量够大这个窗口期仍然会形成明显的流量波峰。我更推荐的做法是按key的维度做哈希分散或者用ThreadLocalRandom来控制随机分布区间同时把随机范围拉开。private static final int BASE_EXPIRE 60 * 60; private static final int EXPIRE_RANGE 30 * 60; // 基于key的哈希做固定偏移 int expire BASE_EXPIRE (key.hashCode() 0x7fffffff) % EXPIRE_RANGE; // 或者用ThreadLocalRandom并发性能更好 int expire BASE_EXPIRE ThreadLocalRandom.current().nextInt(EXPIRE_RANGE);用key的哈希做偏移有一个好处同一个key每次设置过期时间都一致不会因为随机波动导致缓存反复重建对于有缓存预热需求的场景更友好。而且哈希分布足够均匀一批key写进去之后过期时间会自然摊开在一个区间内。2.2 业务错峰把整点失效拆成流水过期时间随机化是通用的兜底手段但光靠它还不够。很多业务场景里key集中失效不是因为“运气不好”而是因为业务逻辑本身就把key设计成了同时失效。最常见的例子是每日0点的定时任务。很多系统习惯在0点整跑一批批处理任务把计算结果写进缓存过期时间统一设置成24小时。这就等于每天23:59:59昨天写进去的那批key会同时过期然后新任务还没跑完期间的所有请求全部穿透到数据库。这种场景的解法不是随机化就能解决的而是要从业务设计上错峰。我的做法是把一个大任务拆成多个批次每批次延迟执行比如0:00处理第一批0:05处理第二批0:10处理第三批让key的写入和过期都变成“流水线式”的而不是“整点齐射式”。另一个高频场景是活动预热。比如电商大促前把一批商品信息提前写入缓存预热的时候为了方便设置了一个统一的过期时间。这个时间一到大促还没结束缓存就全没了数据库直接被流量冲垮。正确的做法是预热时按品类或按商家维度设置不同的过期时间热度高的商品过期时间长一点热度低的短一点既省内存又防雪崩。2.3 热点key永不过期逻辑过期与后台重建随机化和业务错峰能解决大范围的key同时失效问题但对于少数热点key还有一个更彻底的办法不让它在物理上过期。这里的“永不过期”不是真的不设置过期时间那样Redis内存会被撑爆。而是采用“逻辑过期”的方式——key在Redis里不设过期时间但value里存了一个逻辑过期时间戳。读取的时候判断时间戳发现过期了先把这个旧数据返回给调用方同时触发一个异步任务去数据库重建缓存。这样做的好处非常明显Redis层面不存在key失效的概念也就不会因为热点key过期而打穿数据库。坏处是调用方会短暂读到旧数据所以只适合对一致性要求不高的场景。下面是一个简化版示例// 写入时不设置Redis过期时间把逻辑过期时间放在value里 CacheItemString item new CacheItem(value, System.currentTimeMillis() 30_000); redisTemplate.opsForValue().set(key, JSON.toJSONString(item)); // 读取时 String json redisTemplate.opsForValue().get(key); if (json null) { return loadFromDb(key); // 物理上不存在走DB } CacheItemString cacheItem JSON.parseObject(json, CacheItem.class); if (cacheItem.getExpireAt() System.currentTimeMillis()) { return cacheItem.getValue(); // 未过期直接返回 } // 逻辑过期先返回旧值再异步重建 asyncExecutor.submit(() - rebuildCache(key)); return cacheItem.getValue();逻辑过期方案需要配合后台重建任务使用同时要做好防并发重建的保护否则多个请求发现过期后同时触发异步任务数据库一样会被打垮。我的做法是重建任务里加一个分布式锁同一时刻只有一个线程去查DB其他线程如果发现锁被占用就放弃本次重建等下次读取时再说。3. 第二道防线多级缓存把流量挡在DB前面3.1 本地缓存到底能扛住多少流量就算你把过期时间设计得很完美也架不住Redis本身出问题。Redis一旦挂掉所有的缓存保护全部失效。所以一个成熟的系统不能把鸡蛋全放在Redis一个篮子里需要在应用层再加一道缓存——本地缓存。本地缓存就是把数据缓存在应用进程的内存里比如用Caffeine或者Guava Cache。请求来了之后先查本地缓存本地没有再去查RedisRedis也没有才去查数据库。这样做的好处是当Redis不可用的时候本地缓存还能接住一部分请求给系统争取到恢复时间。而且本地缓存的访问不走网络不占Redis连接性能上反而是最快的。在高并发场景下本地缓存能把大量重复请求消化在应用进程内部极大地降低对Redis和DB的压力。我见过一个实际案例某接口的QPS是2万加了本地缓存之后真正打到Redis的请求不到2000Redis的压力直接降了一个数量级。这就是本地缓存的威力——它天然起到了流量过滤的作用。3.2 本地缓存的落地细节与一致性问题本地缓存不是随便加个Map就行工程上有几个细节必须处理好。第一个是缓存大小要限制。本地缓存在应用进程内如果数据量太大会把JVM堆内存撑爆。我用Caffeine时一般会设置maximumSize和initialCapacity比如最多缓存1万个key这样即使热点数据再多也不会失控。CacheString, Object localCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(30, TimeUnit.SECONDS) .build();第二个是缓存过期时间要设置得比Redis短。这一点是很多新手容易踩的坑——本地缓存时间如果设置得比Redis长一旦缓存了脏数据Redis里的最新数据就永远无法被读到。我的经验是本地缓存时间设置为Redis过期时间的20%到50%比如Redis设置1小时本地就设置10到15分钟。这样既保证了大部分请求命中本地缓存又不会让脏数据停留太久。第三个是多实例部署下的一致性问题。本地缓存是进程级的A机器和B机器上的缓存数据可能不一样。如果业务对数据一致性要求极高本地缓存就不合适。但对于商品名称、用户昵称这类不敏感的数据短暂的不一致完全可以接受。4. 第三道防线熔断、降级、限流如何配合4.1 为什么“宁降级也不打挂DB”实际操作中防线再完善也有防不住的时候比如突发流量远超预估、数据库出现慢查询拖垮连接池等。这时候你面临的选择不是“怎么保证数据完整”而是“让数据库挂掉还是让部分功能不可用”。我的原则很明确宁可短暂降级也不能让数据库挂了。因为数据库一挂连恢复的入口都没有了整个系统会陷入长时间不可用而降级只是牺牲一小部分功能核心链路还能走。所谓降级就是当缓存和数据库都压力过大时主动返回一些降级结果比如默认值、旧缓存数据、或者是“稍后重试”的提示而不是继续让请求打到数据库上去。这里要纠正一个常见的错误思维很多人觉得降级是万不得已才用的能不用就不用。实际上降级是系统设计的一部分应该在系统上线前就规划好。哪些接口可以降级、降级返回什么数据、降级之后如何恢复这些都应该在架构设计阶段就想清楚。4.2 熔断降级的工程实现熔断降级的实现方案线上常用的有Sentinel、Resilience4j等也可以用简单的状态机自己实现。核心逻辑就是系统检测到缓存或数据库调用失败率达到阈值时熔断器打开后续请求不再尝试调用下游而是直接走降级逻辑过一段时间进入半开状态放少量流量试探如果试探成功熔断器关闭恢复正常调用。我用Resilience4j做过的简单配置如下CircuitBreakerConfig config CircuitBreakerConfig.custom() .failureRateThreshold(50) // 失败率超过50%触发熔断 .waitDurationInOpenState(Duration.ofSeconds(10)) // 熔断持续时间 .permittedNumberOfCallsInHalfOpenState(10) // 半开状态允许试探的请求数 .build(); CircuitBreaker circuitBreaker CircuitBreaker.of(redisChecker, config); SupplierString supplier () - redisService.get(key); SupplierString decorated CircuitBreaker.decorateSupplier(circuitBreaker, supplier);这里我把熔断的粒度设为“单个Redis操作”而不是整个服务。这样即使Redis出了波动用户也只是看数据稍微慢了一点不会导致整个接口不可用。降级策略要结合业务设计。比如商品详情页Redis挂了之后可以降级返回本地缓存里的商品名称和价格不返回库存和推荐位再不行就返回一个默认的“商品已下架”提示。这些数据可能不是最新的但至少用户能看到页面、系统不会挂掉。等Redis恢复之后再逐步放开完整数据的返回。4.3 限流与加锁排队严防重试风暴限流这个事放在雪崩场景里特别容易被忽视。你会想我们系统里没配限流不也跑得好好的吗问题是一旦雪崩发生客户端看到请求超时往往会自动重试如果你的客户端的重试逻辑没有做好退避就会形成“重试风暴”——原本1万的请求重试3次就变成4万再重试就变成16万数据库压力直接翻几倍。有一年我排查一个线上事故数据库连接数猛增到接近极限查了半天发现不是业务流量涨了而是某个服务调用Redis超时后默认重试了5次重试之间没有任何间隔。原来1000的并发瞬间变成6000多。所以防雪崩的必备操作之一就是所有下游调用的重试必须有退避策略重试次数必须有限制。# 重试配置示例 maxAttempts: 3 waitDuration: 500ms retryExceptions: - RedisConnectionFailureException另外还有一种更优雅的做法叫加锁排队。适用于单热点key重建的场景当某个key失效时只允许一个请求去数据库加载数据其他请求先等待或直接返回旧值。用一个简单的分布式锁就能实现String lockKey lock: key; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(3)); if (locked) { try { // 只有一个线程能走到这里 Object value loadFromDb(key); redisTemplate.opsForValue().set(key, value, randomExpire); return value; } finally { redisTemplate.delete(lockKey); } } else { // 让其他请求短暂等待后重查缓存 Thread.sleep(50); return redisTemplate.opsForValue().get(key); }注意这个方案只适合少量热点key的击穿场景不适合大量key同时失效的雪崩场景——雪崩时如果给每个key都加锁锁本身的竞争反而会成为新的瓶颈。关于分布式锁业界更推荐直接用Redisson这类自带看门狗续期机制的库避免业务逻辑执行时间超过锁过期时间的问题。锁不是越大越好也不是越小越好核心是让回源DB的并发控制在一个安全范围内。5. 第四道防线Redis自身高可用与恢复预案5.1 主从、哨兵、Cluster怎么选前面说了Redis本身宕机也是雪崩的重要诱因所以Redis部署架构的高可用设计本身就是雪崩应对的一部分。单机Redis就不用说了生产环境坚决不要用。一旦宕机整个缓存层瞬间失效这是最典型的雪崩触发场景。主从哨兵是中小体量项目最合适的方案。主节点负责读写从节点负责读流量哨兵负责监控主节点的健康状况主节点挂掉后自动将从节点提升为主节点。整个切换过程对应用层是透明的连接地址不变。Redis Cluster适合数据量大、并发量高的场景。它把数据分片存储到多个节点上每个节点都有主从副本任何一个节点挂了集群会自动做故障转移。但Cluster对客户端有要求需要支持Cluster协议而且多key操作要小心跨节点的批量操作性能会受影响。我的建议是单机Redis超过10G、QPS超过几万或者对可用性要求极高直接上Cluster否则主从哨兵就够了。另外无论哪种方案内存淘汰策略都要提前设置好建议用volatile-lru或allkeys-lru并给Redis预留足够的内存水位线避免内存被打满后触发大面积key淘汰。5.2 缓存预热与故障后的恢复顺序Redis故障恢复之后有一个很容易被忽略的问题冷缓存启动。假设Redis重启完成节点恢复了但里面没有任何数据。这时候如果直接放流量进来所有请求都会穿透到数据库等于雪崩又来了一遍。所以Redis恢复之后不能直接上线要先做缓存预热。预热的思路是把业务里访问量最高的那些key提前从数据库加载并写入Redis。怎么找出这些热点key一个简单有效的办法是从日志里捞——统计最近半小时内访问量最高的前N个key或者从DB的慢查询日志里找哪些SQL的查询参数最频繁。预热的执行可以分批次进行避免一次性把数据库压垮。比如先预热Top 100的key再预热Top 1000的key期间观察数据库的负载情况负载稳定再继续扩大预热范围。# 预热脚本伪代码 for key in top_hot_keys: value db.query(key) redis.set(key, value, expire_with_random_jitter()) time.sleep(0.1)预热完成后不要立即把限流和熔断全部放开而是逐步调低熔断阈值观察Redis命中率恢复到了什么水平。命中率恢复到95%以上之后再把限流放开到正常水平。这个“慢恢复”的过程是我吃了不少亏之后总结出来的恢复阶段如果太激进很容易造成二次雪崩。6. 数据库的兜底保护与急性期止血6.1 连接池保护、读写分离与db限流如果前面几道防线都被突破了数据库真的开始承受压力这时候还有最后一层保护措施——数据库侧的兜底。第一个要做的是连接池保护。很多系统数据库连接池配置得非常大比如maxActive500这反而是一个隐患。当大量请求打到DB时连接池会瞬间被占满每个连接都在等待数据库响应但数据库已经完全处理不过来了结果是连接池里的线程全部阻塞应用层也跟着被拖垮。把连接池限制在合理范围比如200或者300超出部分的请求直接快速失败反而能保住应用的响应能力。第二个是读写分离。雪崩发生时大多数回源查询都是读操作。如果已经有读写分离架构把读流量分发到从库主库的压力会小很多。但从库也可能被打垮所以依然要对从库的调用做限流和熔断。第三个是数据库限流。可以在DAO层做一层简单的信号量控制限制并发查询DB的数量。超出阈值的请求直接走降级逻辑不要让它们阻塞在数据库调用上。private final Semaphore dbSemaphore new Semaphore(50); public Object queryFromDb(String key) { if (!dbSemaphore.tryAcquire()) { return fallbackValue(key); // 降级返回 } try { return db.query(key); } finally { dbSemaphore.release(); } }6.2 db被击穿后的恢复步骤如果数据库已经被打挂了恢复的顺序非常重要。很多人一看到数据库恢复了就立刻放开所有流量结果数据库再次被打挂反复几次之后整个系统的可用性降到了冰点。正确的恢复步骤是先确认Redis已经完全恢复并完成预热命中率恢复正常再逐步放开对DB的限流阈值观察数据库连接数、CPU、慢查询等指标稳定一段时间后再放开下游服务的重试机制和本地缓存的过期策略最终让系统恢复到正常状态。如果数据库一时半会儿恢复不了还有一种临时方案直接降级到只读模式。比如把一些非核心查询改成直接返回固定数据或者只支持查询最近的热点数据把核心功能保住等凌晨低峰期再修复。另外一个很多人忽略的点是缓存和数据库之间的一致性。雪崩恢复期间大量旧缓存被重建如果此时DB里恰好有更新操作可能会把旧数据缓存下来。所以恢复后要注意观察数据一致性必要时可以清掉部分缓存让其重新加载。7. 常见问题与避坑实录7.1 高频踩坑场景速查我把这些年做缓存治理过程中踩过的坑、以及帮别人排查雪崩问题时常见的问题整理成了一张表方便你对照自查现象可能原因解决方案key设置了随机过期时间仍然出现集中失效尖峰随机范围太小或者业务写入时就集中在同一秒加大随机范围按key哈希分散结合业务错峰Redis挂了之后应用也跟着假死没有熔断降级请求全部阻塞在Redis调用上加熔断器Redis异常时快速失败走降级逻辑数据库连接池被占满应用线程全部阻塞连接池maxActive配置过大或DB层无限流控制连接池大小DAO层加信号量限流恢复Redis后数据库又被打崩了冷缓存启动就直接放量先预热热点key再逐步放开流量客户端一直重试流量被放大好几倍重试逻辑没有退避策略重试次数无上限限制重试次数设置等待时间超过阈值快速失败本地缓存读到很旧的数据本地缓存过期时间设置比Redis还长缩短本地缓存过期时间设为Redis的20%-50%Redis内存被打满key被大量淘汰maxmemory-policy设置不当或内存水位过高调整淘汰策略预留内存监控内存使用率这张表里的每一个问题我都曾经在生产环境或者压测环境里真实遇到过。特别是“随机范围太小”这个坑很多时候不是因为你不懂方案而是因为方案做得不够彻底只解决了一半问题。7.2 排查工具与监控指标最后说说排查雪崩问题要用到哪些工具和监控指标这些是我每次进行“缓存治理”时几乎必看的。Redis侧最重要的指标是命中率keyspace_hits / keyspace_misses和expired_keys的变化速率。我在Grafana里配了一张图专门监控expired_keys的瞬时变化如果看到某个时段出现尖峰立刻就能定位到是不是有key集中过期。另一个是Redis的info all中的evicted_keys这个指标一旦上涨说明内存淘汰正在发生也是雪崩的预警信号。数据库侧我最关注的是Threads_connected连接数和Threads_running活跃线程数。当连接数快速上升、活跃线程数接近CPU核心数时数据库往往已经处于高负载状态。另外还要开慢查询日志看雪崩期间有没有大量慢SQL出现。应用侧核心看P99延迟和错误率。正常情况下P99是几十毫秒一旦雪崩发生P99可能直接飙到几秒甚至超时。同时观察应用的线程池状态和Full GC情况——缓存失效导致的回源查询会大量占用工作线程线程池被打满之后整个应用的吞吐量会断崖式下跌。定位问题的基本思路是先看应用P99是否上涨再看Redis命中率是否下降再看DB连接数是否飙升最后结合expired_keys和慢查询日志判断根因。三步下来90%的雪崩场景都能快速定位。关于缓存雪崩我最后再分享一个个人经验不要指望某一个单独方案能彻底解决雪崩问题。有些团队只做了过期时间随机化就觉得万事大吉结果Redis宕机时照样翻车有些团队上了熔断降级却忽略了key集中过期的问题。真正可靠的做法是把本文提到的几道防线组合起来——设计时错峰、应用层加本地缓存、调用层做熔断限流、Redis本身做高可用每道防线都能挡住一部分风险叠加起来才能扛住极端情况。你在自己项目里至少也要保证“过期随机化多级缓存降级兜底”这三板斧到位让同一时刻落到数据库的流量始终控制在数据库能承受的范围之内。
返回列表