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;
}
嗯,这里要注意 SizeLimit 和 SlidingExpiration 的配合。我在项目中遇到过一个问题:设置了滑动过期,但缓存一直没被访问,结果内存占用越来越高。后来才发现,滑动过期只在你访问时刷新,如果一直没人访问,它其实不会自动清理——直到达到 SizeLimit 触发淘汰。
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;
}
}
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
});
4.4 实战总结
说了这么多,总结一下我的经验:
- MemoryCache 适合单机场景,分布式场景建议用 Redis。
- 过期策略要组合使用,绝对过期 + 滑动过期是最佳拍档。
- 缓存穿透用空值缓存或布隆过滤器,缓存击穿用互斥锁或逻辑过期,缓存雪崩用过期时间随机化。
- 监控不能少,我习惯给缓存加命中率监控,低于 80% 就要排查了。
嗯,内存缓存这块其实还有很多细节,比如缓存淘汰算法(LRU、LFU)、缓存预热等。但今天咱们先把这几个核心问题搞清楚,后面的章节再深入聊。记住一句话:缓存不是银弹,用不好反而会引入更多问题。