專案筆記

Dictionary、MemoryCache、HttpRuntime.Cache:三種快取差在生命週期,不是差在效能

2026-03-19 #.NET#C##快取#MemoryCache

上一篇把批次程式裡的參考資料改成 Lazy<Dictionary<K,V>>,效果很好。但那個做法有個沒講清楚的前提:它只在「行程活不久、資料不會變」的情況下成立。

換到長駐的 Web 應用,同一套寫法會變成「設定改了要重啟才生效」。這時候要換工具。

這篇把 .NET Framework 上三種常見的快取放在一起比:自建 Dictionary、System.Runtime.Caching.MemoryCache、以及 ASP.NET 的 HttpRuntime.Cache。結論先講:它們的差別幾乎不在查找效能,全都是 O(1) 雜湊查找;差在誰決定資料什麼時候消失。

後半段會拆一份真實的 MemoryCache 包裝類別——它有四個缺陷,其中兩個相乘之後會讓快取實質上永不更新。

一、三者定位

自建 Dictionary / HashSet MemoryCache HttpRuntime.Cache
命名空間 System.Collections.Generic System.Runtime.Caching System.Web.Caching
相依 System.Web 否 否 是
存活範圍 持有它的物件 / AppDomain AppDomain,可多實例 AppDomain,單一實例
過期機制 無 絕對 / 滑動 / 變更監視 絕對 / 滑動 / 相依性
記憶體壓力驅逐 無 有 有
執行緒安全 否(要自己包) 是 是
可存 null 可以 不行 不行

微軟官方對 MemoryCache 的定位講得很直白:它和 ASP.NET 的 Cache 很像,主要差別是改成讓非 ASP.NET 的 .NET Framework 應用也能用,不再相依 System.Web;另外它可以在同一個 AppDomain 裡建立多個實例(MemoryCache Class)。

換句話說,HttpRuntime.Cache 是歷史包袱,MemoryCache 是它的通用化後繼者。新寫的程式沒有理由選 HttpRuntime.Cache,除非你要用它獨有的 CacheDependency(例如綁 SQL Server 的資料變更通知)而又不想換寫法。

二、真正的選型依據:誰決定資料什麼時候消失

三種快取的查找都是雜湊,效能差異在多數情境下量不出來。真正要問的是這三題:

flowchart TB
    Q1{行程會活很久嗎}
    Q1 -->|否,跑完就結束| D1[自建 Dictionary / Lazy]
    Q1 -->|是| Q2{資料在執行期間會變嗎}
    Q2 -->|不會| D1
    Q2 -->|會| Q3{需要在變更當下失效
還是可以等過期} Q3 -->|等過期就好| D2[MemoryCache + 絕對/滑動過期] Q3 -->|要立即失效| D3[MemoryCache + 主動 Remove
或 ChangeMonitor]

自建 Dictionary 之所以在批次程式裡夠用,不是因為它快,是因為行程幾分鐘後就結束了,過期機制毫無意義。同一份程式碼放進 IIS,這個前提立刻不成立。

反過來,MemoryCache 也有它的代價,而且很容易被忽略:它會在記憶體壓力下主動驅逐項目。CacheMemoryLimitMegabytes 和 PhysicalMemoryLimitPercentage 預設是 0,代表使用自動調整的啟發式,執行期每隔一段時間(PollingInterval 預設兩分鐘)比對一次記憶體負載(memoryCache 設定元素)。

所以用 MemoryCache 的程式碼必須接受一件事:你放進去的東西,隨時可能不在了。每個讀取點都要能自己補回來:

1
2
3
4
5
6
7
var data = cache.Get(key) as List<Item>;
if (data == null) // ← 這個分支不是防呆,是常態路徑
{
data = LoadFromDb();
cache.Set(key, data);
}
return data;

自建 Dictionary 沒有這個問題(沒人會拿走你的東西),但也因此沒有記憶體上限。兩邊各有各的死法。

三、一份真實的包裝類別,和它的四個缺陷

以下是我在一個 ASP.NET MVC 專案裡看到的快取包裝,只改了命名,邏輯原封不動:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class DefaultCacheProvider : ICacheProvider
{
private ObjectCache Cache { get { return MemoryCache.Default; } }

public object Get(string key) => Cache[key];

public void Set(string key, object data)
{
CacheItemPolicy policy = new CacheItemPolicy();
int cacheTime = 1800;
if (ConfigurationManager.AppSettings["SessionTimeOut"] != null)
{
cacheTime = int.Parse(ConfigurationManager.AppSettings["SessionTimeOut"]) * 60;
}

policy.AbsoluteExpiration = DateTime.Now + TimeSpan.FromMinutes(cacheTime);
Cache.Add(new CacheItem(key, data), policy);
}

public void ClearAll()
{
List<string> cacheKeys = Cache.Select(kvp => kvp.Key).ToList();
foreach (string cacheKey in cacheKeys)
Cache.Remove(cacheKey);
}
}

看起來很正常。四個問題。

缺陷一:Add 不會覆蓋既有項目

1
Cache.Add(new CacheItem(key, data), policy);

ObjectCache.Add 的官方定義是:嘗試插入,但不覆蓋或移除同 key 的既有項目;回傳 true 代表插入成功,false 代表已經有同 key 的項目存在(ObjectCache.Add)。

而 Set 才是 insert-or-update:不存在就新增,存在就更新(MemoryCache.Set)。

這裡的方法叫 Set,裡面呼叫的卻是 Add,而且回傳值直接丟掉。結果是:第一次寫入成功,之後對同一個 key 的每一次「更新」都靜默失敗,沒有例外、沒有 log。呼叫端會以為自己更新了快取。

正確寫法只有一個字的差別:

1
Cache.Set(new CacheItem(key, data), policy);

缺陷二:時間單位錯了 60 倍

1
2
3
int cacheTime = 1800;                                    // 看起來是 1800 秒 = 30 分鐘
cacheTime = int.Parse(設定值) * 60; // 分鐘 × 60 → 換算成秒
policy.AbsoluteExpiration = DateTime.Now + TimeSpan.FromMinutes(cacheTime); // ← 卻用 FromMinutes

前兩行都在算「秒」,最後一行用 FromMinutes 把它當成「分鐘」。

  • 沒設定時:FromMinutes(1800) = 30 小時(本意 30 分鐘)
  • 設定 30 分鐘時:30 × 60 = 1800 → 一樣 30 小時

單位在變數名稱裡完全沒有體現,cacheTime 這個名字不帶單位,是這個 bug 能活下來的原因。改法是讓型別自己說話:

1
2
3
4
5
TimeSpan ttl = TimeSpan.FromMinutes(30);
if (ConfigurationManager.AppSettings["SessionTimeOut"] != null)
ttl = TimeSpan.FromMinutes(int.Parse(ConfigurationManager.AppSettings["SessionTimeOut"]));

policy.AbsoluteExpiration = DateTimeOffset.Now + ttl;

變數型別用 TimeSpan 而不是 int,單位錯誤就變成編譯錯誤而不是執行期驚喜。

缺陷一 × 缺陷二

這兩個湊在一起才是真正的問題:Add 不覆蓋,所以快取只能靠過期來更新;而過期時間被放大成 30 小時。

結果是這份快取實質上永不更新。 任一個單獨存在都還好——過期時間錯了,至少 30 分鐘後會換新值;Add 用錯了,至少過期後能重建。兩個一起就鎖死了。

缺陷三:ClearAll 用了官方明令不該用的 API

1
List<string> cacheKeys = Cache.Select(kvp => kvp.Key).ToList();

Cache.Select(...) 會走 MemoryCache 的列舉器。官方文件對這件事有明確警告:

Retrieving an enumerator for a MemoryCache instance is a resource-intensive and blocking operation. Therefore, the enumerator should not be used in production applications.

—— MemoryCache.GetEnumerator

「resource-intensive and blocking」是重點:它會擋住其他執行緒的快取存取。在一個高流量頁面上按下「清除快取」,等於對整個站台上鎖。

要能清空,正確做法是自己管理 key(維護一份 key 清單,或用可整批丟棄的獨立 MemoryCache 實例):

1
2
3
4
5
6
7
8
// MemoryCache 可以有多個具名實例,要整批清就換掉整個實例
private static MemoryCache _cache = new MemoryCache("MyRegion");

public void ClearAll()
{
var old = Interlocked.Exchange(ref _cache, new MemoryCache("MyRegion"));
old.Dispose();
}

順帶一提,MemoryCache.Default 是整個 AppDomain 共用的。在上面那份程式碼裡 ClearAll() 清掉的是所有人的快取,包含框架或其他函式庫放進去的。用具名實例把自己的東西隔開,本身就是好習慣。

缺陷四:DateTime.Now 指派給 DateTimeOffset

1
policy.AbsoluteExpiration = DateTime.Now + TimeSpan.FromMinutes(cacheTime);

AbsoluteExpiration 的型別是 DateTimeOffset,這裡靠隱含轉換把 DateTime 塞進去,轉換時會套用機器的本地時區。單機不會出事,但這是那種搬到不同時區設定的機器、或程式碼跨 UTC/Local 混用時才會爆的問題。直接寫 DateTimeOffset.Now 沒有任何成本。

四、還有兩個共通陷阱

MemoryCache 不接受 null。 官方明說:任何嘗試以 null 值新增或變更快取項目都會失敗。所以「查了資料庫、確實沒有這筆」這種結果沒辦法直接快取,會變成每次都去查。要快取「查無此物」得包一層哨兵物件:

1
2
3
4
5
6
7
8
9
private static readonly object NotFound = new object();

var hit = cache.Get(key);
if (hit == NotFound) return null; // 快取過的「查無此物」
if (hit != null) return (Item)hit;

var item = LoadFromDb(key);
cache.Set(key, (object)item ?? NotFound, policy);
return item;

絕對過期和滑動過期不能同時設。 CacheItemPolicy 上兩個都給非預設值,Set 會丟 ArgumentException。只能擇一,另一個要留在 InfiniteAbsoluteExpiration / NoSlidingExpiration。這件事在編譯期看不出來,測試沒覆蓋到就會在正式環境第一次寫快取時炸。

五、選哪個

情境 選擇 理由
Console / 排程批次,跑完即結束 Lazy<Dictionary> 沒有過期需求,最少的機制就是最好的
長駐服務,資料幾乎不變 MemoryCache + 長絕對過期 至少有一條「最終會更新」的路
長駐服務,資料會被人改 MemoryCache + 主動 Remove 改資料的那段程式碼負責失效
需要跟著檔案 / 資料庫變更失效 MemoryCache + ChangeMonitor 內建的機制,不用自己輪詢
多台機器要看到同一份 以上皆非 進程內快取解不了,要外部快取
新專案 不要用 HttpRuntime.Cache 綁 System.Web,MemoryCache 是它的通用版

最後一列值得強調:進程內快取在單機以外全都不成立。兩台 Web 各有各的 MemoryCache,A 機清了快取 B 機不知道,使用者重新整理兩次會看到兩種結果。這種問題在測試環境(通常只有一台)永遠重現不出來。

六、那份包裝類別的結局

我把四個缺陷查完之後才發現一件事:整個專案沒有任何一行程式碼用到它。

介面定義好了、實作寫完了、Get / Set / IsSet / Invalidate / Count / ClearAll 六個方法一應俱全,然後就停在那裡。全域搜尋 DefaultCacheProvider 和 ICacheProvider,除了它們自己的定義檔之外,零個引用。

所以那個「快取永不更新」的 bug 從來沒有發作過。

這跟上一篇結尾講的是同一件事的兩面:那邊是文件宣稱做了、程式碼沒做;這邊是程式碼寫了、沒有人用。兩種都會在半年後誤導接手的人——差別只在於,看到 Caching/ 資料夾就假設「這專案有快取層」的人,比看文件的人更多。

如果要接手這份程式碼,我的順序會是:先確認有沒有人用(沒有就刪掉,或標記為未使用),再修 bug。替沒人呼叫的程式碼修 bug 是最容易讓人有成就感、但完全沒有產出的工作。


三句話總結:

  1. 三種快取的差別在生命週期與失效權,不在查找效能。 先問「誰決定資料何時消失」,答案自然會指向其中一個。
  2. Add 不是 Set。 前者遇到同 key 會靜默失敗,而且回傳值很容易被忽略。
  3. 時間單位用 TimeSpan 承載,不要用 int。 讓單位錯誤在編譯期就被擋下來。

系列前篇:批次程式效能優化:真正省時間的只有一件事,其他加起來不到一成

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(Dictionary、MemoryCache、HttpRuntime.Cache:三種快取差在生命週期,不是差在效能 — mur mur);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:[email protected]

留言
分享

留言