c# MemoryCache 的线程安全问题和高并发使用

14次阅读

MemoryCache.Get线程安全,但GetOrCreate非原子;应通过DI复用单例实例;过期清理惰性执行;PostEvictionCallback不保证触发。

c# MemoryCache 的线程安全问题和高并发使用

MemoryCache.Get 是线程安全的,但 GetOrCreate 不是原子操作

MemoryCache.Get 本身是线程安全的,多个线程并发调用不会导致内部状态损坏。但常见误用是先 Get 判空,再 Set,这中间存在竞态窗口:两个线程同时发现缓存为空,都去构造值并写入,造成重复计算和覆盖风险。

正确做法是使用 GetOrCreateGetOrCreateAsync,它们在内部加锁确保“查-算-存”三步原子性。但要注意:GetOrCreate 的 valueFactory 委托会在锁内执行,若构造逻辑耗时(如 IO、复杂计算),会阻塞其他 key 的缓存操作,拖慢整体吞吐。

  • 高并发下优先用 GetOrCreateAsync,把耗时构造移到异步委托里,避免阻塞同步线程池
  • valueFactory 中不要调用阻塞 API(如 .Result.Wait()),否则引发死锁或线程饥饿
  • 如果 valueFactory 可能抛异常,异常会被捕获并导致本次 GetOrCreate 返回 NULL.net 6+ 行为),需在外层处理 fallback 逻辑

MemoryCache 默认实例不是进程单例,多处 new 就多份缓存

很多人直接 new MemoryCache(new MemoryCacheOptions()),以为拿到的是全局缓存。实际上每次 new 都创建独立实例,互不共享数据,也无跨实例协调机制。这在 Web API 或依赖注入场景中极易导致缓存击穿——每个控制器或服务实例维护自己的缓存副本,无法分摊压力。

正确方式是复用同一个实例。ASP.NET Core 中应通过 DI 注册:

services.AddMemoryCache(options => {     options.SizeLimit = 1024; // 启用大小限制需手动设 });

然后在类中注入 IMemoryCache 接口。DI 容器默认以 Singleton 生命周期提供,所有使用者共享同一缓存实例和内部 ConcurrentDictionary。

  • 手动生成实例仅适用于单元测试或极简脚本,生产环境必须走 DI
  • 若需多个隔离缓存(如按租户分区),应显式注册多个命名选项,而非 new 多个实例
  • 注意 IMemoryCache 是接口,实现类 MemoryCache 内部用 ConcurrentDictionary 存储,其线程安全性已由 .NET 保障

缓存项过期与内存压力触发的清理不是实时的

MemoryCache 的过期策略分两种:绝对过期(AbsoluteExpiration)和滑动过期(SlidingExpiration)。但无论哪种,过期检查都不是定时轮询或实时中断,而是“惰性清理”——只有在 Get/count/遍历时才触发过期扫描。这意味着过期项可能在内存中残留数秒甚至更久,尤其在低访问频率场景下。

更关键的是内存压力响应:当 SizeLimit 被设置且缓存总 size 超限时,MemoryCache 会触发 LRU 清理,但该过程本身也是异步且非抢占式的。它不保证立即释放内存,也不通知调用方哪些项被踢出。

  • 不要依赖缓存项“准时消失”,业务逻辑需容忍短暂脏读
  • 设置 SizeLimit 后必须为每个 entry 指定 Size(通过 MemoryCacheEntryOptions.Size),否则限流无效
  • 监控缓存健康度可读取 MemoryCache.Statistics(需开启 options.TrackStatistics = true),但统计本身有轻微开销

自定义过期回调(PostEvictionCallbacks)可能丢失或延迟执行

通过 RegisterPostEvictionCallback 注册的回调函数,在缓存项被移除时触发。但文档明确说明:该回调不保证一定执行,也不保证执行顺序或线程上下文。尤其在进程退出、OOM 或快速批量驱逐时,回调可能被跳过。

典型误用是把回调当“可靠钩子”做资源释放(如关闭文件句柄、注销事件监听)。一旦回调丢失,就会泄漏资源。

  • 回调只适合轻量、可丢失的日志记录或指标上报
  • 关键资源清理必须在业务代码中显式控制,例如在 Set 缓存前先释放旧资源,或用 IDisposable 包装缓存值并在 Get 后手动 Dispose
  • 回调函数内避免长耗时操作,否则会阻塞缓存内部清理线程

缓存的线程安全边界很清晰,但高并发下的行为偏差往往来自对“原子性范围”和“惰性机制”的误判。最常被忽略的是 valueFactory 的执行时机和 PostEvictionCallback 的不可靠性——这两处不深挖文档,很容易在线上压测时突然暴露问题。

text=ZqhQzanResources