☰
自研C#轻量级缓存:核心代码、过期策略与WinForms/MVC接入
2026/10/13 8:54:58 网站建设 项目流程

最近手头几个项目都有同一个需求:WinForms 工具、ASP.NET MVC 站点,都要一个本地缓存。翻来覆去比较了几轮方案,最后还是决定自己写一个精简的本地缓存类。不是官方的东西不好,而是很多场景真的要不了那么重,我就想要一个体积小、无依赖、行为可控的缓存组件,拷进项目就能用。

这篇文章就把我封装这套缓存的核心思路、完整代码、接入方式和踩过的坑都摊开讲。内容围绕 C# 实现展开,涵盖 WinForms 单机程序和 ASP.NET / MVC 服务端两类场景,适合那些不想引大框架、只需要内存缓存的中小型项目参考。

1. 为什么这个场景下我选择了自研缓存

先说说背景,不是所有缓存需求都需要上框架。

官方自带的内存缓存 MemoryCache 功能确实全:支持过期、回调、区域、驱逐策略、计数器,还能配多个独立的缓存实例。但正因为功能全,它也有一些让中小项目难受的地方。首先是 API 相对笨重,对泛型支持不够直接,每次取值都要处理 object 和类型转换。其次是它引入了一些后台调度和配置项,在小工具、小型站点里属于杀鸡用牛刀。更关键的一点是,MemoryCache 的行为有一部分是“黑盒”的,它在内存压力下会主动逐出缓存项,这本身是好事,但如果你需要非常精确地控制缓存的存活时间和清理时机,就得花时间研究它的策略配置。

另一个常见方案是 Dictionary + lock。这个方案能解决问题,但有个隐患:业务代码里到处出现 lock 包 Dictionary 的写法,读多写少的时候锁竞争不明显,一旦并发写多,性能下降非常快,而且代码很难看。ConcurrentDictionary 可以省掉大部分锁,但它只解决并发访问的问题,不解决过期、淘汰、容量控制的问题。

所以我把目标定得很明确:一个类,泛型方法,支持绝对过期和滑动过期,线程安全,定时清理过期项,可选容量上限,不依赖任何第三方包。满足这些需求就够了。自己封装的好处是行为完全透明,每行代码都知道在干嘛,出了问题不用去翻框架源码或查文档。对工具类项目来说,这种可控性比功能堆砌更重要。

基于这个思路,我最终选择了 ConcurrentDictionary 作为底层存储,配合 System.Threading.Timer 做定期清理,用惰性检查兜底。这样的组合能在“代码量”和“并发安全性”之间取得一个平衡点,下文会详细展开每一块的设计理由。

2. 核心技术设计拆解

2.1 并发容器选型:为什么必须是 ConcurrentDictionary

我先对比过几种候选方案。普通的 Dictionary 需要自己加锁,这在简单场景下没有大问题,比如只有一个线程写、多个线程读。但缓存这东西天然就是多线程使用的,尤其在 ASP.NET / MVC 环境里,十几个请求同时进来读同一个键,还可能有几个请求同时写入新键,这时候锁的粒度稍微没控制好,就会出现性能瓶颈或者鬼畜的并发 bug。Hashtable 倒是线程安全的,但它的同步机制是给整张表加锁,并发一上来直接退化成一个串行队列,所以也不合适。

ConcurrentDictionary 是 .NET 4.0 之后提供的并发集合,内部采用分段锁(.NET Core 之后又改进了实现),读操作基本无锁,写操作锁粒度精细。对于缓存这种“读远多于写”的场景,它的表现非常合适。同时它提供了 TryGetValue、TryAdd、TryRemove 这些原子操作,配合 GetOrAdd 这种工厂方法,可以在不写 lock 的情况下完成大部分操作。

有一点要注意,ConcurrentDictionary 的 Count 是近似值,GetOrAdd 的 valueFactory 也可能会被多次调用。也就是说,在高并发下调用 GetOrAdd 时,同一个 key 的 factory 可能同时执行多次。这个行为在缓存场景里很关键,后面讲 GetOrCreate 方法时我会专门处理。

2.2 过期策略:绝对过期和滑动过期各有用武之地

缓存的过期方式本质就两种:绝对过期和滑动过期。绝对过期是指缓存项在固定的时间点失效,不管你怎么访问它,到点就没了。适合用来缓存变化周期明确的配置、字典数据。比如一分钟才允许刷新一次的配置表,设置 60 秒绝对过期,时间一到就重新拉取,这一分钟内所有请求都打缓存,效果很好。

滑动过期是指每次访问缓存项后,把它的过期时间往后顺延。只要在窗口期内持续有访问,这个缓存项就一直存活。适合用来缓存用户会话、访问频率高的临时数据。比如一个在线用户的状态信息,只要用户一直操作,缓存就一直有效;一旦用户超过 5 分钟没有动作,缓存项自然过期,下次再来就重新加载。

实现上,缓存项内部需要记录三个时间点:创建时间(或最近访问时间)、过期时刻。绝对过期的话,过期时刻在写入时就定死;滑动过期的话,每次读取命中时刷新过期时刻。这个逻辑在 Get 方法的路径上完成,不需要额外线程参与。

需要注意,DateTime.Now 和 DateTime.UtcNow 不要混用,否则在某些业务里会出现诡异的“缓存提前过期一小时”问题。DateTime.Now 依赖服务器本地时间,跨时区部署时行为不一致;DateTime.UtcNow 是全球统一时钟。缓存内部统一用 DateTime.UtcNow 存时间戳,只在对外暴露的接口里维持语义一致即可。

2.3 清理机制:Timer 定期扫描加惰性检查双保险

有没有必要每次读缓存时都检查一遍全局过期项?没必要。每次读只能检查当前命中的那个 key,全局过期项如果不主动清理,就会一直占着内存。所以清理过期项需要一个后台机制。

我直接用 System.Threading.Timer,每隔一段时间扫描一次字典,把已过期的项移除。这个间隔不需要太长,我一般建议 1 分钟到 5 分钟之间。间隔太短,扫描频繁,白白消耗 CPU;间隔太长,过期项会多占用一些内存,心理上会不舒服。我自己习惯设置 1 分钟,因为扫描本身的开销很低,只遍历字典做时间比较,几百上千个缓存项一毫秒内就能扫完。

但要明白一点,Timer 扫描是一个兜底手段,不是唯一手段。Get 命中时也要做惰性检查:如果当前时间已经超过过期时刻,直接当作未命中处理,同时顺手把这个 key 移除。这样即使 Timer 还没跑过,过期项也不会被业务读到。这套双保险机制非常关键,缺少哪一个都可能出问题。

清掉过期项之后如果缓存项数量仍然超过容量上限,就需要做淘汰。简单方案是扫描时找最近访问时间最早的那个 key 移除,直到数量降到上限以下。这个方案等价于一个比较粗糙的 LRU 驱逐策略,对于“精简”这个目标来说够用了。精确的 LRU 要维护访问链表,代码量会翻倍,我这里不做。

3. 完整实现与逐段代码解读

先看缓存项的内部结构。这个类作为私有嵌套类型放在缓存类内部,外部完全不需要知道它存在。

public class SimpleCache { private class CacheItem { public object Value { get; set; } public DateTime ExpireTime { get; set; } public bool IsSliding { get; set; } public DateTime LastAccessTime { get; set; } } private readonly ConcurrentDictionary<string, CacheItem> _items; private readonly TimeSpan _cleanInterval; private readonly int _maxItems; private readonly Timer _timer; private readonly object _syncRoot = new object(); private bool _disposed; public int Count => _items.Count; }

CacheItem 里存 object 而不是泛型,是为了让同一个缓存容器可以装各种不同类型的数据。整数、字符串、自定义对象、List,都可以放进去。类型安全问题由泛型方法在取值时保证。

构造函数如下:

public SimpleCache(TimeSpan? cleanInterval = null, int maxItems = 10000) { _items = new ConcurrentDictionary<string, CacheItem>(); _cleanInterval = cleanInterval ?? TimeSpan.FromMinutes(1); _maxItems = maxItems > 0 ? maxItems : 10000; _timer = new Timer(OnCleanupTimer, null, _cleanInterval, _cleanInterval); }

这里我把清理间隔和容量上限做成可配置参数,并提供默认值。为什么用 Timer 而不是 Task.Delay 循环?Timer 更省资源,回调在独立的线程池线程上执行,不额外占用业务线程。回调里只做清理工作,不触碰 UI 线程。

核心的 Set、Get 方法:

public void Set<T>(string key, T value, TimeSpan expire, bool sliding = false) { if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); if (expire <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(expire)); var now = DateTime.UtcNow; var item = new CacheItem { Value = value, IsSliding = sliding, LastAccessTime = now, ExpireTime = sliding ? now.Add(expire) : now.Add(expire) }; _items[key] = item; if (_items.Count > _maxItems) { Cleanup(true); } }

有个细节值得留意:滑动过期项在写入时,ExpireTime 同样是当前时间加上窗口期。这个初始值等于“如果写入后一直没人读,它在窗口期后就会过期”。而绝对过期项也是同样赋值。两类项在写入时刻的表现一致,差别在选择路径上。

Get 方法:

public T Get<T>(string key) { if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); if (!_items.TryGetValue(key, out var item)) { return default; } var now = DateTime.UtcNow; if (now > item.ExpireTime) { _items.TryRemove(key, out _); return default; } if (item.IsSliding) { item.ExpireTime = now.Add(item.ExpireTime - item.LastAccessTime); item.LastAccessTime = now; } if (item.Value is T typedValue) { return typedValue; } _items.TryRemove(key, out _); return default; }

滑动过期的计算逻辑我写成了item.ExpireTime = now.Add(item.ExpireTime - item.LastAccessTime);。这个写法其实等价于“当前时间加上原始窗口期”,注意这里有一个坑:如果你搞混了窗口期和剩余时间,滑动过期会变成每次访问只延长一小段时间,最后越缩越短。这里我建议直接缓存一个明确的窗口期字段而不是通过 ExpireTime 和 LastAccessTime 反推。为避免给读者留下隐患,下面我给出更直观的写法:在 CacheItem 中加入 WindowTime 字段,滑动过期时直接item.ExpireTime = now.Add(item.WindowTime);。下面这个版本更清晰,不会出现结构巧妙但容易失手的计算。

private class CacheItem { public object Value { get; set; } public DateTime ExpireTime { get; set; } public bool IsSliding { get; set; } public DateTime LastAccessTime { get; set; } public TimeSpan WindowTime { get; set; } } public void Set<T>(string key, T value, TimeSpan expire, bool sliding = false) { if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); if (expire <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(expire)); var now = DateTime.UtcNow; var item = new CacheItem { Value = value, IsSliding = sliding, WindowTime = expire, LastAccessTime = now, ExpireTime = now.Add(expire) }; _items[key] = item; if (_items.Count > _maxItems) { Cleanup(true); } } public T Get<T>(string key) { if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); if (!_items.TryGetValue(key, out var item)) { return default; } var now = DateTime.UtcNow; if (now > item.ExpireTime) { _items.TryRemove(key, out _); return default; } if (item.IsSliding) { item.ExpireTime = now.Add(item.WindowTime); item.LastAccessTime = now; } if (item.Value is T typedValue) { return typedValue; } _items.TryRemove(key, out _); return default; }

注意 Get 方法里的类型判断。如果我写入时用的是 string,读取时却用 int 去接,直接强转会抛 InvalidCastException。对于缓存这种“低信任”场景,我更倾向于返回 default 并把缓存项移除,让调用方感知到“这个缓存不可用”,而不是让异常直接爆到业务上层。真实的业务里,同一个 key 写入不同类型通常意味着上游代码出 bug 了,静默失败比崩溃更安全,因为至少不会因为异常导致整个请求 500。

Remove 和 Clear 就是字面意思:

public void Remove(string key) { _items.TryRemove(key, out _); } public void Clear() { _items.Clear(); }

GetOrCreate 是这个封装里最实用的方法。业务里大量场景是:查缓存,没有就加载数据源再写入缓存。每次写 if 判断太繁琐,直接封装成一句话最好。

public T GetOrCreate<T>(string key, Func<T> factory, TimeSpan expire, bool sliding = false) { var cached = Get<T>(key); if (cached != null) { return cached; } T newValue = factory(); Set(key, newValue, expire, sliding); return newValue; }

注意这个方法的双检锁问题。ConcurrentDictionary.GetOrAdd 有一个坑,就是高并发时 valueFactory 可能被多次执行。比如两个线程同时调用 GetOrAdd,底层可能让两个 factory 都跑一遍,最后只保留一个结果。如果你的 factory 代价很高(比如查数据库),这个行为就是浪费。所以我这里先用 Get 拿一次,拿不到再 factory。但两个并发同时走进去之后都会执行 factory,这一点无法靠 ConcurrentDictionary 完全避免,所以我加了一个小锁来保证 factory 只执行一次:

public T GetOrCreate<T>(string key, Func<T> factory, TimeSpan expire, bool sliding = false) { var cached = Get<T>(key); if (cached != null) { return cached; } lock (_syncRoot) { cached = Get<T>(key); if (cached != null) { return cached; } T newValue = factory(); Set(key, newValue, expire, sliding); return newValue; } }

这个锁只在未命中的瞬间触发,命中时完全没有锁开销,对性能影响可以忽略。用 .NET 内置的 Lazy 也可以实现类似效果,但代码复杂度会上升,不够“精简”。

最后是定时清理的核心逻辑:

private void OnCleanupTimer(object state) { Cleanup(false); } private void Cleanup(bool force) { var now = DateTime.UtcNow; foreach (var pair in _items) { if (now > pair.Value.ExpireTime) { _items.TryRemove(pair.Key, out _); } } if (force && _items.Count > _maxItems) { var overflow = _items.Count - _maxItems; var staleItems = _items .OrderBy(p => p.Value.LastAccessTime) .Take(overflow + 10) .Select(p => p.Key) .ToList(); foreach (var key in staleItems) { _items.TryRemove(key, out _); if (_items.Count <= _maxItems) { break; } } } }

需要特别说明的是,ConcurrentDictionary 的枚举操作在并发环境下返回的是弱一致快照,也就是说枚举过程中其他线程修改了集合,不会导致异常。这正好符合我们清理线程的执行场景:一边遍历一边尝试移除,每一项的 TryRemove 都是原子操作,安全。OrderBy 排序在缓存项数量很大(几万以上)时会有性能开销,但清理本身的触发频率很低,可以接受。如果你已经预估会塞几十万个缓存项,这种简单实现就不太够用了,应该考虑更精细的 LRU 结构。

4. WinForms 和 ASP.NET MVC 的落地接入方式

4.1 WinForms 工具里的用法:静态实例还是实例字段

WinForms 程序大多是单用户单窗口的应用,但缓存的作用一样重要。我做过一个数据导入工具,需要在多个窗体之间反复读取配置和临时计算结果,每次重复计算太慢,放缓存里就快多了。

WinForms 接入时,我建议直接用一个静态只读实例,这样可以做到全窗体共享,不用到处传引用:

public partial class MainForm : Form { private static readonly SimpleCache Cache = new SimpleCache( TimeSpan.FromMinutes(5), 5000 ); private void LoadConfig() { var config = Cache.GetOrCreate("AppConfig", LoadConfigFromFile, TimeSpan.FromMinutes(30)); // 使用 config } }

Set、Get、GetOrCreate 都不会访问 UI 控件,所以不存在跨线程访问控件的问题。Timer 清理线程只操作 ConcurrentDictionary,不触碰任何控件,这个设计让我的 WinForms 程序从未为缓存相关的跨线程问题操心过。

在 WinForms 里用滑动过期特别适合缓存“用户输入中间态”。比如一个向导式工具,用户在第一步填了些内容,切到第二步再返回来,希望内容还在。直接退出界面就清掉缓存,5 分钟内反复切换都保持状态,这就是滑动过期最典型的应用场景。

4.2 ASP.NET / MVC 中作为单例使用

ASP.NET 跟 WinForms 的最大差别在于:MVC 的 Controller 是有状态的,每次 HTTP 请求都会 new 一个 controller 实例,所以你不能把缓存放在 controller 的实例字段里,那样每个请求都会创建一份,缓存就名存实亡了。正确做法是把缓存放在应用程序级,用 static 字段或者 Application 状态保存。

Application state(HttpApplicationState)是一开始很多人喜欢用,但它有几个问题:内部存储基本是 object 类型,读写要自己做类型转换和加锁;而且在应用池回收时,存储内容不保证稳定;用多了还会跟业务状态混在一起,整个全局状态变得难以维护。相比之下,static readonly 字段天然是 AppDomain 级的单例,配合我们的 SimpleCache 刚好。

在 Global.asax 的 Application_Start 里初始化一次也可以,但更省事的做法是直接定义一个静态只读字段,编译器保证懒加载和线程安全:

public class CacheProvider { public static readonly SimpleCache SiteCache = new SimpleCache( TimeSpan.FromMinutes(2), 20000 ); }

然后在 Controller 里直接用:

public class UserController : Controller { public ActionResult GetProfile(int userId) { var profile = CacheProvider.SiteCache.GetOrCreate( $"UserProfile:{userId}", () => LoadProfileFromDb(userId), TimeSpan.FromMinutes(5), true ); return Json(profile); } }

注意几个 ASP.NET 环境特有的问题。

第一是键名冲突。直接用字符串拼接 userId 做键,如果还有其他地方用不同对象做键,很容易撞车。建议加上前缀,比如UserProfile:123、Order:456。这套命名空间机制虽然原始,但在精简缓存里足够用了。

第二是内存回收问题。IIS 的应用池可能因为空闲、配置、内存压力等原因回收。一旦回收,static 字段存储的缓存数据全部丢失,这是正常现象,不是 bug。如果你缓存的是用户会话这种临时数据,完全不用在意;但如果是持续时间较长的全局配置数据,就需要在缓存失效后能自动从原始数据源重新加载。我们提供的 GetOrCreate 正好满足这个要求,缓存丢了自动重建。

第三是不要在缓存里放 DbContext 或数据库连接等重量级对象。这类对象生命周期短,且往往不是线程安全的,缓存这些会造成连接泄漏、线程安全问题。缓存应该只存不可变的数据对象或者已完成的计算结果。

5. 实测中避开的坑与问题排查记录

5.1 滑动过期计算的隐蔽错误

我最初实现滑动过期时,在 Get 里写的是item.ExpireTime = DateTime.UtcNow.Add(item.ExpireTime - item.LastAccessTime);。表面上看没问题,但稍不留意就会引入一个逻辑错误:如果外界传入的窗口期和实际经过的时间不是一回事,这个“剩余时间”会被当成新的滑动窗口,导致缓存项在总寿命上逐渐缩短,极端情况下永远都不会过期,除非你再移除它。后来我直接把窗口期存成显式字段才一眼看清。

这提醒我一件事:缓存这种代码,越是看起来“聪明”的写法越容易埋雷。清晰直白比花哨更重要。

5.2 Get 和 Set 的 key 为空问题

如果 key 是 null 或空字符串,ConcurrentDictionary 会抛 ArgumentNullException。这个行为本身没问题,但如果你在 ASP.NET 环境里接到的请求参数是 null,没检查就直接让异常抛出去,用户会看到一个 500 错误。我在 Set 和 Get 的开头都做了参数校验,抛出的异常信息更明确。实际项目中你还需要在 Controller 层做参数校验,不能让空 key 一路走到缓存层。

5.3 Timer 回调中访问已释放的缓存对象

如果业务代码主动调用了缓存对象的 Dispose,Timer 仍然可能在后台触发。此时再访问 _items 或者在回调里调用方法,可能会抛 ObjectDisposedException。我的处理方式是加一个 volatile bool _disposed 标记,在 Dispose 里设置为 true,Timer 回调开头先判断这个标记,为 true 就直接返回。虽然 System.Threading.Timer 的 Dispose 本身可以等待回调完成,但加一个标记更稳,避免极端情况下回调中抛异常导致应用池崩溃。

5.4 缓存“穿透”问题

当某些 key 对应的值确实不存在时,比如用户查询一个不存在的订单,每次都走一遍数据库加载,这就是缓存穿透。常规做法是把 null 也作为缓存值存进去,这样下次查询就直接命中缓存返回 null,不再打数据库。我们的 SimpleCache 是支持缓存 null 的,因为 CacheItem.Value 是 object 类型,存 null 没问题。但 Get 方法返回类型是泛型,如果值本身就是 null,你不会知道是“缓存未命中”还是“缓存了 null”。所以我在 GetOrCreate 的实现里用的是先 Get 再判断,如果 factory 返回 null,Set 就把 null 存进去;下次 Get 返回 null,GetOrCreate 又认为没命中,再次执行 factory。这会导致你明明缓存了 null,还是会穿透。

解决这个问题的办法是:GetOrCreate 内部判断不能只看返回值是否为 null,应该先看 key 是否存在。可以提供一个 ContainsKey 方法,或者让 GetOrCreate 的 factory 传入一个特殊标记。这个方法会增加一点代码量,但处理“需要缓存空结果”的业务是值得的。如果你确认自己的业务不会有 null 值存在,那直接忽略这个坑就行。

5.5 缓存项数量失控

_set 的时候如果超过 _maxItems 会触发 Cleanup(true)。但 Cleanup 里做减法的方式是先清过期项,过期项清了还不够再按 LastAccessTime 排序移除。这里有一个性能隐患:如果写入速度极快,而 _maxItems 又很大(比如两万),每次 Set 都触发一次排序清理,会拖慢写入性能。我后来把清理策略改成了“只在超出 10% 时才开启扣减清理”,即_items.Count > _maxItems * 1.1才排序。这个改动在内存占用和性能之间找到了一个更舒适的平衡点。

实际项目里,“写缓存的速度远超缓存过期速度”本身就是一种配置不合理。缓存容量设计应该根据数据总量、对象大小、内存空间来评估,而不是默认填一个很大的数字。如果你预估配置数据有 2000 条,就设置 _maxItems 为 5000 而不是 50000。这一点在 ASP.NET 环境里尤为重要,服务端内存是宝贵资源,缓存失控会让整个应用的内存占用居高不下。

6. 一些使用建议和后续扩展方向

这套缓存目前已经在我个人的几个小项目里跑过一段时间了。WinForms 工具里缓存界面配置和权限项,MVC 站点里缓存用户资料和下拉框数据,效果都比较稳定。最让我舒服的一点是:它没有黑盒逻辑,出问题可以直接打开类看代码,哪里逻辑不对一目了然。

如果后续有更复杂的需求,这个类的扩展路径也很清晰。比如增加缓存项移除回调,让业务在缓存项被清理时收到通知,可以用来释放非托管资源。再比如支持按前缀批量移除,需要遍历 key 前缀匹配后逐个移除,实现起来不复杂。或者你想要更强的并发安全保证,可以在 GetOrCreate 里改用 Lazy 包裹 factory,实现真正的一次性懒加载。这些都是可以在现有结构上逐步加,不用推倒重来。

按我个人经验,这种组件的核心价值从来不是“替代官方缓存”,而是给你一个完全可控的、简单可靠的基础设施。如果你遇到的情况恰恰也是“够了别多加”,这套精简方案值得直接拿去用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询