前言
进程内缓存是每个后端项目迟早要面对的问题:热数据重复查库、配置项频繁读取、外部接口结果短期复用,都可以用内存缓存解决。.NET 自带的 IMemoryCache 开箱即用,但缓存击穿、过期策略和内存上限这些细节,往往要踩过坑才知道。
本文只讲框架自带的缓存能力,示例基于 .NET 8。
一、为什么不能只用字典做缓存
最朴素的缓存是一个 Dictionary 加锁,实际跑起来会遇到:
- 没有过期时间,数据越积越多,最终内存爆掉。
- 并发重建:热点 key 失效瞬间,几十个请求同时去查数据库。
- 没有大小限制,一次缓存注入攻击就能把进程打挂。
- 值被外部修改后无法察觉,脏数据无限扩散。
IMemoryCache 把过期、大小限制、并发防护这些能力内置了,剩下的细节要自己处理。
二、基础用法与过期策略
builder.Services.AddMemoryCache();
var cache = serviceProvider.GetRequiredService<IMemoryCache>();
var user = await cache.GetOrCreateAsync($"user:{id}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
entry.SlidingExpiration = TimeSpan.FromMinutes(1);
return LoadUserAsync(id);
});
AbsoluteExpirationRelativeToNow 是「最多活多久」,SlidingExpiration 是「多久不访问就过期」,两者可以组合:组合时以先到者为准,适合「高频访问保持活跃、但整体不超过 5 分钟」的场景。
三、缓存击穿:GetOrCreate 并不防并发重建
很多人以为 GetOrCreateAsync 天然防止并发重建,实际上同一时刻多个请求同时 miss,会各自执行一次工厂方法。热点 key 需要自己加锁:
private static readonly SemaphoreSlim _gate = new(1, 1);
private async Task<User> GetUserAsync(int id)
{
string key = $"user:{id}";
if (_cache.TryGetValue(key, out User cached))
return cached;
await _gate.WaitAsync();
try
{
// 双重检查:拿到锁后可能别的请求已经重建完了
if (_cache.TryGetValue(key, out cached))
return cached;
var user = await LoadUserAsync(id);
_cache.Set(key, user, TimeSpan.FromMinutes(5));
return user;
}
finally
{ _gate.Release(); }
}
按 key 细分锁可以用 ConditionalWeakTable 或现成的 KeyedSemaphores 实现,全局单锁只适合 key 数量少的场景。
四、SizeLimit 与逐出策略
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 100_000; // 单位自定义:条数或估算字节数
});
_cache.Set(key, value, new MemoryCacheEntryOptions
{
Size = 1,
Priority = CacheItemPriority.High
});
要点是:设置了 SizeLimit 之后,每个条目都必须给 Size,漏给的条目会在 Set 时抛异常。逐出不是精确的 LRU,只是尽力而为,不要把 SizeLimit 当精确配额使用。
五、失效与依赖
缓存失效有两个方向:主动删和被动联动。主动删就是 Remove(key);联动失效用 CancellationChangeToken 串多个 key:
var cts = new CancellationTokenSource();
var entryOptions = new MemoryCacheEntryOptions()
.AddExpirationToken(new CancellationChangeToken(cts.Token));
_cache.Set("list:v1", LoadList(), entryOptions);
_cache.Set("list:count", 42, entryOptions);
// 配置或底表变更时一键失效所有关联条目
cts.Cancel();
六、直接注入还是包一层服务
| 做法 | 适合 | 代价 |
|---|---|---|
| 业务代码直接注入 IMemoryCache | 小项目、读多写少的配置类数据 | 缓存逻辑散在业务里,测试要 mock |
| 服务内封装(XxxCachedService) | 热点数据、需要防击穿的场景 | 多一层类,但逻辑集中可测试 |
| HybridCache(.NET 9+) | 既要内存又要分布式二级缓存 | 新 API,需要迁移老代码 |
.NET 9 引入的 HybridCache 把「内存缓存 + 分布式缓存 + 防击穿」合成一个 GetOrCreateAsync,新项目可以直接用它,老项目继续用 IMemoryCache 也没有问题。
七、生产使用注意事项
- 缓存对象尽量不可变(record 或只读集合),外部修改缓存对象等于污染所有读者。
- 大对象缓存前估算内存:100 万个 1KB 对象就是 1GB,SizeLimit 要按字节算就诚实按字节给 Size。
- 过期时间不要拍脑袋:读一次源库的成本和脏数据窗口,决定分钟级还是秒级。
- 不要缓存 HttpContext、DbContext 或任何带生命周期的东西。
- 监控命中率和内存占用,命中率长期低于预期就删掉缓存直接查库,省一层心智负担。
总结
IMemoryCache 的正确用法是:绝对过期兜底加滑动过期保鲜,SizeLimit 配齐 Size,热点 key 手动防击穿,联动失效用 ChangeToken。.NET 9 之后新项目优先考虑 HybridCache,它把上面这些坑一次填完。
推荐的落地顺序是:先给配置类、字典类数据上缓存拿到收益,再针对压测中发现的热点 key 补防击穿逻辑,最后监控命中率决定下一步。缓存是性能优化的第一站,也是最容易过度设计的一站,从最简单的绝对过期开始就够了。










暂无评论内容