.NET 实战:Redis 缓存穿透、击穿与雪崩的原理剖析与解决方案

在 .NET 高并发系统中,Redis 作为核心缓存层,一旦出现"穿透、击穿、雪崩",数据库将瞬间承受巨大压力,严重时甚至会导致整个服务雪崩。本文将深入剖析三者原理,并给出可直接落地的 .NET 解决方案。


一、缓存穿透

1. 原理

客户端请求的数据,缓存里没有,数据库里也没有。每次请求都会绕过缓存,直达数据库。恶意攻击者可通过构造大量不存在的 key,使数据库压力剧增。

复制代码
请求 --> 缓存(Miss) --> 数据库(无记录) --> 返回空
(下次同样请求仍会重复上述路径)

2. 解决方案

A. 缓存空值

查询数据库发现数据不存在时,也在 Redis 中缓存一个短过期时间的空对象(或特殊标记)。下次相同请求直接命中空缓存,不再访问数据库。

.NET 实现示例 (StackExchange.Redis):

csharp 复制代码
public async Task<Product?> GetProductAsync(int id)
{
    var db = _redis.GetDatabase();
    string key = $"product:{id}";
    var cached = await db.StringGetAsync(key);

    if (cached.HasValue)
    {
        // 若为特殊空标记,直接返回null
        if (cached == "NULL") return null;
        return JsonSerializer.Deserialize<Product>(cached!);
    }

    // 查询数据库
    var product = await _dbContext.Products.FindAsync(id);
    if (product == null)
    {
        // 缓存空值,过期时间短,防止占用内存
        await db.StringSetAsync(key, "NULL", TimeSpan.FromMinutes(1));
        return null;
    }

    // 正常缓存
    await db.StringSetAsync(key, JsonSerializer.Serialize(product), TimeSpan.FromMinutes(10));
    return product;
}
B. 布隆过滤器

在缓存之前加一层布隆过滤器,它用极小的内存判断一个 key 一定不存在可能存在。所有合法的 key(如所有商品 ID)提前加载到布隆过滤器,请求先通过过滤器,若判断不存在则直接拒绝,不访问缓存和数据库。

.NET 使用 BloomFilter 示例 (借助 StackExchange.Redis 的布隆模块或本地内存过滤器):

csharp 复制代码
// 使用内存布隆过滤器(适合单机或预热到Redis)
var filter = new BloomFilter(1000000, 0.01); // 预计100万数据,1%误判率
// 初始化:加载所有合法key
foreach (var id in allProductIds)
    filter.Add(id);

public async Task<Product?> GetWithBloomAsync(int id)
{
    if (!filter.Contains(id))
        return null; // 直接返回,不查库

    // 正常走缓存+数据库逻辑
    return await GetProductAsync(id);
}

布隆过滤器可基于 Redis 的 BF.ADD/BF.EXISTS 命令(需安装 RedisBloom 模块),或使用 BitMap 自行实现。


二、缓存击穿

1. 原理

某个热点 key 在过期的一瞬间,大量并发请求同时穿透缓存,直接打到数据库。例如秒杀商品的缓存刚过期,瞬间涌入上万请求重建缓存,数据库极易被压垮。

复制代码
时间线:
T1: 热点key过期
T2: 大量请求同时发现缓存Miss -> 全部查询数据库

2. 解决方案

核心思想:避免让大量线程同时执行数据库查询与缓存重建

A. 互斥锁(SetNX)

第一个线程获取分布式锁,负责查库并重建缓存;其他线程等待锁释放后,直接从缓存读取。

csharp 复制代码
public async Task<Product?> GetProductWithLockAsync(int id)
{
    var db = _redis.GetDatabase();
    string key = $"product:{id}";
    string lockKey = $"lock:product:{id}";
    var token = Guid.NewGuid().ToString();

    var cached = await db.StringGetAsync(key);
    if (cached.HasValue)
        return cached == "NULL" ? null : JsonSerializer.Deserialize<Product>(cached!);

    // 尝试获取分布式锁
    if (await db.LockTakeAsync(lockKey, token, TimeSpan.FromSeconds(10)))
    {
        try
        {
            // 双重检查,可能其他线程已经重建
            cached = await db.StringGetAsync(key);
            if (cached.HasValue)
                return cached == "NULL" ? null : JsonSerializer.Deserialize<Product>(cached!);

            var product = await _dbContext.Products.FindAsync(id);
            if (product == null)
            {
                await db.StringSetAsync(key, "NULL", TimeSpan.FromMinutes(1));
                return null;
            }

            await db.StringSetAsync(key, JsonSerializer.Serialize(product), TimeSpan.FromMinutes(10));
            return product;
        }
        finally
        {
            await db.LockReleaseAsync(lockKey, token);
        }
    }
    else
    {
        // 未获取到锁,等待后重试
        await Task.Delay(50);
        return await GetProductWithLockAsync(id); // 递归重试(可限制次数)
    }
}
B. 逻辑过期(永不过期 + 异步重建)

缓存本身不设过期时间,在 value 中额外存储一个逻辑过期时间。读取时若发现逻辑过期,先返回旧值,然后开一个后台任务去更新缓存,避免阻塞请求。

csharp 复制代码
public class ProductCacheData
{
    public Product Data { get; set; }
    public DateTime ExpireTime { get; set; }
}

public async Task<Product?> GetProductLogicalExpireAsync(int id)
{
    var db = _redis.GetDatabase();
    string key = $"product:{id}";
    var json = await db.StringGetAsync(key);
    if (json.IsNullOrEmpty) return null;

    var cacheData = JsonSerializer.Deserialize<ProductCacheData>(json!);
    // 逻辑未过期,直接返回
    if (cacheData.ExpireTime > DateTime.Now)
        return cacheData.Data;

    // 逻辑过期,尝试获取锁
    string lockKey = $"lock:product:{id}";
    var token = Guid.NewGuid().ToString();
    if (await db.LockTakeAsync(lockKey, token, TimeSpan.FromSeconds(10)))
    {
        try
        {
            // 异步重建缓存,不阻塞当前请求
            _ = Task.Run(async () =>
            {
                var product = await _dbContext.Products.FindAsync(id);
                var newData = new ProductCacheData
                {
                    Data = product,
                    ExpireTime = DateTime.Now.AddMinutes(10)
                };
                await db.StringSetAsync(key, JsonSerializer.Serialize(newData));
            });
        }
        finally
        {
            await db.LockReleaseAsync(lockKey, token);
        }
    }

    // 无论是否获得锁,都返回旧数据(可能略微过时,但保证可用)
    return cacheData.Data;
}

三、缓存雪崩

1. 原理

大量缓存在同一时刻失效,或者 Redis 节点宕机,导致巨量请求直接冲向数据库,就像雪崩一样瞬间冲垮系统。

场景:

  • 批量 key 设置了相同的过期时间,在某个时间点共同过期。
  • Redis 集群大面积故障,缓存服务完全不可用。

2. 解决方案

A. 过期时间加随机因子

在基础过期时间上叠加一个随机值,避免大量 key 同时过期。

csharp 复制代码
var baseExpire = TimeSpan.FromMinutes(10);
var random = new Random();
var actualExpire = baseExpire + TimeSpan.FromSeconds(random.Next(0, 300)); // 加0~5分钟
await db.StringSetAsync(key, value, actualExpire);
B. 多级缓存 + 限流降级

设置本地内存缓存(如 IMemoryCache)作为一级缓冲,Redis 为二级缓存。即使 Redis 故障,本地缓存仍能挡掉部分请求。同时接入熔断降级框架(如 Polly),当数据库压力过大时,直接返回降级数据或限流。

csharp 复制代码
// Polly + 本地缓存降级示例
var fallbackPolicy = Policy<Product?>
    .Handle<Exception>()
    .FallbackAsync(async ct =>
    {
        // 尝试从本地内存缓存获取
        if (_memoryCache.TryGetValue(key, out Product localProduct))
            return localProduct;
        return null; // 或返回兜底数据
    });

var product = await fallbackPolicy.ExecuteAsync(async () =>
{
    return await GetFromRedisOrDbAsync(id);
});
C. Redis 高可用架构
  • 使用 Redis 哨兵 / 集群模式。
  • 主从 + 自动故障转移,避免单点故障。
  • 对于关键数据,采用持久化(RDB/AOF)确保快速恢复。

四、总结与最佳实践

问题 典型特征 .NET 核心解决手段
穿透 请求不存在的数据 缓存空值、布隆过滤器
击穿 热点 key 瞬间过期 互斥锁(SetNX)、逻辑过期异步重建
雪崩 大量 key 同时过期或 Redis 宕机 随机过期时间、多级缓存、熔断降级、集群高可用

.NET 实战组合建议:

  1. 所有缓存写入统一封装,自动添加随机过期时间。
  2. 对已知的合法 ID 集合构建布隆过滤器,入口处拦截非法请求。
  3. 热点数据采用"逻辑过期 + 互斥锁"方案,保证高可用。
  4. 结合 Polly 实现熔断、降级、重试策略,形成纵深防御。
  5. 监控 Redis 和数据库的 QPS、延迟,设置告警阈值。

通过上述方案,.NET 应用能够在面对 Redis 三大缓存经典问题时,保持系统稳定与高可用,有效保护后端数据库。

相关推荐
贾斯汀玛尔斯2 小时前
每天学一个算法--缓存淘汰策略(LRU / LFU · 结构与复杂度)
算法·缓存
wuqingshun31415910 小时前
说说mybatis的缓存机制
java·缓存·mybatis
Devin~Y11 小时前
大厂Java面试实录:Spring Boot/Cloud、Kafka、Redis、K8s 与 Spring AI(RAG/Agent)三轮连环问
java·spring boot·redis·mysql·spring cloud·kafka·kubernetes
武藤一雄13 小时前
19个核心算法(C#版)
数据结构·windows·算法·c#·排序算法·.net·.netcore
小小小米粒14 小时前
redis命令集合
数据库·redis·缓存
旷世奇才李先生14 小时前
Redis高级实战:分布式锁、缓存穿透与集群部署(附实战案例)
redis·分布式·缓存
014-code18 小时前
Caffeine:最快的本地缓存
缓存
uElY ITER19 小时前
基于Spring Boot 3 + Spring Security6 + JWT + Redis实现登录、token身份认证
spring boot·redis·spring
java干货20 小时前
如果光缆被挖断导致 Redis 出现两个 Master,怎么防止数据丢失?
数据库·redis·缓存