缓存穿透、击穿、雪崩解决方案大全:L2Cache的NullValue空值缓存实战指南
做缓存的人,谁没被「缓存穿透、缓存击穿、缓存雪崩」这三个词折磨过?L2Cache 是一个基于 Caffeine + Redis 的二级缓存框架,它用一套 NullValue 空值缓存机制,把缓存穿透这类高并发难题变成了「开箱即用」的能力。本文带你快速搞懂:三大缓存故障的成因、L2Cache 的 NullValue 空值缓存是怎么实现的、有哪些关键配置参数,以及一个真实的线上踩坑案例。
一、缓存三大故障,一张表看懂
先对齐概念,避免踩坑时「雾里看花」:
| 故障类型 | 一句话定义 | 典型后果 | 常规解决思路 |
|---|---|---|---|
| 🕳️ 缓存穿透 | 查的数据根本不存在,缓存和 DB 都没有 | 每次请求都打到 DB,可被恶意利用 | 空值缓存、布隆过滤器 |
| 💥 缓存击穿 | 某个热点 key 过期瞬间,海量请求同时穿过缓存 | 单 key 高并发击穿 DB | 互斥锁、异步加载、本地缓存异步加载 |
| 🏔️ 缓存雪崩 | 大量 key 同时失效,请求集体涌向 DB | DB 负载爆表,系统级连锁故障 | 过期时间加随机量、缓存高可用、限流降级 |
三者经常被混淆:穿透查的是「不存在的 key」,击穿打的是「单个热点 key」,雪崩是「大面积 key 集体失效」。
二、L2Cache 的 NullValue 空值缓存原理 🕳️
对付缓存穿透,最经典的方案就是给不存在的 key 缓存一个空值:下次同样的请求进来,直接命中空值、返回 null,再也不用穿透到 DB。
L2Cache 把这件事做成了自动化能力,核心流程分三步:
- 请求到达:先查一级缓存(Caffeine),未命中再查二级缓存(Redis);
- 回源加载:两级都没有时,调用业务方法去 DB 加载,如果加载结果是 null,且配置项
allowNullValues=true(默认开启),就会往一级、二级缓存中写入一个特殊的NullValue空值对象,而不是 null 本身(Redis 等存储不支持 null 值); - 再次命中:后续相同 key 的请求直接命中
NullValue,由NullValueUtil将其解析回 null 返回给业务,请求在缓存层就被拦截,完全不会打到 DB。
关键源码位置(想深挖原理的同学可以看这里):
- 空值对象定义:
l2cache-core/src/main/java/com/github/jesse/l2cache/content/NullValue.java(可序列化的单例,用equals兼容 null 判断) - 空值存取转换:
l2cache-core/src/main/java/com/github/jesse/l2cache/util/NullValueUtil.java - 加载时写入空值:
l2cache-core/src/main/java/com/github/jesse/l2cache/load/LoadFunction.java - 空值清理任务:
l2cache-core/src/main/java/com/github/jesse/l2cache/schedule/NullValueCacheClearTask.java
三、NullValue 四大关键配置参数 🔧
只缓存空值、永不清理,会埋下两个新雷:空值把真实数据「假死」掉、海量空值撑爆内存。L2Cache 在 L2CacheConfig.java 中提供了四个参数(均在 CacheConfig 内),帮你把风险控制在可接受范围:
| 参数 | 默认值 | 作用 |
|---|---|---|
allowNullValues | true | 是否允许缓存空值,防止缓存穿透的总开关 |
nullValueExpireTimeSeconds | 60 秒 | 空值条目的短 TTL,给真实数据「复活窗口」,数据入库后最多 1 分钟即可被查到 |
nullValueMaxSize | 5000 | 空值条目数量上限,防止恶意请求灌空 key 导致 OOM(对应文档中的内存溢出实战问题) |
nullValueClearPeriodSeconds | 10 秒 | 定时清理任务执行频率,触发过期淘汰与基于大小的淘汰 |
背后的机制:每个缓存名额外维护一个专门的 nullValueCache,空值条目在其中登记;NullValueCacheClearTask 按清理频率定时执行 cleanUp(),把过期、超量的空值条目清掉。也就是说,空值缓存是「有限期、有上限」的,既挡住了穿透,又不会长期占坑。
💡 使用建议:如果业务侧「数据先查、后入库」的窗口期较长(例如商品预热上架),可适当调大 nullValueExpireTimeSeconds,否则用户可能在数据真正入库后仍短暂查到 null。
四、击穿与雪崩:L2Cache 又是如何应对的?💥🏔️
空值缓存只解决穿透,击穿和雪崩同样有解,L2Cache 的设计天然兼顾:
- 防击穿 —— Caffeine 异步加载:一级缓存命中加载逻辑时,同一 key 的并发请求只有一个线程真正加载,其余线程等待结果,热点 key 失效瞬间不会把 DB 打爆;配合
refreshAfterWrite异步刷新,热点数据可以在过期前被后台悄悄更新,用户无感知(详见 Caffeine简介.md); - 防雪崩 —— 双层保险:Caffeine 默认异步机制防止同 key/不同 key 被同时击穿;即使 Redis 层大面积故障,本地 Caffeine 一级缓存仍能兜底一部分请求(有损但可用),配合 Sentinel 等限流熔断保护下游(详见 关于缓存的几个常见问题分析和处理方案.md)。
五、一个真实的线上坑:NullValue 被「误缓存」 ⚠️
理论讲完,看一个项目里的真实实战:8 点开售的会场商品集体不显示。排查发现,某请求在 Redis 缓存已过期、且 valueLoader 为 null 的场景下,把 NullValue 错误地写进了本地缓存和 Redis,导致真实数据被空值挡住。
根因是 refreshAfterWrite 模式下,getIfPresent 获取已过期缓存会触发异步加载——而多数人对它的预期是「只读不加载」。最终修复是在加载链路中增加判断:集群环境下 valueLoader 和 value 都为 null 时直接返回 null,不再缓存 NullValue。
这个案例的价值在于:空值缓存不是「配上就完事」,理解触发链路(尤其是 refreshAfterWrite 的加载语义)才能规避误伤。完整分析见 实战-l2cache中通过getIfPresent()获取已过期缓存导致被设置为NullValue的问题分析.md获取已过期缓存导致被设置为NullValue的问题分析.md),内存溢出场景则可参考 实战-l2cache-caffeine之内存溢出分析.md。
六、落地清单:三步开启空值缓存 ✅
- 确认默认配置:
allowNullValues默认为 true,大多数场景无需任何额外配置,穿透防护自动生效; - 按业务调参:数据「先查后建」的场景调大
nullValueExpireTimeSeconds;高防场景可下调nullValueMaxSize; - 观察日志:关注
NullValueCache put与Clean NullValue两类日志,确认空值写入与清理节奏符合预期。
一句话总结:用 NullValue 空值缓存挡住穿透,用 Caffeine 异步加载扛住击穿,用双层缓存 + 限流降级防住雪崩——L2Cache 把这套组合拳封装成了 Caffeine + Redis 二级缓存的默认能力,你只需要把业务接口交给它,剩下的交给框架。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考




