
最近手头几个项目都有同一个需求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 ConcurrentDictionarystring, 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 ConcurrentDictionarystring, 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 SetT(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 GetT(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 SetT(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 GetT(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 GetOrCreateT(string key, FuncT factory, TimeSpan expire, bool sliding false) { var cached GetT(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 GetOrCreateT(string key, FuncT factory, TimeSpan expire, bool sliding false) { var cached GetT(key); if (cached ! null) { return cached; } lock (_syncRoot) { cached GetT(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 stateHttpApplicationState是一开始很多人喜欢用但它有几个问题内部存储基本是 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 回调中访问已释放的缓存对象如果业务代码主动调用了缓存对象的 DisposeTimer 仍然可能在后台触发。此时再访问 _items 或者在回调里调用方法可能会抛 ObjectDisposedException。我的处理方式是加一个 volatile bool _disposed 标记在 Dispose 里设置为 trueTimer 回调开头先判断这个标记为 true 就直接返回。虽然 System.Threading.Timer 的 Dispose 本身可以等待回调完成但加一个标记更稳避免极端情况下回调中抛异常导致应用池崩溃。5.4 缓存“穿透”问题当某些 key 对应的值确实不存在时比如用户查询一个不存在的订单每次都走一遍数据库加载这就是缓存穿透。常规做法是把 null 也作为缓存值存进去这样下次查询就直接命中缓存返回 null不再打数据库。我们的 SimpleCache 是支持缓存 null 的因为 CacheItem.Value 是 object 类型存 null 没问题。但 Get 方法返回类型是泛型如果值本身就是 null你不会知道是“缓存未命中”还是“缓存了 null”。所以我在 GetOrCreate 的实现里用的是先 Get 再判断如果 factory 返回 nullSet 就把 null 存进去下次 Get 返回 nullGetOrCreate 又认为没命中再次执行 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实现真正的一次性懒加载。这些都是可以在现有结构上逐步加不用推倒重来。按我个人经验这种组件的核心价值从来不是“替代官方缓存”而是给你一个完全可控的、简单可靠的基础设施。如果你遇到的情况恰恰也是“够了别多加”这套精简方案值得直接拿去用。