线上业务半夜报警,数据库连接数打满,慢查询堆积成山,缓存Redis里明明还有数据,上游接口却一片超时。翻了一圈监控发现,罪魁祸首不是代码bug,也不是流量突增,而是一个本该在缓存层就被拦下来的无效请求,直接穿透到了数据库。这种事故我在不同团队遇到过至少三次,每一次的根因都绕不开三个词:缓存穿透、缓存击穿、缓存雪崩。这三个问题听起来像兄弟,实际上成因、表现、杀伤力完全不同,对应的防御方案也各有讲究。
这篇内容我打算把这三个“高并发缓存杀手”一次性讲透,从原理层面拆解它们为什么会让数据库崩溃,再到布隆过滤器、互斥锁、多级缓存这些主流防御手段的落地细节。文章会贴大量实操代码、参数配置和我在生产环境里实测过的数据,适合正在维护高并发服务的后端开发、架构师,以及准备系统学习缓存知识的技术人。看完之后你至少能搞清楚一件事:当缓存失效的那一瞬间,你的系统到底在经历什么,以及怎么把它扛住。
1. 三种缓存失效场景,别再把它们混为一谈
很多文章把穿透、击穿、雪崩并列来讲,但实际工作中我发现,很多人对这三个概念停留在“都会导致数据库压力暴增”的模糊印象。其实它们的触发条件、影响范围、修复手段差别非常大,搞混了会直接导致方案选型错误。我先用最直白的方式把它们区分开。
1.1 缓存穿透:查询了一个根本不存在的数据
穿透的本质是:请求的key在缓存中不存在,在数据库中也不存在,于是每次请求都直接打到数据库。攻击者如果构造一批随机不存在的ID,比如负数ID、超长字符串、随机UUID,就能让数据库承受大量无意义的查询。
举个例子。一个电商订单查询接口,订单号是19位数字。正常情况下用户查的都是真实订单,第一次查询没命中缓存,回源数据库,然后回填缓存。但如果有恶意请求连续传入12938192831928391283这种并不存在的订单号,缓存永远不可能命中,数据库每次都要去做一次全表扫描级别的查询,连接池很快就会被耗尽。
最阴险的地方在于:这种流量很难通过常规的限流手段识别,因为它们的参数看起来完全合法,只是在业务语义上不存在。而且穿透请求往往带有随机性,缓存中不可能被提前写入这些key,所以缓存层约等于透明。
1.2 缓存击穿:热点Key在过期瞬间被高并发打穿
击穿针对的是某一个热点Key。比如一个秒杀商品的详情页,平时QPS可能只有几百,活动开始后瞬间飙升到几万。这个商品信息的缓存设置了30分钟过期,缓存服务在过期时间到达的那一刻,如果正好有一大批请求同时来查这个Key,从Redis中取不到数据,它们会一起回源数据库。
这里的关键词是“同时”。如果只有一两个请求回源,数据库毫无压力,但如果同一时刻有几千个请求同时回源,数据库瞬间就被打垮。击穿和穿透的最大区别是:击穿的数据在数据库中是真实存在的,只是缓存恰好过期;穿透的数据在数据库中压根不存在,永远无法回填缓存。
对业务的影响也完全不同:击穿恢复后缓存会被重新回填,系统能自动恢复;穿透则会持续打数据库,直到流量停止或采取了额外防护。
1.3 缓存雪崩:大量Key同时过期导致缓存层整体失效
雪崩的覆盖面比击穿大得多。如果一批缓存Key设置的过期时间相同,比如都是整点过期、都是30分钟过期,到了某个时间点,这大批Key会一起失效。此时请求全部回源数据库,数据库压力瞬间达到峰值,很容易触发连接池耗尽、慢查询堆积、主从延迟等一系列连锁反应。
我见过一个典型事故:运营人员把一批活动页面的缓存过期时间统一设置为600秒,结果每天上午10点整,这批页面同时刷新,数据库在10点整那一分钟内的QPS比平时翻了20倍,直接宕机。更麻烦的是,Redis中大量Key失效后,如果缓存服务本身也扛不住回源请求(比如Redis线程被阻塞),故障会从数据库向上蔓延,形成级联效应。
还有一种雪崩场景不是Key过期导致的,而是Redis实例宕机。Redis挂了之后,所有请求直接绕过缓存打到数据库,这种物理级别的雪崩往往比Key过期更致命,因为缓存层面已经完全没有保护能力。
1.4 三兄弟对比:一张表看懂差异
| 维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 触发场景 | 查询不存在的Key | 单个热点Key过期 | 大量Key同时过期 / Redis宕机 |
| 数据真实性 | 数据库不存在 | 数据库存在 | 数据库存在 |
| 影响范围 | 单个无效请求,持续不断 | 单个Key,高并发瞬间 | 多个Key,全局性压力 |
| 恢复方式 | 无需回填,需要拦截 | 回填缓存自动恢复 | 回填大量缓存,耗时较长 |
| 核心防御 | 布隆过滤器、空值缓存 | 互斥锁、逻辑过期 | 过期时间随机、多级缓存、高可用 |
分清这三种场景之后,下面我逐个讲对应的防御方案,每一步都会给出可以直接抄作业的实现。
2. 布隆过滤器:拦截穿透请求的第一道闸门
布隆过滤器的核心价值在于:用极低的内存成本,快速判断一个Key“一定不存在”。它允许误判存在,但不允许误判不存在。也就是说,如果布隆过滤器说“这个Key不存在”,那它一定不存在,可以安心拦截;如果它说“可能存在”,则需要放行到后续逻辑去验证。
2.1 布隆过滤器的原理和数学直觉
布隆过滤器的底层是一个位数组(bit数组)+ 一组哈希函数。插入一个Key时,用k个哈希函数分别计算得到k个位置,把这些位置上的bit全部置为1;查询一个Key时,同样计算k个位置,如果所有位置都是1,则认为“可能存在”,只要有一个位置是0,就认为“一定不存在”。
为什么说“可能存在”?因为哈希碰撞。不同Key可能映射到相同的位置,导致某个Key虽然没有插入过,但它的所有哈希位置都已经被其他Key置为1,这就产生了误判。误判率可以估算,经典公式是:
误判率 p ≈ (1 - e^(-kn/m))^k其中n是预计插入的元素数量,m是位数组长度,k是哈希函数个数。实际操作中,可以先预估n,然后根据可接受的误判率p反推m和k。常用的简化公式是:
m = -n * ln(p) / (ln2)^2 k = m/n * ln2举个例子。假设预计插入100万个订单号,希望误判率控制在1%,代入公式:m约等于958万位,换算成字节约1.2MB;k约等于7。也就是说,用1.2MB内存和一亿分之一的误判概率,就能挡住几乎所有不存在的Key查询。这个性价比非常高。
2.2 布隆过滤器在生产环境中的三种落地姿势
第一,Guava的BloomFilter。适合单机应用,数据在本地内存中,加载后只能单机判断。对于部署在多个实例上的服务,每个实例都需要各自构建一份,适合Key集合相对固定、实例数不多、数据量在百万级的场景。
BloomFilter<String> filter = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 1_000_000, // 预计元素数 0.01); // 期望误判率 filter.put("order_123456"); boolean mightContain = filter.mightContain("order_987654");第二,Redis的布隆过滤器插件。Redis 4.0之后可以通过模块方式安装bf命令,常见的是RedisBloom模块。这种方式的好处是过滤器数据集中在Redis中,所有应用实例共享同一个判断结果,不必每个实例各自构建。缺点是Redis需要额外加载模块,有一定的运维成本。
BF.RESERVE order_filter 0.01 1000000 BF.ADD order_filter order_123456 BF.EXISTS order_filter order_987654第三,基于Redis字符串位图手动实现。用SETBIT和GETBIT命令来模拟位数组操作,代码可控性强,不依赖额外模块,适合已有Redis集群不想引入新组件的情况。不过哈希函数需要自己在代码里实现,处理起来稍微繁琐一些。
2.3 布隆过滤器误判的后果和处理方法
误判意味着某些不存在的Key会被放行到数据库。虽然误判率可以控制在1%甚至更低,但1%的无效回源请求在千万级QPS下依然很可观。所以要组合使用:布隆过滤器做第一层拦截,拦截掉99%的穿透流量;剩下1%的漏网之鱼,再用空值缓存方案兜底——把查询结果为null的Key也缓存起来,设置一个较短的过期时间(比如60秒)。这样即使布隆过滤器误判放行,第二次相同请求也会被空值缓存挡住。
我在实际项目中推荐的组合逻辑是:
请求进来 -> 布隆过滤器判断不存在 -> 直接返回空结果 请求进来 -> 布隆过滤器判断可能存在 -> 查Redis缓存 -> 命中直接返回 请求进来 -> 布隆过滤器判断可能存在 -> 查Redis缓存未命中 -> 查数据库 -> 回填缓存(含空值缓存)注意:布隆过滤器无法删除元素。如果想删除旧数据,只能用“定期重建过滤器”的方式,或者选择支持删除的变体(比如布谷鸟过滤器),但这个变体的实现复杂度会明显上升。
3. 互斥锁与逻辑过期:击穿防御的两条实战路线
击穿的核心矛盾是:热点Key过期后,一瞬间有大量并发请求同时回源。要解决这个问题,思路很简单——让这些请求不要同时回源,而是只放一个请求去数据库重建缓存,其余请求要么等,要么拿旧值。
3.1 互斥锁方案的实现细节
互斥锁的思路是:当缓存未命中时,不是立刻查数据库,而是先尝试获取一把分布式锁。拿到锁的请求去查数据库并回填缓存,其他请求在锁上等待,然后重新读取缓存。这里的关键问题是:用哪种分布式锁?等待多久?锁何时释放?
我推荐直接用Redis的SET NX PX命令实现,避免引入额外的ZooKeeper或Etcd依赖,毕竟Redis已经在链路里了。
// 尝试获取锁,设置5秒超时 String token = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent("lock:hot_key", token, Duration.ofSeconds(5)); if (Boolean.TRUE.equals(locked)) { try { // 二次检查缓存,防止在等锁期间缓存已经被其他线程重建 Object cached = redisTemplate.opsForValue().get(key); if (cached != null) { return cached; } // 查数据库 Object data = queryFromDatabase(key); redisTemplate.opsForValue().set(key, data, Duration.ofSeconds(600)); return data; } finally { // 释放锁时需要校验持有者身份,防止误删其他线程的锁 String lockValue = redisTemplate.opsForValue().get("lock:hot_key"); if (token.equals(lockValue)) { redisTemplate.delete("lock:hot_key"); } } } else { // 没拿到锁,休眠一小段时间后重试 Thread.sleep(50); return getFromCacheOrDb(key); // 递归重试,注意控制重试次数 }这段代码有几个细节值得展开。
关于锁的持有者校验,释放锁时如果直接delete,可能把其他线程刚获取的新锁误删。比如线程A的锁5秒超时自动释放,线程B获取了新锁,此时线程A的finally块执行delete,就会把B的锁删掉,导致B的互斥失效。所以释放前必须比对value值,我这里用UUID做标识,只有持有者自己才能删除。
关于等待和重试,没拿到锁就短睡眠然后递归重试,但这个递归需要加次数限制。我见过一个线上问题:热点Key的数据库查询特别慢,超过锁的5秒超时,第一个线程的锁已经自动失效了,第二个线程拿到锁又开始查库,结果多个线程实际上都在查库,互斥完全失效。所以锁超时时间必须大于“建缓存”的耗时,通常建议压测后确定,一般取数据库查询耗时的3-5倍。
关于JVM内锁的补充,如果所有请求都落在同一个Java进程内,用JDK内置的synchronized或ReentrantLock做本地锁就够了,性能远高于分布式锁。只有当服务是集群部署时,才需要跨进程的分布式锁。我常用的优化方案是“本地锁+分布式锁”两级结合:先获取本地锁,同一个实例内的并发先被本地锁挡掉,只有不同实例之间才需要竞争分布式锁。这种方式能大幅减少Redis锁请求的压力。
3.2 逻辑过期方案:用旧数据扛过真空期
互斥锁的痛点是:如果热点Key过期瞬间有大量请求在等待,响应时间会变长,重试也会占用线程资源。逻辑过期方案则完全不同——它不存在“缓存过期”的概念。
具体做法是:缓存数据中额外存储一个逻辑过期时间字段,但Redis中的Key本身不过期(或者设置一个很长的物理过期时间)。请求读取到数据后,先判断逻辑过期时间是否已到。如果没过期,直接返回;如果已过期,则尝试获取互斥锁,获取成功后开启一个异步线程去数据库更新缓存,当前请求则直接返回“过期的旧数据”。
public Object getProductInfo(String key) { // 从缓存读取,Redis中的Key永不过期 String json = redisTemplate.opsForValue().get(key); if (json == null) { return null; // 理论上这种情况非常少,Key被手动清理了 } CacheEntry entry = JSON.parseObject(json, CacheEntry.class); if (entry.getExpireTime() > System.currentTimeMillis()) { return entry.getData(); // 逻辑时间未过期,直接返回 } // 逻辑过期:尝试获取锁,异步刷新 if (tryGetLock(key)) { try { // 异步更新缓存 executorService.submit(() -> { Object data = queryFromDatabase(key); String newJson = JSON.toJSONString(new CacheEntry(data, System.currentTimeMillis() + 600_000)); redisTemplate.opsForValue().set(key, newJson); }); } finally { releaseLock(key); } } // 未拿到锁的请求直接返回旧数据 return entry.getData(); }这个方案的核心优势是:请求永远都不会因为缓存过期而等待,响应时间稳定。核心缺点是:在缓存异步更新的窗口期内,用户看到的是旧数据。对于实时性要求不高的场景(比如商品详情、文章内容、配置信息),这个缺点完全可以接受。但如果数据是余额、库存这种强一致要求的数据,就不能用逻辑过期。
这里有一个生产环境常见的坑:异步线程池如果处理不过来,缓存永远不更新,旧数据会一直返回。所以要给这个线程池加上合理的队列大小、拒绝策略和监控报警。我一般用独立线程池,核心线程数和最大线程数根据数据库能承受的并发来定,同时监控队列深度,超过阈值就报警。
4. 雪崩防御:多级缓存与过期时间的艺术
雪崩的杀伤力在于覆盖面大,动辄几十上百个Key同时失效。防御思路也分两条:一是让失效时间分散,二是让数据库上面不止缓存一道防线。
4.1 过期时间的随机化与业务错峰
最基础的方案是把固定的过期时间改成“基础过期时间+随机偏移量”。比如原来统一3600秒,现在改成“3600 + 随机数(60~600)秒”。这个策略在代码里改动最小,却能显著降低同时失效的概率。
基础时间 + 随机偏移 = 实际过期时间 3600 + random(60, 600) = 3660~4200秒要注意的一点是:不要所有Key都用一个随机算法生成完全随机的过期时间,那样会导致缓存命中率下降。更好的做法是“按业务分组打散”。比如活动页面的Key集中在300~600秒随机,商品详情的Key集中在3600~7200秒随机,这样同类Key的过期时间大致在一个接近的区间,但具体时间点错开,既保持了缓存效果,又避免了集中失效。
另一个工程上的细节:很多团队用同一个RedisTemplate的set(key, value, time)方法设置缓存,如果key的命名规则里包含业务前缀,可以通过前缀来配置不同的随机范围。我习惯在缓存工具类中维护一个过期时间策略表,用Map维护前缀和随机范围,新增缓存场景时只需要加一行配置,而不需要到处改业务代码。
4.2 多级缓存:把压力分散到更靠近用户的地方
当Redis整实例宕机时,随机化过期时间完全失效。这时真正能扛住的是多级缓存架构。常见的分级是:本地缓存(Caffeine) -> Redis缓存 -> 数据库。
本地缓存放在应用进程内,读写纳秒级,不依赖网络。即使Redis挂了,只要本地缓存还有数据,请求就不会打到数据库。这里最常用的组件是Caffeine,它支持基于时间的过期策略和基于大小的淘汰策略。
Cache<String, Object> localCache = Caffeine.newBuilder() .maximumSize(10_000) // 最多缓存1万个Key .expireAfterWrite(Duration.ofSeconds(30)) // 写入30秒后过期 .build();读流程变成:
读取本地缓存 -> 未命中则读取Redis -> 未命中则查数据库 -> 回填两级缓存这里强调一个关键点:本地缓存的过期时间必须比Redis短。否则本地缓存长期持有旧数据,Redis中的数据更新后本地缓存感知不到,就会出现数据不一致。常见的设置为:本地缓存30秒,Redis缓存10-15分钟。这样Redis更新后,最多30秒内本地缓存会过期,重新从Redis拉取新数据。
多级缓存最大的槽点是数据一致性。如果后台更新了数据库,需要主动失效Redis中的旧缓存,但本地缓存分布在每个应用实例中,无法统一失效。我的实践经验是:对一致性要求不高的读取场景,接受本地缓存30秒的短时不一致;对一致性要求较高的场景,不要加本地缓存,直接走Redis。
4.3 Redis高可用的兜底
还有一个层面需要重视:Redis本身的可用性。Redis主从切换、哨兵、集群模式都是基础设施层面的雪崩防御。但要注意的是,即便Redis是高可用的,主从切换的那几秒内,缓存写入依然可能失败。所以在业务代码中,写缓存必须捕获异常并降级,不能因为Redis报错而影响主流程。
try { redisTemplate.opsForValue().set(key, value, timeout); } catch (Exception e) { // Redis异常时降级,本次不写缓存,下次请求继续尝试 log.warn("write redis failed", e); }同理,读缓存也要做降级。如果Redis读超时,不要抛异常让请求失败,而是直接返回null并进入数据库查询流程。虽然这会加重数据库压力,但至少业务是可用的,配合熔断机制可以控制住影响范围。
5. 一套完整的防御体系:高并发读接口的落地框架
前面分别讲了穿透、击穿、雪崩的防御手段,但真实的生产环境要的是把这些手段组合成一个统一的读接口框架,而不是在每一个业务代码里东拼西凑。我构建过一套比较通用的“四级防御”读接口,在这里给出完整的设计。
5.1 四级防御链路拆解
整个读请求的流程可以分为四层:
第一层:本地缓存。Caffeine承载,过期时间30秒。这一层的目的是扛住极端QPS下对Redis的压力。如果本地缓存命中,整体响应时间在毫秒级。
第二层:布隆过滤器。判断Key是否可能存在。如果不存在,直接返回空结果或缓存中的空标记,杜绝穿透流量进入数据库。布隆过滤器可以放在Redis(RedisBloom模块)中,也可以放在本地(Guava),根据数据量权衡。
第三层:Redis缓存。承载主要的缓存命中流量,过期时间根据业务配置,采用基础时间+随机偏移。这一层是系统的主力缓存层。
第四层:数据库。只有前面三层全部未命中才会到达这一层。到达这一层时,还要用互斥锁控制并发回源的数量,保证同时只有少数请求在查库。
5.2 统一的读接口模板代码
下面是我常用的一个泛型化模板的骨架,业务方只需要传入Key生成函数和数据库查询函数:
public <T> T queryWithDefense(String key, Function<String, T> dbQuery, Class<T> clazz) { // 本地缓存1:Caffeine Object local = localCache.getIfPresent(key); if (local != null) { return (T) local; } // 防线2:布隆过滤器,不存在直接返回 if (!bloomFilter.mightContain(key)) { return null; } // 防线3:Redis缓存 String json = redisTemplate.opsForValue().get(key); if (json != null) { T data = JSON.parseObject(json, clazz); localCache.put(key, data); // 回填本地缓存 return data; } // 防线4:互斥锁控制回源 T data = loadFromDbWithLock(key, dbQuery, clazz); if (data != null) { localCache.put(key, data); } return data; }要注意的是:这个模板把布隆过滤器的判断放在Redis缓存之前。这么做的原因是布隆过滤器的查询时间比较快,而且如果它判断Key不存在,就没有必要再去Redis执行一次G一边GET。另一个细节:如果业务允许一定程度的延迟,这里的“防线2”可以在本地缓存中实现,但如果布隆过滤器数据量较大,本地内存撑不住,就必须放在Redis中。
5.3 实测效果与调优参数参考
我把这套框架用在一个商品详情接口上,压测结果供大家参考:接口原生QPS约800,数据库单机可承受约2000 QPS的查询。加四级防御后,Jmeter压测线程数从100调到1000,数据库实际接收的查询QPS稳定在30左右,接口P99响应时间从40ms降到了12ms。主要参数配置是这样的:
| 参数 | 值 | 说明 |
|---|---|---|
| 本地缓存过期时间 | 30秒 | 必须小于Redis缓存过期时间 |
| 本地缓存最大条目 | 10000 | 防止内存溢出,需要根据Heap大小调整 |
| Redis缓存过期时间 | 600秒+随机120秒 | 兼顾命中率和防雪崩 |
| 布隆过滤器预计元素量 | 实际数据量×1.1 | 留出10%余量 |
| 布隆过滤器误判率 | 0.01 | 取值越低内存越大 |
| 锁超时时间 | 5秒 | 大于数据库慢查询耗时 |
| 异步刷新线程池 | 核心4,最大8,队列1000 | 根据数据库能力配置 |
这个配置不是固定不变的,仅供参考。核心原则是:数据库回源量必须压到数据库能承受的范围以内,同时保证缓存命中率在95%以上。
6. 常见问题与排查技巧实录
在实际维护这套体系的过程中,我踩过不少坑,也总结了一些排查路径。单独拎出来说一遍,可能比上面那些代码更有价值。
6.1 布隆过滤器误判偏高怎么办
误判率升高,先别急着调大位数组。排查一下是不是数据量增长超出预期,导致初始化的n值过小。比如你初始化的预计元素量是100万,实际写进了500万,误判率会急剧升高。解决办法是监控过滤器中的BF.INFO(RedisBloom)或者布隆过滤器当前元素数的估算值,超过预计量的80%就触发自动重建,或者提前把预计量设大一点。
还有一个容易忽略的问题:业务代码中对Key的序列化方式不统一。有的地方用String,有的地方用DigestUtils.md5DigestAsHex做了哈希,如果Key的格式不统一,布隆过滤器中的数据和查询的数据无法对应,会产生大量“假误判”。排查时可以先打日志确认同一个Key在写入和查询时经过的字符串是否完全一致。
6.2 互斥锁失效,缓存被重复重建
锁失效最典型的场景是持锁线程执行时间超过了锁的自动过期时间。锁被Redis自动删除后,其他线程又拿到新锁,造成多个线程同时查库。排查时看日志里“acquire lock”和“release lock”的间隔,如果经常接近锁超时时间,说明建缓存过程太慢,需要优化数据库查询,或者把锁的超时时间拉长。
还有一个更隐蔽的问题:锁的key设置了过期时间,但释放锁时用delete命令,如果恰好在这个时刻锁已经过期,删除操作就会把别人刚创建的新锁删掉。前面代码里面已经提到解决方案,就是用UUID作为锁的value并校验后再删。这个细节能挡住很多人都会踩的坑。
6.3 多级缓存数据不一致如何缓解
多级缓存一定会面临不一致的问题。Redis更新了,本地缓存可能还是旧数据;数据库更新了,Redis缓存也还是旧数据。我的建议是:不要追求强一致,而是把一个可接受的不一致时间明确写进文档。
对于主动更新的场景,可以在更新数据库后先删Redis缓存,再通过延迟双删(1秒后再次删除)来清理可能被读请求重新写入的旧缓存。本地缓存这边,如果框架支持消息通知,可以通过Redis的Pub/Sub广播一个“清本地缓存”的消息;如果没条件,就接受它最多30秒的过期时间。
重要提示:多级缓存的一致性考验的不是技术,而是业务容忍度。设计架构前先和业务方确认:数据更新后最多可以接受多少秒的延迟?把这个数字写清楚,比你用再高级的组件都重要。
6.4 缓存回填风暴的预防
当大量缓存同时失效时,回填过程本身也会造成数据库压力。除了随机过期时间以外,还有个技巧是“回填节流”——不是所有请求都去数据库回填,而是只让一部分请求去查询,其他请求直接返回默认值或空数据。比如用互斥锁保证同一时刻只有一个请求回填,或者用一个“回填信号量”控制全局的最大回填并发数。
我在高并发场景下还用过一种“渐进式回填”:缓存失效时返回的旧数据里带一个refresh_after字段,表示“这个数据最多再服务多久”。到达这个时间后,只允许一个异步任务去刷新,而同步请求永远拿旧数据。这是逻辑过期方案的变种,非常适用于数据更新不频繁但读取量极大的场景。
7. 这些方案的边界和取舍
布隆过滤器、互斥锁、多级缓存这些方案都不是银弹,它们有各自的边界。我在实际项目中反复调整过很多次,逐渐摸索出一些取舍的经验。
布隆过滤器最大的边界是它只能用于“判断存在性”,不适合作为唯一的穿透防御。如果业务中合法请求都是“近期创建的数据”,那么布隆过滤器很快就会被塞满,重建成本很高。这种情况下,空值缓存也许更直接。两种方案可以组合:空值缓存放Redis,布隆过滤器放本地,前者兜底,后者拦截绝大多数恶意流量。
互斥锁的边界在于它本质上是在“用性能换一致性”——请求需要等待,必然增加延迟。如果热点Key的过期频率很高(比如每分钟都过期),那么锁竞争本身就会成为新的瓶颈。这时候逻辑过期方案更合适,因为它几乎没有锁等待。
多级缓存的边界在于“数据一致性”和“内存成本”。本地缓存的引入意味着每个应用实例要额外占用内存,如果缓存条目太大,会导致JVM GC压力上升。选择哪些数据进本地缓存、哪些只走Redis,需要按数据热度来分。热度高的、读取量大的、一致性要求低的数据才适合放本地。
我个人的原则是:先解决主要矛盾。如果问题是穿透流量大,优先上布隆过滤器;如果问题是热点数据偶尔过期导致抖动,优先上互斥锁;如果问题是周期性雪崩,优先随机化过期时间。不要一上来就上一个全量的“缓存防御全家桶”,复杂度会翻倍,排查问题的难度也会翻倍。
8. 一次真实事故的复盘:从报警到修复的完整思路
最后分享一次我亲身经历的事故,把前面这些理论串起来看,效果会更好。
某次大促预热期,凌晨两点监控突然报警,数据库CPU打满,慢查询暴涨。查看Redis的hit ratio,从平时的98%掉到了76%。第一反应是查RedisKey的过期情况,发现大量活动相关的Key同时过期——运营同学在后台定时任务里批量刷新活动数据,刷新代码统一设置的过期时间是300秒,而定时任务每5分钟执行一次,相当于每300秒就有几百个Key同时被重建。
解决思路分了三步:
第一步,止血。临时把定时任务的刷新逻辑改掉,刷新完成后不删旧缓存,而是先更新数据库,再通过异步任务在后台批量重写缓存,让缓存过期时间错开。这一步花了5分钟,数据库压力立即回落。
第二步,治理。把活动缓存代码统一改成随机过期时间,并且加了一个开关:如果某个批次Key的数量超过阈值,就随机挑选其中一半先刷新,剩下的延迟30秒再刷新,避免瞬时并发回填。
第三步,预防穿透。大促当天存在大量用户反复刷新不存在的活动页面,布隆过滤器提前添加了所有活动ID的集合,无效请求在过滤器就直接被拦掉。
事故复盘后我意识到一个核心问题:很多缓存故障不是技术方案不行,而是业务侧的定时任务、批量操作没有考虑缓存失效的“共振效应”。技术防御是必要的,但更重要的是在业务流程中加入缓存意识——比如批量更新数据的任务,一定要把“集中过期”作为一项风险来评估。
这套防御体系,我的建议是你不用全上,根据自己的业务体量,从布隆过滤器或互斥锁选一个开始,逐步叠加。先把最痛的那一个问题解决了,再考虑引入更多组件。缓存这层看上去简单,但真正要扛住高并发,那些“缓存失效瞬间”的处理才见功底。