缓存穿透需分层拦截:入口校验→布隆过滤器→空值写"null_value"短ttl;雪崩需大范围随机过期+启动预热+memorycache兜底+淘汰回调;空值必须用setstringasync写字符串标记,禁用泛型序列化。

缓存穿透和雪崩不是加个 GetOrCreateAsync 或改个随机过期时间就能解决的——关键在拦截层级、空值语义、以及失效节奏的协同控制。
缓存穿透:别让 null 值绕过布隆过滤器直接进缓存
很多人用 MemoryCache.GetOrCreateAsync 或 IDistributedCache.GetStringAsync 查不到就写 null,结果是:恶意 ID(如 "user:-1"、"order:9999999999")每次都被缓存成 null,内存越积越多,且无法区分“真不存在”和“刚删掉”。
真正有效的做法是分层拦截:
- 第一层:在 Controller 入口用
[Range(1, int.MaxValue)]或自定义ValidationAttribute拦截明显非法 ID - 第二层:启动时加载全量有效 key(如用户 ID、SKU)到布隆过滤器(推荐
Microsoft.Extensions.Caching.BloomFilter),请求先走_bloomFilter.MightContain(key);返回false直接return NotFound() - 第三层:仅对通过布隆过滤器的 key 才查缓存;若 DB 返回
null,才写入"NULL_VALUE"并设短 TTL(如TimeSpan.FromMinutes(2))
注意:NULL_VALUE 必须是字符串字面量,不能是序列化后的 null(否则 JsonConvert.DeserializeObject<t></t> 会抛异常);Redis 中用 StringSetAsync 写入,不要用对象序列化器处理空值。
缓存雪崩:随机过期只是表象,核心是失效密度与预热节奏
把所有 key 的 AbsoluteExpirationRelativeToNow 改成 TimeSpan.FromMinutes(10 + random.Next(-3, 4)) 只能缓解,不是根治。问题常出在:冷启动时全量 key 集中加载、定时任务统一刷新、或 Redis 实例重启后无预热。
必须组合三件事:
- 为每个 key 设置基础过期时间 + 随机扰动,但扰动范围要足够大(例如 ±30% 而非 ±2 分钟),避免“伪随机”仍扎堆
- 应用启动后立即触发关键数据预热(如首页 Banner、用户配置项),用
Task.Run(() => PreloadCriticalKeys()),而非等第一个请求触发 - 对高优先级 key,启用本地
MemoryCache作为兜底:先查IMemoryCache,未命中再查 Redis;即使 Redis 整体不可用,也能扛住部分读流量
特别注意:MemoryCache 的 PostEvictionCallbacks 要注册监听,一旦 key 被淘汰,立刻触发异步重载或告警——否则你以为的“兜底”其实已失效。
IDistributedCache 空值缓存的序列化陷阱
用 IDistributedCache.SetStringAsync(key, "NULL_VALUE") 是安全的,但一旦换成 SetAsync<t></t> 或依赖默认 JSON 序列化,就容易翻车:
-
System.Text.Json默认不保留DateTimeKind.Unspecified,若业务对象含时间字段,反序列化后可能变成 UTC 时间,导致逻辑错误 - 空值被序列化成
"null"字符串,而你代码里判断的是cacheValue == "NULL_VALUE",永远不匹配 - Redis 中 key 存的是 JSON 字符串,但你误以为是原始对象,后续用
StringGetAsync拿到后再手动DeserializeObject,多了一次无谓解析
建议统一用 SetStringAsync + 明确字符串标记("NULL_VALUE"、"EMPTY_LIST"),并在读取时先判断字符串内容,再决定是否反序列化。不要图省事封装一个泛型 SetAsync<t></t> 就往里塞所有类型。
最易被忽略的一点:布隆过滤器初始化必须是幂等的,且覆盖全量有效 key —— 如果只预热了前 10 万用户,而第 100001 个新注册用户 ID 立刻被恶意刷,过滤器照样放行。冷数据上线、分库分表扩容、ID 生成策略变更,都得同步更新布隆位图。










