.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 三大缓存经典问题时,保持系统稳定与高可用,有效保护后端数据库。

相关推荐
二宝哥12 小时前
CentOS 7.9 系统下 Redis 5.0.4 单机安装与源码编译安装指南
redis·centos
慧都小妮子16 小时前
Aspose.CAD for .NET 26.6 更新发布
pdf·.net·3d渲染·cad·pdf导出·ifc编辑
时空无限17 小时前
vllm 缓存对模型启动时间的影响
缓存·vllm
ShiXZ21318 小时前
Redis docker-compose 部署指南
redis·docker·eureka
青山木21 小时前
Redis 缓存一致性全解:从 Cache Aside 到延迟双删、Canal+MQ 的进阶之路
java·数据库·redis·后端·缓存
夜莺悠吟1 天前
关于对 C# 中 ImplicitUsings,GlobalUsings 的讨论
c#·.net
YDS8291 天前
大营销平台 —— 活动SKU库存扣减业务及其一致性处理
java·spring boot·redis·rabbitmq·ddd
咏方舟【长江支流】1 天前
【开源】跨语言·跨平台·跨数据库(6) ——一种ORM缓存的接口实现和容错处理
数据库·缓存·开源·咏方舟-长江支流·用宝框架
爬也要爬着前进2 天前
redis主从搭建
数据库·redis·缓存
用户095367515832 天前
在系统的学习 redis 前的疑惑
redis·go