做后端这几年,缓存相关的坑一个都没少踩。尤其是缓存穿透、缓存击穿、缓存雪崩这三个问题,几乎每个跟高并发、热点数据打过交道的系统都会遇到。很多新手把这些概念背得滚瓜烂熟,面试也能说出一二三,但真到了线上,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% BloomFilter<Long> bloomFilter = BloomFilter.create( Funnels.longFunnel(), 100_000, 0.0001); // 系统启动时,把商品 ID 批量加载进过滤器 List<Long> 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 CacheData<T> { private T data; // 业务数据 private long expireTime; // 逻辑过期时间戳 } // 写入缓存时,设置逻辑过期时间为当前时间 + 30分钟 CacheData<Product> 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); } CacheData<Product> 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); CacheData<Product> 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 分钟过期 Cache<Long, 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 时,建议按单个缓存对象的空间占用乘以目标容量,再结合堆内存估算,比如单个对象大约 1KB,1000 个就是 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 重试频率,问题才真正解决。这个教训告诉我:方案的好坏要看在压力下的表现,不是看代码里写得是否优雅。
第四,给缓存代码留好观测点和日志。至少要保证每个关键路径(缓存命中、缓存未命中、缓存重建、返回空值)都能输出一条日志。否则线上出了问题,你根本不知道请求在哪个环节发生了穿透。经验是宁可多打几条日志,也不要吝啬那一点写入开销,尤其是避免了故障后,这几条日志反而会成为排查故障的最大助力。
最后说一句我一直信奉的话:缓存是性能利器,也是故障温床。你用好了它,系统如虎添翼;用不好它,数据库就是那个最先崩溃的软肋。这篇文章里讲的三个问题,说到底都是在提醒一件事:任何一层技术组件,都要把它当成一个需要持续维护的系统来对待,而不是写几行代码就丢到一边。希望我的这些踩坑经历,能帮你少走几条弯路。