缓存穿透用空值缓存+布隆过滤器双保险:先以布隆过滤器拦截95%+非法key,再对通过者查缓存,DB返回null时写入带TTL的"NULL_VALUE";缓存击穿用Redis SETNX加锁+指数退避重试防并发;缓存雪崩靠随机过期时间、启动预热、本地缓存兜底及Redis多实例分片。

缓存穿透:用空值缓存 + 布隆过滤器双保险
缓存穿透本质是「查一个数据库里压根不存在的 key」,比如恶意请求 user:999999999,每次都会绕过 Redis 直击数据库。只靠空值缓存("NULL_VALUE")能挡住重复攻击,但内存浪费大、误删难清理;只靠布隆过滤器又存在误判风险——所以 C# 项目里推荐组合使用。
- 先用
BloomFilterService在请求入口拦截 95%+ 的非法 key(如 ID ≤ 0、格式明显错误、不在预热集合中) - 对通过布隆过滤器的请求,再走标准缓存逻辑;若 DB 返回
null,写入"NULL_VALUE"并设 TTL(建议TimeSpan.FromSeconds(30)~TimeSpan.FromMinutes(5)),避免长期占内存 - 注意:布隆过滤器初始化必须在应用启动时完成,且要覆盖所有「可能被查询」的有效 key(如全量用户 ID、商品 SKU),不能只塞热点数据
public async Task<T> GetDataWithBloomAndNullAsync<T>(string key, Func<Task<T>> dbQueryFunc, TimeSpan nullExpire = default)
{
// 1. 布隆过滤器前置校验(假设已注入 BloomFilterService)
if (!_bloomFilter.MightContain(key))
return default;
<pre class='brush:php;toolbar:false;'>// 2. 尝试读缓存
var cacheValue = await _redisDb.StringGetAsync(key);
if (!cacheValue.IsNullOrEmpty())
return cacheValue == "NULL_VALUE" ? default : JsonConvert.DeserializeObject<T>(cacheValue);
// 3. 查库
var data = await dbQueryFunc();
if (data == null)
await _redisDb.StringSetAsync(key, "NULL_VALUE", nullExpire == default ? TimeSpan.FromMinutes(2) : nullExpire);
else
await _redisDb.StringSetAsync(key, JsonConvert.SerializeObject(data), TimeSpan.FromMinutes(30));
return data;}
缓存击穿:用 Redis SETNX + 本地锁兜底防并发
缓存击穿是「单个热点 key 过期瞬间,大量请求同时打到 DB」。C# 里不能只依赖 lock(跨进程无效),也不能只靠 SETNX(失败后重试逻辑容易写成死循环或雪崩式重试)。
- 优先用
Redis.StringSetAsync(key, value, expiry, When.NotExists)实现原子加锁,key 命名为"lock:" + originalKey - 加锁失败后,用指数退避(
Task.Delay(50 + new Random().Next(0, 100)))再试,最多 3 次,超时直接查 DB(避免卡死) - 务必设置锁过期时间(如
TimeSpan.FromSeconds(30)),防止持有锁的线程崩溃导致死锁 - 如果业务允许短暂不一致,可考虑「逻辑过期」:缓存值里嵌入时间戳,过期不删 key,而是后台异步刷新
缓存雪崩:随机过期 + 预热 + 多级降级
缓存雪崩不是某个 key 的问题,而是「一批 key 集体过期」引发的连锁反应。C# 服务里最容易踩的坑是:所有商品详情缓存统一设 TimeSpan.FromHours(2),凌晨两点全崩。
- 写缓存时,对同类数据加随机偏移:
TimeSpan.FromHours(2).Add(TimeSpan.FromMinutes(new Random().Next(0, 30))) - 系统启动时,用
IHostedService异步预热核心 key(如首页 Banner、热门分类),避免首请求触发雪崩 - 关键路径上加本地缓存兜底(如
MemoryCache),即使 Redis 宕机也能扛几秒——但注意它不跨进程,不能替代分布式锁 - 不要把所有鸡蛋放在一个 Redis 实例上:读多写少场景可用
ConnectionMultiplexer配置多个 endpoint 做分片或读写分离
容易被忽略的细节
这三个问题从来不是孤立存在的。比如你加了布隆过滤器,但没做 key 格式标准化("user:123" 和 "USER:123" 被当成两个 key),过滤器就形同虚设;再比如用了 StringSetAsync(..., When.NotExists) 加锁,却忘了在 finally 块里 del 锁 key,下次请求永远进不了 DB。
最麻烦的是「空值缓存 + 雪崩」叠加:大量 "NULL_VALUE" key 同时过期,导致同一时刻所有非法请求又集体打库。所以空值 TTL 一定要短,且和业务真实失效周期错开。










