必须用cacheitempolicy显式传入removedcallback才能触发剔除回调,直接set(string,object)不会触发;回调在项被真正逐出时执行,且仅限同步轻量清理操作。

MemoryCache.Set 时如何注册剔除回调
必须用 CacheItemPolicy 显式传入回调,直接调用 Set(string, object) 重载是**不会触发**剔除回调的。这是最常踩的坑——以为设了过期时间就会自动清理资源,结果内存泄漏了。
实操要点:
-
CacheItemPolicy.RemovedCallback是唯一能捕获“被剔除”事件的入口,包括过期、主动Remove、内存压力触发的逐出 - 回调函数签名固定为
Action<cacheentryremovedarguments></cacheentryremovedarguments>,其中CacheEntryRemovedArguments.RemovedReason可区分剔除原因(Expired/Removed/ExpiredDueToSlidingExpiration等) - 务必在回调里做**同步、轻量**的清理:关闭文件句柄、释放
IDisposable对象、注销事件监听 —— 别在这里加 HTTP 调用或数据库写入
var policy = new CacheItemPolicy
{
AbsoluteExpiration = DateTimeOffset.Now.AddSeconds(30),
RemovedCallback = args =>
{
if (args.RemovedReason == CacheEntryRemovedReason.Expired)
{
(args.CacheItem.Value as IDisposable)?.Dispose();
}
}
};
cache.Set("key", new ExpensiveResource(), policy);
为什么 SlidingExpiration 下 RemovedCallback 可能不触发
滑动过期(SlidingExpiration)本身不导致剔除;只有当项**真正被逐出缓存时**才触发回调。而 MemoryCache 的默认逐出策略依赖内存压力,不是定时扫描 —— 所以即使过了滑动窗口,只要没被访问、也没触发 GC 或内存回收,项就一直挂着,回调永远不会来。
这意味着:
- 仅靠
SlidingExpiration+RemovedCallback无法保证资源在“闲置 N 秒后释放” - 若需严格时限释放(比如连接池、临时文件),必须搭配
AbsoluteExpiration,或自己启动后台任务定期检查cache.GetCount()和键的最后访问时间 -
CacheEntryRemovedReason在滑动过期场景下几乎总是Removed或Expired,但具体取决于触发机制,不能反推行为
回调中访问缓存项值的线程安全问题
RemovedCallback 在 MemoryCache 内部线程(通常是 ThreadPool 线程)中执行,且**发生在项已从缓存中移除之后**。此时通过 args.CacheItem.Value 拿到的是原始引用,但该对象可能正被其他线程使用 —— 如果你在这个回调里修改它或调用非线程安全方法,会出问题。
安全做法:
- 只做释放(
Dispose)、置空(Close())、注销(Unsubscribe())等幂等操作 - 避免在回调里重新写入缓存(
cache.Set(...)),这会引发不可预测的嵌套调用 - 如果值类型是自定义类,确保其
Dispose方法是线程安全的,或内部加锁保护共享状态
MemoryCache 默认不启用后台清理,过期项不会“自动消失”
很多人误以为设了 AbsoluteExpiration,过期那一刻项就没了。实际上 MemoryCache **只在 Get/Count/Set 等操作时惰性清理过期项**,没有独立的后台线程轮询。所以一个过期项可能在缓存里躺几个小时,直到有人访问它或触发内存回收。
这直接影响剔除回调时机:
- 回调不是“到期即发”,而是“到期后首次被清理时才发”
- 高并发读取下,多个线程可能同时发现同一项过期,但只有第一个完成移除的会触发回调(其余被忽略)
- 若缓存长期无人访问,过期项和它们的回调就永远卡住 —— 这是生产环境资源泄漏的典型源头
真要可靠释放,得自己加个 Timer 定期调用 cache.GetCount()(触发惰性清理)或遍历 cache.ToList() 主动检查过期时间 —— 但注意后者性能差,慎用。











