资讯动态

缓存穿透、击穿、雪崩:成因、判定与解决方案全解析

发布时间:2026/10/22 9:33:57 来源:尧图企业网站定制
做后端这几年缓存相关的坑一个都没少踩。尤其是缓存穿透、缓存击穿、缓存雪崩这三个问题几乎每个跟高并发、热点数据打过交道的系统都会遇到。很多新手把这些概念背得滚瓜烂熟面试也能说出一二三但真到了线上Redis 突然被打满、数据库连接池瞬间耗尽的时候才发现自己连“当前到底是穿透还是击穿”都分不清。这篇文章不打算讲教科书上的定义而是直接从故障现场出发把这三兄弟的成因、判定方法、解决方案的选型逻辑、以及我实际踩过的坑一次说清楚。不论你是刚接触缓存的新手还是已经维护过一阵子老系统的开发者这篇文章都能给你一套可以直接抄作业的落地方案。1. 先从三兄弟的成因说起——一张图看清缓存问题模型想搞懂这三个问题先要建立一个核心认知它们全都是“缓存没起到预期作用”时请求打到数据库上引发的连锁反应。区别只在于“没起到作用”的原因不同。1.1 缓存穿透查了个根本不存在的东西穿透的核心特征是请求的 key 在缓存里没有在数据库里也没有。因为数据本身不存在所以缓存永远无法命中每次请求都会穿过缓存层直接打到数据库。举个例子一个商城系统的商品详情接口如果请求了一个不存在的商品 ID比如商品表里根本没有 ID 等于 10000001 的记录那缓存里自然也不会有。正常情况下这个请求会走一遍缓存、查一遍数据库然后返回空。如果只有一两个这种请求数据库根本无感。但要是有人写了个脚本循环请求不存在的 ID数据库就会被无效查询打满连接数飙升最终拖垮整个服务。这类问题的可怕之处在于攻击成本极低危害却不小。随便找个线上接口的参数列表把数字 ID 从 1 试到 1000000就能制造出海量无效查询。我见过一个真实案例某系统的积分详情接口被人用类似方式连续打了两天数据库慢查询日志里全是“SELECT ... WHERE id ?”一堆明显不存在的 ID 在轮询轰炸。1.2 缓存击穿热点 key 瞬间过期击穿的特征是某个热点 key 在缓存过期的瞬间大量请求同时涌入全部穿透到数据库。这里强调“某个”和“瞬间”它是单点问题而且只针对高并发访问的热点数据。还是商城场景某款手机在秒杀活动开始的那一分钟商品详情的数据会被成千上万个用户同时读取。这个 key 平时在缓存里待得好好的结果恰好在这个节骨眼过期了。缓存里一空接下来的所有请求都会在同一时刻打到数据库上数据库一条 SQL 就能查出来的数据硬是被重复执行了几千次。击穿和我们后面要说的雪崩最大的区别是击穿是单个 key 失效雪崩是大面积的 key 同时失效。单点虽然看起来不如雪崩吓人但热点 key 的并发量往往远高于普通key有时候一个热点 key 被打穿造成的数据库压力比雪崩还要严重因为所有流量都集中在同一条数据上。1.3 缓存雪崩大面积 key 同时失效雪崩的特征是短时间内有大量 key 集中过期导致数据库压力骤然升高。它击穿的区别是“面”和“点”的区别——击穿是单兵突进雪崩是集团军压境。最典型的触发场景就是缓存中设置了相同的过期时间。比如业务上线时运营把一批商品配置成了同一时间上架开发图省事写入缓存时统一设置了“缓存 30 分钟过期”。结果就是这批商品的数据在同一时刻集体失效数据库瞬间收到一批本应命中缓存的查询。要是这批数据恰好是首页推荐栏的数据流量集中程度可想而知。还有一种容易被忽略的雪崩场景Redis 服务本身发生了故障或重启。比如主节点宕机切换期间缓存服务不可用所有请求都穿透到数据库。这种问题的本质和“key 集中过期”不同但造成的现象几乎一样——数据库被打垮服务雪崩式下降。2. 缓存穿透的四种解法与选型建议穿透问题的核心矛盾是缓存里存不住“不存在”的数据因为大家默认只有“存在”的数据才需要缓存。解法思路无非两条路一是让无效请求不进数据库二是把“不存在”也缓存起来让它快速结束。2.1 空值缓存简单粗暴的第一道防线这是我最先推荐给新手的一种方案因为它足够直观。既然你查不到数据的时候会比较痛那我就把“查不到”这个结果本身也缓存起来。public Product getProductById(Long id) { // 1. 先查缓存 Object cacheValue redis.get(product: id); if (cacheValue ! null) { return (Product) cacheValue; } // 2. 缓存里没有查数据库 Product product productMapper.selectById(id); // 3. 如果数据库也没有缓存一个空值过期时间设置短一些 if (product null) { redis.set(product: id, , 60, TimeUnit.SECONDS); return null; } // 4. 正常数据写入缓存过期时间正常设置 redis.set(product: id, product, 30, TimeUnit.MINUTES); return product; }这里有几个细节需要特别留意。空值的过期时间一定要设置得比正常数据短得多。我习惯空值过期设 60 秒左右正常数据设 30 分钟或更久。原因很实际假设商店里某个商品只是暂时下架了过几分钟又恢复上架你如果给空值设置了 10 分钟缓存用户就会在这 10 分钟内一直看到不存在。空值缓存只是为了挡住一时的恶意请求没必要长期占用宝贵的缓存空间。另一个容易踩的坑是空值缓存可能会把后面真正存在的请求也挡在外面。我说的不是逻辑上的问题而是编码上的问题。很多人判断缓存有没有命中用的是“缓存值是否为 null”。但空值缓存存进去的是一个空字符串读取的时候这个值不为 null所以正常逻辑里会直接返回空字符串给上层。如果上层把它当成正常数据解析就会出现类型转换异常。所以我建议空值缓存统一返回一个特殊的“空对象”标记而不是直接塞一个空的字符串或者 null。提示空值缓存的本质是“用缓存空间换数据库安全”它解决的是“不存在数据被反复查询”的问题但不建议把过期时间设得太长。你也不希望一堆黑洞 key 长期占用 Redis 内存。2.2 布隆过滤器从源头拦截非法 key空值缓存虽然简单但它有一个绕不开的问题攻击者如果每次构造的 key 都在变化空值缓存就形同虚设。你缓存一个空值他下次换个 ID 再来缓存又没命中又打到数据库。如果对方是机器遍历每秒可以构造几千上万个不同的无效 ID数据库照样扛不住。这种场景下就该上布隆过滤器了。布隆过滤器的工作原理可以用一句话概括它用一个很长的二进制位数组和若干个哈希函数判断一个元素“一定不存在”或“可能存在”。当你把一个商品 ID 加入过滤器时会用多个哈希函数算出几个位置把这些位置都标记为 1。之后判断某个 ID 是否存在时同样算出这些位置只要有一个位置是 0就说明这个 ID 肯定没有加入过过滤器。用 Java 实现时比较常见的库是 Google 的 Guava它内置了布隆过滤器的实现// 初始化布隆过滤器预计存 10 万个商品 ID误判率设为 0.01% BloomFilterLong bloomFilter BloomFilter.create( Funnels.longFunnel(), 100_000, 0.0001); // 系统启动时把商品 ID 批量加载进过滤器 ListLong allProductIds productMapper.selectAllIds(); allProductIds.forEach(bloomFilter::put); // 查询时先用布隆过滤器判断 public Product getProductById(Long id) { // 先判断 id 是否存在 if (!bloomFilter.mightContain(id)) { // 一定不存在直接返回连缓存都不用查 return null; } // 可能存在继续查缓存和数据库 // ... }这里有个容易让人困惑的细节布隆过滤器只能保证“一定不在”和“可能在”。也就是说它说“没有”就一定没有但说“有”的时候不一定真有。不过这个误判率是可以控制的上面代码里 0.0001 意思是 1 万个通过判断的 key 里大约有 1 个是“本来并不存在但被放行了”。这 1 个被放行的请求又会变成一次数据库查询但概率极低对数据库构不成威胁。用布隆过滤器还有两个必须考虑的工程问题。第一是初始数据量要预估准确。布隆过滤器的大小在初始化时就固定了如果你预估要存 10 万个 ID结果上线后实际有 200 万个 ID误判率就会急剧升高。因为位数组不够用所有位置很快都被标记成 1过滤器会退化成“什么都说可能存在”直接失效。所以上线前要充分评估数据量宁可预估得大一些也要避免预估不足。第二是新增数据的同步问题。如果系统支持新增商品那每次新增时都要把新的 ID 也写入过滤器。否则新上架的商品会被过滤器错误地拦截用户怎么都查不到。这一点经常被忽略导致“数据库里有数据但接口就是查不到”的诡异 bug。我的建议是把布隆过滤器的更新放在数据落库之后的同一个事务里或者至少做成异步的一定要执行成功不能默默丢了。2.3 参数校验与增强校验很多人一上来就想着上布隆过滤器、上 Redis 集群反而把最基础的一层忘了接口层的参数校验。布隆过滤器解决的是“合法范围内的 ID 被恶意遍历”但很多穿透请求其实根本不需要进入业务逻辑光靠参数合法性就能挡下来。以商品 ID 为例如果业务约定 ID 是正整数那接口层就应该拦截所有非正整数请求。很多系统用的是 Long 类型接收 ID负数、0、超长数字都能传进来。这些值在业务上一定是不存在的查都不需要查。在 Spring Boot 里可以用注解快速实现GetMapping(/product/{id}) public Product getProduct(PathVariable Min(1) Long id) { // 天然拦截负数、0 等非法参数 return productService.getProductById(id); }更进一步有些系统的 ID 是带业务含义的比如前几位代表分类或者包含特定的校验位规则。这种规则也可以做成参数校验的一部分。我见过一个订单系统订单号里包含一个随机校验码可以通过 mod 运算快速判断格式是否合法。这个校验操作是纯计算不涉及任何 IO几微秒就能完成却能过滤掉绝大部分伪造请求。2.4 穿透方案怎么选我经常被问到这几个方案到底该选哪个我的建议是按系统和流量分级。如果系统规模不大QPS 在几百到几千空值缓存完全够用。它实现简单不用引入新组件维护成本为零。只要注意空值过期时间不要设置太长。如果系统有被恶意攻击的风险或者存在大量不确定的非法 ID布隆过滤器就要安排上了。它在拦截效果上远好于空值缓存因为空值缓存只是“被动防御”布隆过滤器是在请求进入缓存之前就把非法 key 拦掉了。但代价是要维护过滤器和业务数据的同步需要一定的开发和运维投入。至于参数校验这不是一个可选项而是必选项。不管你有没有上布隆过滤器参数校验都应该做。它承担了一部分本来不该到达缓存层的流量减轻了后续各层的压力。还有一个细节穿透其实是三个问题里最容易埋坑的。因为它的攻击源是“不存在的 key”很多时候不会在业务日志里留下明显痕迹。你可能看到数据库慢查询多了但不知道是有人在搞穿透。建议在拦截或者返回空结果的地方加一个专门的日志输出记录请求参数和来源 IP出了事情至少有地方查。3. 缓存击穿的互斥锁与逻辑过期方案击穿和穿透在故障现象上有点像——都是缓存没命中然后查数据库。但击穿的核心是“热点 key 过期的瞬间并发请求击穿缓存”。所以方案设计的出发点是要保证在缓存重建的窗口期内只有一份请求去查数据库其他请求要么等着要么先拿到一个不那么新鲜的旧值。3.1 互斥锁让重建缓存的代价只出现一次互斥锁的思路很直接当一个请求发现缓存不存在的时候先去获取一把锁。谁拿到了锁谁就去重建缓存拿不到锁的请求说明已经有别的线程在重建了就等一下再去缓存里取。在只有一台应用服务器的情况下用 Java 自带的 synchronized 或 JUC 包里的 Lock 就足够了public Product getProductById(Long id) { // 先查缓存 Product product (Product) redis.get(product: id); if (product ! null) { return product; } // 缓存不存在尝试获取锁 synchronized (this) { // 二次检查缓存防止重复重建 product (Product) redis.get(product: id); if (product ! null) { return product; } // 查数据库 product productMapper.selectById(id); redis.set(product: id, product, 30, TimeUnit.MINUTES); return product; } }注意这段代码里的“二次检查缓存”这是我叫它双检锁的原因。如果不做二次检查两个线程同时进入 synchronized 块一个查了数据库并写缓存另一个完全不知道又查了一遍数据库锁就白加了。不过单机的锁解决不了多机部署的问题。如果系统有 3 个实例同一个商品的请求分布在 3 台机器上每台机器都有一把独立的锁那 3 个请求还是可能同时去查数据库。所以分布式场景下需要换成分布式锁。用 Redis 实现分布式锁最稳妥的方式是基于 Redisson 客户端它封装了锁的获取、释放、过期续期不容易踩“锁忘记释放”或者“持锁时间太长被自动过期”的坑public Product getProductById(Long id) { Product product (Product) redis.get(product: id); if (product ! null) { return product; } // 获取分布式锁key 用商品 ID 区分 RLock lock redisson.getLock(lock:product: id); lock.lock(); try { // 二次检查缓存 product (Product) redis.get(product: id); if (product ! null) { return product; } // 模拟重建缓存的时间开销 product productMapper.selectById(id); redis.set(product: id, product, 30, TimeUnit.MINUTES); return product; } finally { lock.unlock(); } }互斥锁方案有几个需要注意的点。第一个是锁的粒度要控制好。锁粒度太粗比如所有商品共用一个锁那么一个商品的重建会阻塞其他所有商品的查询锁粒度太细比如给每个参数都加锁那锁的数量可能失控。对商品场景锁的 key 用“商品 ID”这种业务维度就刚好。第二个是持锁期间要做超时兜底。有一次我做压测数据库查询因为网络抖动慢了几秒持锁时间超过了 Redis 锁的自动过期时间锁被释放了接着第二个线程也拿到了锁又查了一次数据库。虽然问题不严重但说明锁的方案需要配合完整的时间预算。如果预估重建时间不会超过 100ms锁的过期时间设置 1 秒就比较合理既不会很快过期导致锁失效也不会因为持锁太久阻塞太长时间。3.2 逻辑过期用空间换时间互斥锁虽然好用但它有一个天然的牺牲缓存失效后到重建完成前请求是阻塞等待的。如果热点 key 的重建耗时是 200ms那么这 200ms 内所有请求都会排队等锁。虽然数据库不会再被打爆但用户感受到的延时反而明显增加了。为了解决这个问题业界大量使用“逻辑过期”方案。它的思路很巧妙给每个缓存数据额外存一个过期时间这个时间叫逻辑过期时间物理上缓存不删除只是逻辑上认为过期。读取时发现逻辑过期了先返回旧值给用户同时在后台异步重建缓存。// 缓存数据结构包含业务数据和逻辑过期时间 public class CacheDataT { private T data; // 业务数据 private long expireTime; // 逻辑过期时间戳 } // 写入缓存时设置逻辑过期时间为当前时间 30分钟 CacheDataProduct cacheData new CacheData(); cacheData.setData(product); cacheData.setExpireTime(System.currentTimeMillis() 30 * 60 * 1000); redis.set(product: id, JSON.toJSONString(cacheData));读取时判断逻辑时间public Product getProductById(Long id) { String json redis.get(product: id); if (json null) { // 缓存不存在说明可能还没初始化或者主动删除了 // 走互斥锁重建或者直接查数据库 return loadProductFromDb(id); } CacheDataProduct cacheData JSON.parseObject(json, CacheData.class); // 未过期直接返回 if (cacheData.getExpireTime() System.currentTimeMillis()) { return cacheData.getData(); } // 逻辑过期了返回旧值同时异步刷新缓存 freshCacheAsync(id); return cacheData.getData(); }逻辑过期方案的核心好处就是读请求永远不阻塞。哪怕缓存已经过期了用户拿到的还是一个旧数据但这个代价换来了极低的延时和极高的可用性。你可能会问数据旧了能接受吗这取决于业务。秒杀场景里库存数据差个几秒钟对用户很难感知但查询失败或者超时是绝对不能接受的。这类场景用逻辑过期就很合适。实现的时候有一个细节值得注意异步刷新一定要做幂等控制。否则多个线程同时发现逻辑过期同时去查数据库又相当于击穿了。我习惯的做法是配合一个“重建锁”可以用 Redis 的 SET NX 命令实现只有真正拿到锁的那个线程才去重建缓存private void freshCacheAsync(Long id) { // 尝试获取重建锁超时时间设短一点比如 3 秒 Boolean locked redis.setIfAbsent(lock:product:reload: id, 1, 3, TimeUnit.SECONDS); if (locked ! null locked) { // 异步线程池里执行重建 executor.execute(() - { try { Product product productMapper.selectById(id); CacheDataProduct newCacheData new CacheData(); newCacheData.setData(product); newCacheData.setExpireTime(System.currentTimeMillis() 30 * 60 * 1000); redis.set(product: id, JSON.toJSONString(newCacheData)); } finally { redis.delete(lock:product:reload: id); } }); } }这里有一个隐藏的坑异步重建过程中如果用户请求持续到来拿到的都是旧数据。如果恰好是秒杀场景库存数据本来就紧张返回旧数据可能会导致多卖出几件或者显示不准确。这类场景我建议对“一致性要求高”的字段单独判断宁可互斥锁等待几毫秒也不能让用户看到明显错误的数据。3.3 击穿场景的关键判断击穿问题最大的难点不在于方案本身而在于什么时候需要上这些方案。我给一个相对实用的判断标准。第一看 key 的并发访问量。如果某个 key 的 QPS 只有个位数缓存过期了查一次数据库也就几十毫秒的事完全没有必要加锁。多数普通业务数据的 QPS 也就几十到几百锁的复杂度反而高于它解决的问题。第二看数据重建的成本。如果重建缓存需要查多个表、计算几个维度耗时在几百毫秒及以上就需要保护了。如果只是查一条主键记录耗时 1ms 不到数据库完全扛得住就不用折腾。第三看业务对旧数据的容忍度。秒杀库存、支付状态这类强一致数据倾向于互斥锁商品描述、文章详情这类弱一致数据逻辑过期更合适。注意击穿和穿透经常同时出现。一个热点 key 失效时突然被大量请求打入如果其中还掺杂着很多不存在的 key数据库压力会叠加。排查的时候不能只看单一问题。4. 缓存雪崩的系统级防御雪崩虽然是“面”上的问题但它的成因和解决方案都相对清晰。我按从简单到复杂的顺序说几种我实际用过且有效的方案。4.1 过期时间加随机因子性价比最高的防雪崩手段大量 key 同时失效的常见原因就是过期时间被设置成了同一个固定值。要破解这个最直接的办法就是在设置过期时间时加一个随机扰动。// 基础过期时间是 30 分钟再随机加 0~300 秒 int baseExpire 30 * 60; int randomExpire baseExpire new Random().nextInt(300); redis.setex(product: id, randomExpire, productJson);这段代码的意思很简单每个 key 的过期时间不再是统一的 30 分钟而是在 30 分钟到 35 分钟之间随机浮动。这样一来原本会在同一个时间点集体过期的 key就被拆散到了几分钟的窗口期内数据库接收到的请求量就会平缓很多。这个方案唯一的缺点是需要改动所有写入缓存的代码。很多老系统在多个地方都直接调用 redis.setex()改起来要仔细找全。我曾经接手过一个系统商品模块和营销模块各写了一套缓存逻辑都用了固定过期时间改了一个没改另一个结果活动期间雪崩又来了。建议改完之后全面搜一遍 setex 相关的写法确认没有遗漏。4.2 多级缓存让本地缓存成为第二道堤坝如果随机过期时间还是扛不住大流量那就需要引入多级缓存了。多级缓存的思路是除了 Redis 这个分布式缓存之外在应用进程内再加一层本地缓存。当 Redis 大面积失效时本地缓存还能挡住一部分请求。一个常用的组合是 Caffeine 作为本地缓存 Redis 作为分布式缓存// 本地缓存最大容量 1000写入后 1 分钟过期 CacheLong, Product localCache Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(1, TimeUnit.MINUTES) .build(); public Product getProductById(Long id) { // 1. 查本地缓存 Product product localCache.getIfPresent(id); if (product ! null) { return product; } // 2. 查 Redis String json redis.get(product: id); if (json ! null) { product JSON.parseObject(json, Product.class); localCache.put(id, product); return product; } // 3. 查数据库 product productMapper.selectById(id); if (product ! null) { // 先写 Redis再写本地缓存 redis.setex(product: id, 30 * 60, JSON.toJSONString(product)); localCache.put(id, product); } return product; }这里有个很重要的点本地缓存的过期时间必须比 Redis 短。否则会出现本地缓存数据已经过时而 Redis 里已经是新数据的情况。我习惯把本地缓存过期时间设为 Redis 的五分之一到三分之一这样数据更新的延迟不会太大。还有一点容易被忽略的是内存管理。本地缓存是存储在 JVM 堆内存里的Caffeine 这类库会根据配置的最大容量自动淘汰但如果你把它当成无限容量的 HashMap 来用内存迟早被撑爆。设置 maximumSize 时建议按单个缓存对象的空间占用乘以目标容量再结合堆内存估算比如单个对象大约 1KB1000 个就是 1MB完全可控。4.3 限流降级与熔断缓存雪崩最严重的后果是数据库被大量请求打垮。即便前置方案都做了也难保证极端情况下数据库不被打穿。所以在数据库入口前必须再加一道限流的闸门。常用的做法是在数据库访问层或者服务网关层做限流。限流的具体算法有令牌桶、漏桶等一般用成熟中间件即可实现。比如用 Redis 做分布式限流给某个关键的数据库查询接口设置一个阈值超过阈值的请求直接返回降级提示不再尝试查询数据库。// 伪代码基于 Redis 的简单计数器限流 public boolean tryAcquire(String key, int maxCount, int windowSeconds) { Long count redis.incr(key); if (count 1) { redis.expire(key, windowSeconds); } return count maxCount; }限流阈值要怎么设我给个经验值数据库当前稳定承受的 QPS 乘以 0.6 到 0.7。如果你不确定数据库能扛多少可以先做一轮压测找到数据库 CPU 或者连接数开始明显升高的点然后按 60% 留出余量。限流值设得太高起不到保护作用设得太低又会误伤正常请求。降级则是在缓存和数据库都不可用时的最后兜底。比如商品详情接口可以返回一个缓存在本地文件里的默认推荐商品列表搜索接口可以暂时关闭筛选功能只返回基础结果。这种降级策略虽然不是最优体验但可以保证接口不直接报错用户还能正常浏览大部分功能。5. 线上问题排查与监控告警实录很多情况是你根本不知道缓存已经出问题了直到数据库连接池爆了、接口大量超时才发现原来又经历了一次经典的缓存故障。所以要学会通过监控指标判断当前发生的是哪一类问题效率会高很多。5.1 怎么判断当前正在发生哪一类我先放一个判断流程各位可以拿它当参考。第一步看 Redis 的命中率。如果 Redis 命中率突然大幅下降说明大量请求走到数据库去了。这时候要看请求的 key 是不是同一条数据。如果是同一个 key就是击穿如果是大量不同的 key 同时失效就是雪崩如果 key 经常是不存在的、非法的值就是穿透。第二步看数据库的慢查询日志。穿透和击穿带来的慢查询往往是同一类 SQL但穿透的查询条件通常是“不存在的数据”击穿则是“存在但被重复查”。通过看查询结果记录是否存在可以快速区分这两者。第三步看 Redis 服务状态。如果 Redis 本身 CPU 高、内存不足或者存在主从切换记录雪崩的概率就很大。这种时候即使你做了随机过期时间也顶不住 Redis 服务不可用造成的大量穿透。为了方便理解我整理了一个表格现象可能问题进一步观察点命中率骤降查询 key 集中在某几个热点缓存击穿看这些 key 是否刚好属于同一业务场景命中率骤降大量 key 同时找不到缓存雪崩检查是否统一设置了固定过期时间命中率骤降大量非法 key 被查询缓存穿透检查请求参数是否被批量伪造Redis 服务异常缓存雪崩服务端看实例资源、慢日志、主从切换记录有一个细节我这些年翻过几次车不能只看命中率这一个指标。如果 Redis 集群中共有 100 万个 key其中 99 万个是低频冷数据命中率本来就不高。这时候即使出现了热点 key 击穿命中率波动也可能不明显。所以还需要配合“热点 key 访问量”和“数据库 QPS”这两个指标一起分析。5.2 监控指标与阈值我自己在线上重点盯这么几个指标。第一个是Redis 命中率。正常情况下应该在 90% 以上如果低于 80%就要开始排查了。但注意不同业务的命中率差异很大有些业务的缓存命中率天然就低比如大量随机查询。所以这个阈值要结合自身业务调整。第二个是数据库 QPS 和连接数。数据库连接数是更敏感的指标因为 QPS 可能会有日常波动但连接数一旦接近最大值意味着系统已经在崩溃边缘了。建议设置告警阈值在连接池最大值的 70%。第三个是缓存 key 过期淘汰数。Redis 的 INFO 命令里可以看 expired_keys如果这个数字在某个时间点突然飙升说明大量 key 集中过期了极有可能引发雪崩。这个指标我建议加到每日巡检清单里毕竟不是每个团队都能做到实时监控。监控方案不是本文重点但我给一个低成本起步的组合用 Spring Boot Actuator 暴露 Redis 和数据库连接池指标配合 Prometheus 和 Grafana 展示趋势。这套组合对于一个中小团队来说完全够用而且都是开源方案不需要额外成本。5.3 问题速查表我把三类问题的经典现象、解决方案和排查要点汇总成一个速查表方便你在故障现场直接对照。问题核心特征首选方案升级方案排查要点缓存穿透查询不存在的 key空值缓存布隆过滤器、参数校验看请求 key 是否存在业务数据中缓存击穿单个热点 key 瞬间失效互斥锁逻辑过期看数据库慢查询是否集中在同一 key缓存雪崩大量 key 同时失效过期时间加随机值多级缓存、限流降级看缓存过期时间设置、Redis 服务状态这张表的值在于它把“选型”和“定位”的第一步给定住了。你在处理具体问题时先按表格判断类型再选择对应的解决方案能省掉不少临场思考的时间。6. 几点实在的经验写到最后分享一些我个人的体会。这些东西不算什么高深理论但都是时间里熬出来的。第一缓存方案一定要在系统设计阶段就考虑进去而不是等出了问题再来补。我见过很多项目前期为了赶进度直接查询数据库上线后发现扛不住再加班做缓存改造。这时候缓存层加上去了但业务代码里到处都是边边角角的逻辑排查一个缓存和数据库不一致的问题往往比当初多做 20 个接口还要痛苦。缓存穿透、击穿、雪崩这三个问题不是“要不要防”的问题而是“什么时候就需要防”的问题。第二没有一劳永逸的缓存方案。穿透可以靠布隆过滤器从源头拦截但布隆过滤器的数据同步、误判率需要持续维护击穿可以用互斥锁但锁的粒度、持锁时间需要结合实际调整雪崩可以加随机过期时间和多级缓存但多级缓存的一致性又是一个新课题。方案之间没有绝对的优劣只有适不适合当前业务。第三压测是验证缓存方案是否有效的唯一标准。我刚接触缓存处理那些年总觉得自己设计的方案没问题上线跑一跑就知分晓。结果有一次在秒杀场景下我用的逻辑过期方案在低并发下表现很好但压测到 2000 QPS 时异步重建线程池被任务打满了线程池队列积压严重最终导致缓存刷新延迟高达十几秒用户看到的数据一直不更新。后来我把线程池改成带有丢弃策略的线程池并且限制了单 key 重试频率问题才真正解决。这个教训告诉我方案的好坏要看在压力下的表现不是看代码里写得是否优雅。第四给缓存代码留好观测点和日志。至少要保证每个关键路径缓存命中、缓存未命中、缓存重建、返回空值都能输出一条日志。否则线上出了问题你根本不知道请求在哪个环节发生了穿透。经验是宁可多打几条日志也不要吝啬那一点写入开销尤其是避免了故障后这几条日志反而会成为排查故障的最大助力。最后说一句我一直信奉的话缓存是性能利器也是故障温床。你用好了它系统如虎添翼用不好它数据库就是那个最先崩溃的软肋。这篇文章里讲的三个问题说到底都是在提醒一件事任何一层技术组件都要把它当成一个需要持续维护的系统来对待而不是写几行代码就丢到一边。希望我的这些踩坑经历能帮你少走几条弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑