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
2
3
4
5
6
7
8
9
10
11
12
13
/// <summary>
/// 快取的資料容器,記錄資料與時間戳
/// </summary>
public class CacheItem<T>
{
public T Value { get; set; }
public DateTime CreatedAt { get; set; }
public CacheItem(T value)
{
Value = value;
CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
}

快取服務:CacheService

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
27
28
29
/// <summary>
/// Yoyo 特製版快取
/// </summary>
public static class CacheService<T>
{
/// <summary>
/// 快取資料
/// </summary>
private static Dictionary<object, CacheItem<T>> _cacheData = new Dictionary<object, CacheItem<T>>();

/// <summary>
/// Gets or creates data
/// </summary>
/// <param name="key">cache key</param>
/// <param name="createItem">delegate to create item</param>
/// <returns>T</returns>
public static CacheItem<T> GetOrCreate(object key, Func<T> createItem)
{
if (_cacheData.ContainsKey(key) == false)
{
//// 快取用[] = 更接近 Create or Update 的狀態不會因為誤判噴 Exception
//// System.ArgumentException: An item with the same key has already been added.
_cacheData[key] = new CacheItem<T>(createItem());
}

Console.WriteLine($"key : {key}, data : {_cacheData[key]}");
return _cacheData[key];
}
}

快取測試 Endpoint

1
2
3
4
5
6
7
8
9
public static void MapCacheTestEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/SimpleCacheTest", () =>
{
var data = CacheService<string>.GetOrCreate(123, () => YoyoDB.GetUserInfo(123));
var data2 = CacheService<string>.GetOrCreate(456, () => YoyoDB.GetUserInfo(456));
return new Tuple<CacheItem<string>, CacheItem<string>>(data,data2);
});
}

測試畫面(GET 請求)
simpleCacheTest



🥥 第二階段:加入 Thread-Safety


為什麼要 Thread-Safe?

在 Web API 或多執行緒程式中,可能會有多個請求「同時」存取快取。因此發生同時檢查 ContainsKey() 為 false,然後同時執行 _cacheData[key] = …
結果會出現「重複寫入」或 Key already exists 的例外!



測試

1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.MapGet("RunCacheRaceConditionTest", () =>
{
var tasks = new List<Task>();
for (int i = 0; i < 20;i++)
{
tasks.Add(Task.Run(() =>
{
var data3 = CacheService<string>.GetOrCreate(1, () => YoyoDB.GetUserInfo(1));
return data3;
}));
}

Task.WaitAll(tasks.ToArray());
});
1
2
3
System.InvalidOperationException:
Operations that change non-concurrent collections must have exclusive access.
A concurrent update was performed on this collection and corrupted its state.

「你試圖同時改變一個非執行緒安全的集合(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) 提供 TryAddGetOrAddTryUpdate 等具備執行緒安全的操作


📦 完整實作

改寫後的 Thread-Safe 版本:ThreadSafeCacheService

1
2
3
4
5
6
7
8
9
10
public class ThreadSafeCacheService<T>
{
private static readonly ConcurrentDictionary<object, CacheItem<T>> _cacheData = new();
public static CacheItem<T> GetOrCreate(object key, Func<T> createItem)
{
var cacheItem = _cacheData.GetOrAdd(key, _ => new CacheItem<T>(createItem()));
Console.WriteLine($"[ThreadSafe] key: {key}, data: {cacheItem.Value}");
return cacheItem;
}
}

快取測試 Endpoint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.MapGet("/ThreadSafeCacheTest", () =>
{
var tasks = new List<Task>();
for (int i = 0; i < 20; i++)
{
tasks.Add(Task.Run(() =>
{
var data4 = ThreadSafeCacheService<string>.GetOrCreate(1, () => YoyoDB.GetUserInfo(1));
return data4;
}));
}

Task.WaitAll(tasks.ToArray());
});

這次就不會出現 DictionaryCorrupt 的問題了!



🥥 第三階段:過期機制

在現實世界裡,資料是會隨著時間改變的,而「快取」如果永不更新,就會產生過時的資訊(Stale Data)。

🎯 解法就是:給每筆快取「設定一個生命週期(Time-To-Live, TTL)」。

我們會擴充原有的快取機制,加入以下概念:

概念 說明
ExpireAfter 每筆資料儲存時就指定一個有效時間
DateTime.Now 檢查 每次讀取資料時,檢查是否已過期
過期就重建 如果過期,則重新執行委派取得新資料並更新快取

快取資料 CacheItem:新增 ExpireAfter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CacheItem<T>
{
public T Value { get; set; }
public string CreatedAt { get; set; }

public TimeSpan ExpiredAfter { get; set; } = TimeSpan.FromMinutes(5);
public bool IsExpired
{
get
{
DateTime createdAt = DateTime.Parse(CreatedAt);
return DateTime.Now - createdAt > ExpiredAfter;
}
}

public CacheItem(T value,TimeSpan expireAfter)
{
Value = value;
CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
ExpiredAfter = expireAfter;
}
}


ThreadSafeCacheService 加入過期邏輯

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
public class ThreadSafeCacheService<T>
{
private static readonly ConcurrentDictionary<object, CacheItem<T>> _cacheData = new();

public static CacheItem<T> GetOrCreate(object key, Func<T> createItem, TimeSpan expireAfter)
{
_cacheData.AddOrUpdate(
key,
_ => new CacheItem<T>(createItem(), expireAfter),
(_, existingItem) =>
{
if (existingItem.IsExpired)
{
Console.WriteLine($"[Expired] key: {key} 資料已過期,重新建立");
return new CacheItem<T>(createItem(), expireAfter);
}

return existingItem;
});

var item = _cacheData[key];
Console.WriteLine($"[Expirable] key: {key}, data: {item.Value}");
return item;
}
}


測試用 API Endpoint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.MapGet("/TreadSafeCacheWithTTLTest", () =>
{
var tasks = new List<Task>();
for (int i = 0; i < 5; i ++)
{
tasks.Add(Task.Run(() =>
{
var data5 = ThreadSafeCacheService<string>.GetOrCreate(1, () => YoyoDB.GetUserInfo(1), TimeSpan.FromSeconds(2));
}));

Thread.Sleep(1000);
}

Task.WaitAll(tasks.ToArray());
});

輸出結果顯示,過期後重新建立資料在快取上

1
2
3
4
5
6
7
8
9
10
查詢資料庫: 1
[Expirable] key: 1, data: User-1
[Expirable] key: 1, data: User-1
[Expired] key: 1 資料已過期,重新建立
查詢資料庫: 1
[Expirable] key: 1, data: User-1
[Expirable] key: 1, data: User-1
[Expired] key: 1 資料已過期,重新建立
查詢資料庫: 1
[Expirable] key: 1, data: User-1


🌟 總結

在這三個階段,我們已經讓快取具有了「時間的概念」,這是從單純資料存取,進化到資料新鮮度管理的關鍵。

  • ✅ 你現在已擁有的快取功能:
  • ✅ 快速查找:用 Dictionary 儲存資料
  • ✅ 執行緒安全:使用 ConcurrentDictionary
  • ✅ 過期機制:加入 ExpireAfter 自動更新

接下來我們可以探索的擴充功能

功能 說明
✅ 手動移除快取 例如當資料被修改時,可以主動清除指定 key
✅ 快取整體清空 快速重建整個快取表,常用於後台設定更新後
✅ 自動背景清理(Eviction) 定時掃描快取並移除過期資料,釋放記憶體
✅ 支援非同步委派(Func<Task<T>> 可快取 async 請求或計算結果
✅ 支援快取策略(CachePolicy) 不同資料型別有不同 TTL、來源或刷新邏輯
✅ 支援多層快取(Memory + Redis) 搭配分散式架構,資料能夠更持久與共享

將在後續篇章實作



🥥 結語

隨著查詢的請求越來越多,Yoyo 的筆記本也越寫越厚。他發現:

有時資料變了,筆記卻沒跟著改;

有時筆記給別人看,會同時有人翻動同一頁,導致混亂;

有時資料早就不需要了,卻還佔據著頁面空間……

他開始思考:如果這是一本真正的智慧之書,它該會自動更新、會分頁鎖定、會自己刪除不重要的記憶。

於是他加上了更多邏輯:
🔸 每一頁資料都有「有效期限」,過了就自動重新抄寫;
🔸 支援多人同時翻閱,不會打架;
🔸 能夠指定清除某一頁,或整本清空重新開始。

Yoyo 不再是只會抄寫的小學徒,而是懂得管理記憶的系統設計師。

這篇文章寫完了,但 Yoyo 的筆記本還有很多空白頁,等待著未來的功能與邏輯補上。
你的系統,也該有一本屬於自己的快取筆記:它不完美,卻可以每天學著更聰明一點、更可靠一點。

如果你也曾因重複請求而疲憊,或因查太慢而懊惱,不妨從這篇開始,一頁一頁為你的系統寫下記憶的方式。