【.NET】IMemoryCache 进阶:过期策略、缓存击穿与内存上限

前言

进程内缓存是每个后端项目迟早要面对的问题:热数据重复查库、配置项频繁读取、外部接口结果短期复用,都可以用内存缓存解决。.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 分钟」的场景。

只有滑动过期会让永远被访问的 key 永不过期,必须配一个绝对过期兜底。

三、缓存击穿: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();
失效 token 只能触发一次,重建关联缓存时要创建新的 CTS。

六、直接注入还是包一层服务

做法 适合 代价
业务代码直接注入 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 补防击穿逻辑,最后监控命中率决定下一步。缓存是性能优化的第一站,也是最容易过度设计的一站,从最简单的绝对过期开始就够了。

© 版权声明
THE END
喜欢就支持一下吧
点赞13 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容