4、内存缓存策略:MemoryCache使用、缓存过期策略、缓存穿透/击穿/雪崩应对

聊到高并发,缓存绝对是绕不开的话题。我个人习惯把缓存看作是系统的「减震器」——没有它,数据库直接面对流量洪峰,再好的硬件也扛不住。今天咱们就深入聊聊 .NET 里的内存缓存,特别是 MemoryCache 怎么用,以及那几个让人头疼的缓存问题怎么解决。

4.1 MemoryCache 基础使用

先说说 MemoryCache 本身。它是 .NET 内置的内存缓存方案,说白了就是把数据存在进程内存里。我最早接触它是在 .NET Framework 4.0 时代,那时候还叫 ObjectCache,现在用起来方便多了。

基本用法很简单,看代码:

// 创建缓存实例
var cache = new MemoryCache(new MemoryCacheOptions
{
    // 设置缓存最大大小(单位:MB)
    SizeLimit = 100,
    // 缓存过期扫描间隔
    ExpirationScanFrequency = TimeSpan.FromMinutes(1)
});

// 写入缓存
cache.Set("user:1001", userData, new MemoryCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30),
    SlidingExpiration = TimeSpan.FromMinutes(5)
});

// 读取缓存
if (cache.TryGetValue("user:1001", out UserData cachedUser))
{
    return cachedUser;
}

嗯,这里要注意 SizeLimitSlidingExpiration 的配合。我在项目中遇到过一个问题:设置了滑动过期,但缓存一直没被访问,结果内存占用越来越高。后来才发现,滑动过期只在你访问时刷新,如果一直没人访问,它其实不会自动清理——直到达到 SizeLimit 触发淘汰。

小技巧: 建议同时设置绝对过期和滑动过期。绝对过期兜底,滑动过期应对热点数据。比如:绝对过期设 30 分钟,滑动过期设 5 分钟。这样既保证数据新鲜度,又避免冷数据长期占用内存。

4.2 缓存过期策略详解

过期策略这块,说白了就是决定「什么时候把缓存扔掉」。.NET 提供了三种主要策略:

策略类型 说明 适用场景
绝对过期 固定时间后过期,不管有没有被访问 配置信息、字典数据
滑动过期 每次访问刷新过期时间,持续不访问才过期 用户会话、热点数据
混合过期 同时设置绝对和滑动,以先到者为准 大部分业务场景

你想想看,为什么要有滑动过期?举个例子,用户登录后的 Session 数据。如果用户一直在操作,你肯定不希望缓存过期让他重新登录。滑动过期正好解决这个问题——只要用户活跃,缓存就一直有效。

但滑动过期有个坑:缓存风暴。我曾经在一个高并发项目里,所有缓存都用了纯滑动过期。结果某个时刻大量缓存同时过期,所有请求都打到数据库,直接导致服务雪崩。后来我改成混合过期,绝对过期时间设长一点,滑动过期设短一点,问题就解决了。

避坑指南: 纯滑动过期在高并发场景下容易引发缓存雪崩。建议总是搭配绝对过期使用,给缓存一个「生命上限」。

4.3 缓存穿透、击穿、雪崩

这三个问题,我估计每个做高并发的都遇到过。咱们一个一个说。

4.3.1 缓存穿透

缓存穿透,说白了就是「查一个不存在的数据」。比如请求一个不存在的用户 ID,缓存里没有,数据库里也没有。每次请求都穿透缓存直接打数据库,如果并发高,数据库瞬间就跪了。

怎么解决?我常用的方法有几种:

  • 缓存空值: 查不到数据时,也缓存一个空对象,设置较短的过期时间(比如 1 分钟)。这样后续同样的请求就不会打到数据库了。
  • 布隆过滤器: 在缓存前面加一层布隆过滤器,判断数据是否存在。不存在就直接返回,不用查缓存和数据库。

代码示例:

public UserData GetUser(int userId)
{
    // 先查缓存
    if (cache.TryGetValue($"user:{userId}", out UserData user))
    {
        return user;
    }

    // 查数据库
    user = db.Users.Find(userId);
    
    if (user == null)
    {
        // 缓存空值,过期时间设短一些
        cache.Set($"user:{userId}", null, TimeSpan.FromMinutes(1));
        return null;
    }

    // 正常缓存
    cache.Set($"user:{userId}", user, new MemoryCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
    });
    
    return user;
}
重点: 缓存空值一定要设置过期时间,否则空值会一直存在,导致数据更新后用户还是查不到。

4.3.2 缓存击穿

缓存击穿和穿透不一样。击穿是指一个热点 key 过期了,大量并发请求同时来查这个 key,结果全部打到数据库。说白了就是「一个 key 扛不住」。

我记得有一次双十一活动,某个爆款商品的详情页缓存过期了,瞬间几万个请求同时打到数据库,差点把数据库打崩。后来怎么解决的?

  • 互斥锁: 当缓存过期时,只让一个线程去查数据库,其他线程等待。等数据加载完,所有线程都能从缓存读取。
  • 逻辑过期: 缓存不过期,但数据里带一个逻辑过期时间。后台异步刷新缓存,保证数据新鲜度。

互斥锁的实现:

private static readonly object _lockObj = new object();

public UserData GetHotUser(int userId)
{
    // 先查缓存
    if (cache.TryGetValue($"hot:user:{userId}", out UserData user))
    {
        return user;
    }

    // 加锁,只让一个线程查数据库
    lock (_lockObj)
    {
        // 双重检查,防止其他线程已经加载了
        if (cache.TryGetValue($"hot:user:{userId}", out user))
        {
            return user;
        }

        user = db.Users.Find(userId);
        cache.Set($"hot:user:{userId}", user, TimeSpan.FromMinutes(10));
        return user;
    }
}
注意: 互斥锁在高并发下会影响性能。如果热点 key 特别多,建议用逻辑过期方案,让缓存「永不过期」,后台定时刷新。

4.3.3 缓存雪崩

缓存雪崩是三个问题里最严重的。它是指大量缓存同时过期,或者缓存服务挂了,导致所有请求都打到数据库。数据库扛不住,服务就崩了。

为什么会这样?我见过最典型的场景:所有缓存都设置了相同的过期时间,比如凌晨 0 点统一过期。结果 0 点一到,所有请求都去查数据库,数据库直接被打满。

应对策略:

  • 过期时间加随机值: 设置过期时间时,加一个随机偏移量,避免大量缓存同时过期。
  • 多级缓存: 本地缓存 + Redis 缓存 + 数据库,层层防护。
  • 限流降级: 当数据库压力过大时,直接返回降级数据或错误提示。

代码示例:

// 过期时间加随机值,避免雪崩
var baseExpiration = TimeSpan.FromMinutes(30);
var randomOffset = TimeSpan.FromSeconds(new Random().Next(0, 300));
var expiration = baseExpiration + randomOffset;

cache.Set($"user:{userId}", user, new MemoryCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = expiration
});
避坑指南: 我曾经在一个项目里,所有缓存都用了相同的过期时间,结果凌晨 3 点数据库突然飙高。排查了半天才发现是缓存雪崩。从那以后,我所有缓存的过期时间都加了随机偏移,再也没出过类似问题。

4.4 实战总结

说了这么多,总结一下我的经验:

  1. MemoryCache 适合单机场景,分布式场景建议用 Redis。
  2. 过期策略要组合使用,绝对过期 + 滑动过期是最佳拍档。
  3. 缓存穿透用空值缓存或布隆过滤器,缓存击穿用互斥锁或逻辑过期,缓存雪崩用过期时间随机化。
  4. 监控不能少,我习惯给缓存加命中率监控,低于 80% 就要排查了。

嗯,内存缓存这块其实还有很多细节,比如缓存淘汰算法(LRU、LFU)、缓存预热等。但今天咱们先把这几个核心问题搞清楚,后面的章节再深入聊。记住一句话:缓存不是银弹,用不好反而会引入更多问题