Cache - 從頭開始
在系統村裡,有位小學徒 Yoyo,每天最常被叫去做的工作,就是幫村裡人查詢「住戶資料」。
「Yoyo,去問資料庫大師,這位用戶住哪裡?」
「Yoyo,再去查一次,那筆我剛剛沒記住。」
「Yoyo,再去……」
一開始,Yoyo 也不以為意。畢竟這是學徒的日常。可重複個幾百次後,他終於受不了了:「為什麼我每次查完,都要重跑一樣的路?」
直到有天,他開始在口袋裡放一本小筆記本,把查過的資料寫在上面。從此以後,他只要看看自己的筆記本,就能直接回應問題,不必每次都跑一趟。
這本小筆記本,就是 Yoyo 發明的「快取系統」。
這篇文章,就是 Yoyo 筆記本的成長紀錄——從一開始只是單純的記錄,到後來要考慮資料會變、要支援多人同時翻閱、還要知道什麼時候該撕掉舊頁重寫。
如果你也曾為重複查資料煩惱過,那就一起打開這本「Cache 筆記」,從第一頁開始吧。
🥥 第一階段:打造屬於自己的「最簡快取機制」
在這一階段,我們要實作一個最基礎、卻實用+的快取系統。這是認識快取背後原理的第一步,也是許多大型系統背後的關鍵核心。
快取的真正意義:避免重複做「同一件事」
在真實世界的應用程式中,我們常常會遇到重複的操作,例如:
- 每次都從資料庫撈資料(明明資料沒變)
- 重複做費時的計算(像重新分析某張報表)
- 網路請求一樣的 API
這些動作不只浪費效能,也浪費資源。
快取的本質就是:
「如果我已經做過,就不要重做,直接把結果記下來再拿來用。」
技術核心概念:「懶加載 + Dictionary」
這個版本的快取,我們只靠兩樣東西:
- Dictionary 當作資料容器
- 一個 Func
委派來延遲建立資料(Lazy Load)
實作規劃
元件 | 說明 |
---|---|
Dictionary<object, CacheItem<T>> |
儲存資料的容器,使用 object 為 Key,最大化彈性 |
CacheItem<T> |
包裝資料本體與快取時間戳 |
Func<T> |
當資料不存在時,用來產生資料的委派 |
static 類別設計 |
讓整個系統可以共用同一個快取容器 |
完整實作
資料包裝類別:CacheItem
1 | /// <summary> |
快取服務:CacheService
1 | /// <summary> |
快取測試 Endpoint
1 | public static void MapCacheTestEndpoints(this IEndpointRouteBuilder app) |
測試畫面(GET 請求)
🥥 第二階段:加入 Thread-Safety
為什麼要 Thread-Safe?
在 Web API 或多執行緒程式中,可能會有多個請求「同時」存取快取。因此發生同時檢查 ContainsKey() 為 false,然後同時執行 _cacheData[key] = …
結果會出現「重複寫入」或 Key already exists 的例外!
測試
1 | app.MapGet("RunCacheRaceConditionTest", () => |
1 | System.InvalidOperationException: |
「你試圖同時改變一個非執行緒安全的集合(Dictionary),導致內部狀態毀損,Dictionary 已經壞掉了!」
底層的資料結構已經進入不穩定或不一致的狀態,雜湊表內部的 bucket index、linked list 結構不一致,導致查詢時可能跳過資料、不準確或找不到值,因此在寫入或查找行為會觸發 Exception 或無法預期的行為
🧠 技術核心概念:「鎖定」或使用「並行資料結構」
.NET 提供兩種常見的 Thread-Safe 解法:
方法 | 適用情境 | 說明 |
---|---|---|
lock |
控制進入區段 | 精準鎖定,但容易產生效能瓶頸 |
ConcurrentDictionary |
原生支援多執行緒 | 高效能內建併發控制機制,推薦使用 |
🔧 本次實作採用:ConcurrentDictionary
✅ 優點:
- 不需額外使用 lock
- 支援 GetOrAdd() 方法,完美符合我們的快取邏輯
- 效能比手動加 lock 更佳
ConcurrentDictionary 是一種高效能、支援併發存取的 key-value 容器。它不是用一整個 lock 包住整張表,而是用「分段鎖(Segmented Locking)」的方式來做到:
機制 | 說明 |
---|---|
分段桶(bucket) | 資料分散存放在不同區塊 |
區段鎖(lock striping) | 每個區段各自鎖住,不影響其他區 |
原子操作(atomic) | 提供 TryAdd 、GetOrAdd 、TryUpdate 等具備執行緒安全的操作 |
📦 完整實作
改寫後的 Thread-Safe 版本:ThreadSafeCacheService
1 | public class ThreadSafeCacheService<T> |
快取測試 Endpoint
1 | app.MapGet("/ThreadSafeCacheTest", () => |
這次就不會出現 DictionaryCorrupt 的問題了!
🥥 第三階段:過期機制
在現實世界裡,資料是會隨著時間改變的,而「快取」如果永不更新,就會產生過時的資訊(Stale Data)。
🎯 解法就是:給每筆快取「設定一個生命週期(Time-To-Live, TTL)」。
我們會擴充原有的快取機制,加入以下概念:
概念 | 說明 |
---|---|
ExpireAfter |
每筆資料儲存時就指定一個有效時間 |
DateTime.Now 檢查 |
每次讀取資料時,檢查是否已過期 |
過期就重建 |
如果過期,則重新執行委派取得新資料並更新快取 |
快取資料 CacheItem:新增 ExpireAfter
1 | public class CacheItem<T> |
ThreadSafeCacheService 加入過期邏輯
1 | public class ThreadSafeCacheService<T> |
測試用 API Endpoint
1 | app.MapGet("/TreadSafeCacheWithTTLTest", () => |
輸出結果顯示,過期後重新建立資料在快取上
1 | 查詢資料庫: 1 |
🌟 總結
在這三個階段,我們已經讓快取具有了「時間的概念」,這是從單純資料存取,進化到資料新鮮度管理的關鍵。
- ✅ 你現在已擁有的快取功能:
- ✅ 快速查找:用 Dictionary 儲存資料
- ✅ 執行緒安全:使用 ConcurrentDictionary
- ✅ 過期機制:加入 ExpireAfter 自動更新
接下來我們可以探索的擴充功能
功能 | 說明 |
---|---|
✅ 手動移除快取 | 例如當資料被修改時,可以主動清除指定 key |
✅ 快取整體清空 | 快速重建整個快取表,常用於後台設定更新後 |
✅ 自動背景清理(Eviction) | 定時掃描快取並移除過期資料,釋放記憶體 |
✅ 支援非同步委派(Func<Task<T>> ) |
可快取 async 請求或計算結果 |
✅ 支援快取策略(CachePolicy) | 不同資料型別有不同 TTL、來源或刷新邏輯 |
✅ 支援多層快取(Memory + Redis) | 搭配分散式架構,資料能夠更持久與共享 |
將在後續篇章實作
🥥 結語
隨著查詢的請求越來越多,Yoyo 的筆記本也越寫越厚。他發現:
有時資料變了,筆記卻沒跟著改;
有時筆記給別人看,會同時有人翻動同一頁,導致混亂;
有時資料早就不需要了,卻還佔據著頁面空間……
他開始思考:如果這是一本真正的智慧之書,它該會自動更新、會分頁鎖定、會自己刪除不重要的記憶。
於是他加上了更多邏輯:
🔸 每一頁資料都有「有效期限」,過了就自動重新抄寫;
🔸 支援多人同時翻閱,不會打架;
🔸 能夠指定清除某一頁,或整本清空重新開始。
Yoyo 不再是只會抄寫的小學徒,而是懂得管理記憶的系統設計師。
這篇文章寫完了,但 Yoyo 的筆記本還有很多空白頁,等待著未來的功能與邏輯補上。
你的系統,也該有一本屬於自己的快取筆記:它不完美,卻可以每天學著更聰明一點、更可靠一點。
如果你也曾因重複請求而疲憊,或因查太慢而懊惱,不妨從這篇開始,一頁一頁為你的系統寫下記憶的方式。