缓存是提升系统性能最直接的手段之一。在C#的ASP.NET Core生态里,微软提供了内置的内存缓存组件IMemoryCache,它把数据存储在当前进程的内存中,读写速度极快,不需要额外部署Redis这类分布式缓存服务,非常适合单实例部署的应用或者作为分布式缓存的前置缓存层。这篇文章会从零开始讲解IMemoryCache的注册、读写、过期策略,以及实际项目中容易踩到的坑。

一、注册与基本使用
使用IMemoryCache的第一步是在依赖注入容器中注册内存缓存服务。在Program.cs中加入一行代码即可:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddMemoryCache(); var app = builder.Build(); app.Run();
注册完成后,就可以在任何通过DI创建的类中注入IMemoryCache接口。它最核心的几个方法是Set、Get、TryGetValue和Remove。看一个基础的例子:
public class ProductService
{
private readonly IMemoryCache _cache;
public ProductService(IMemoryCache cache)
{
_cache = cache;
}
public Product? GetProduct(int id)
{
// 尝试从缓存获取
if (_cache.TryGetValue($"product_{id}", out Product? product))
{
return product;
}
// 缓存未命中,从数据库查询
product = QueryFromDb(id);
// 写入缓存,60秒后过期
_cache.Set($"product_{id}", product, TimeSpan.FromSeconds(60));
return product;
}
public void RemoveProduct(int id)
{
_cache.Remove($"product_{id}");
}
private Product? QueryFromDb(int id) { /* 数据库查询逻辑 */ return new Product(); }
}除了这种手动判断的写法,还有一个更简洁的API叫GetOrCreate,它把"查缓存、未命中则执行工厂方法、写入缓存"三步合并成了一次调用,代码可读性明显更好:
public Product GetProduct(int id)
{
return _cache.GetOrCreate($"product_{id}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return QueryFromDb(id);
})!;
}需要注意一点:IMemoryCache存储的是对象的引用而不是副本(对于引用类型而言)。也就是说,你从缓存里取出来一个对象并修改了它的属性,缓存中的对象也会跟着变,因为它们是同一个实例。如果多个线程同时拿到这个对象并发修改,就可能产生数据错乱。稳妥的做法是取出来之后做一次深拷贝,或者把缓存的对象设计成不可变对象。
二、三种过期策略的区别与选择
IMemoryCache的过期机制是很多人容易混淆的地方,它支持绝对过期、滑动过期以及两者组合三种方式,行为差异很大,直接选错可能导致缓存失效或数据过期不更新。
绝对过期(AbsoluteExpiration)是指从设定时刻起,经过固定时间后缓存必定失效,无论期间有没有被访问。适合数据一致性要求较高的场景,比如商品库存、活动配置这类数据,即使访问频繁也必须定期回源刷新。
_cache.Set("key", value, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});滑动过期(SlidingExpiration)则相反,只要缓存条目持续被访问,过期时间就会不断向后顺延;只有连续一段时间没人访问,它才会被清除。典型的应用是用户会话数据,活跃用户的会话一直保活,不活跃的用户数据自动清理,节省内存。
_cache.Set("key", value, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(5)
});两者组合使用时,滑动过期生效的前提是不超过绝对过期的上限。比如设置滑动5分钟、绝对30分钟,那么即使条目每分钟都被访问,30分钟后也一定会过期。另外还有Size属性可以限制单个条目占用的内存大小,配合注册时的SizeLimit做整体内存控制,避免缓存无限膨胀把进程撑爆。还有一个细节值得知道:过期条目并不是到点立即删除的,内部有个后台扫描机制定期清理,所以在Get一个刚过期的key时才会触发实际移除,这在高内存压力下表现会更明显。
三、缓存穿透与雪崩的应对
缓存穿透是指查询一个数据库里根本不存在的数据,由于缓存中永远不会有这条记录,每次请求都会打到数据库。如果有人恶意构造大量不存在的ID发起请求,数据库压力会瞬间飙升。应对方式是缓存空值:
public Product? GetProduct(int id)
{
if (_cache.TryGetValue($"product_{id}", out Product? cached))
{
// 缓存了空对象,说明数据库中确实没有这条数据
return cached;
}
var product = QueryFromDb(id);
if (product is null)
{
// 数据库不存在也写入缓存,短时间过期,防止穿透
_cache.Set($"product_{id}", (Product?)null, TimeSpan.FromSeconds(30));
return null;
}
_cache.Set($"product_{id}", product, TimeSpan.FromMinutes(10));
return product;
}缓存雪崩是指大量缓存条目在同一时刻集中过期,请求全部涌向数据库。预防办法很简单:在基础过期时间上加一个随机偏移量,把过期时间打散。比如原本都是10分钟过期,改成10分钟加上0到3分钟的随机值,各条目的过期时间就错开了。
另外要提醒的是,内存缓存是进程级别的。如果应用部署了多个实例(比如容器编排扩容到多个Pod),每个实例的缓存相互独立,可能出现数据不一致的情况。这种场景下要么接受短暂不一致,要么改用Redis这类分布式缓存,要么在数据变更时通过消息广播通知所有实例清理各自缓存。单实例应用则完全不用担心这个问题,IMemoryCache就是最优解。
四、进阶用法:主动失效与缓存依赖
除了靠时间过期,IMemoryCache还支持主动让缓存失效。最直接的就是在数据发生变更时调用Remove方法删除对应条目,下次查询会自动回源重建。对于用户提交修改后台立即生效的场景,这种做法比等待过期可靠得多。
更进一步,可以利用RegisterPostEvictionCallback注册条目被移除后的回调,做一些日志记录或联动清理:
var options = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10))
.RegisterPostEvictionCallback((key, value, reason, state) =>
{
Console.WriteLine($"缓存条目 {key} 被移除,原因:{reason}");
});
_cache.Set("key", data, options);还可以通过ExpirationTokens挂载CancellationToken实现缓存依赖,当Token被触发时缓存立即失效。这在配合文件监听、数据库变更通知等场景非常有用,比如配置文件一改动,相关缓存马上作废重建,业务代码完全无感知。
最后总结一下使用原则:读多写少的数据优先缓存,过期时间结合业务容忍度设置,热点数据加上随机过期防止雪崩,不存在的数据缓存空值防止穿透,多实例部署时评估是否需要分布式缓存。掌握这些,IMemoryCache基本就能覆盖日常开发中百分之九十的缓存需求了。
C#内存缓存IMemoryCache.NET缓存修改时间:2026-09-07 22:46:35