上一篇把批次程式裡的參考資料改成 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 | var data = cache.Get(key) as List<Item>; |
自建 Dictionary 沒有這個問題(沒人會拿走你的東西),但也因此沒有記憶體上限。兩邊各有各的死法。
三、一份真實的包裝類別,和它的四個缺陷
以下是我在一個 ASP.NET MVC 專案裡看到的快取包裝,只改了命名,邏輯原封不動:
1 | public class DefaultCacheProvider : ICacheProvider |
看起來很正常。四個問題。
缺陷一: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 | int cacheTime = 1800; // 看起來是 1800 秒 = 30 分鐘 |
前兩行都在算「秒」,最後一行用 FromMinutes 把它當成「分鐘」。
- 沒設定時:
FromMinutes(1800)= 30 小時(本意 30 分鐘) - 設定 30 分鐘時:
30 × 60 = 1800→ 一樣 30 小時
單位在變數名稱裡完全沒有體現,cacheTime 這個名字不帶單位,是這個 bug 能活下來的原因。改法是讓型別自己說話:
1 | TimeSpan ttl = TimeSpan.FromMinutes(30); |
變數型別用 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.
「resource-intensive and blocking」是重點:它會擋住其他執行緒的快取存取。在一個高流量頁面上按下「清除快取」,等於對整個站台上鎖。
要能清空,正確做法是自己管理 key(維護一份 key 清單,或用可整批丟棄的獨立 MemoryCache 實例):
1 | // MemoryCache 可以有多個具名實例,要整批清就換掉整個實例 |
順帶一提,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 | private static readonly object NotFound = new object(); |
絕對過期和滑動過期不能同時設。 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 是最容易讓人有成就感、但完全沒有產出的工作。
三句話總結:
- 三種快取的差別在生命週期與失效權,不在查找效能。 先問「誰決定資料何時消失」,答案自然會指向其中一個。
Add不是Set。 前者遇到同 key 會靜默失敗,而且回傳值很容易被忽略。- 時間單位用
TimeSpan承載,不要用int。 讓單位錯誤在編譯期就被擋下來。
留言