快取策略 Caching
什麼是快取(Cache)?
快取就是把經常需要的資料「暫時存放在離你比較近的地方」,下次需要時就不用再跑一趟遠路。
💡 比喻:便利商店 vs 工廠 你想喝一瓶可樂:
- 沒有快取 = 每次都開車去可口可樂工廠買(資料庫查詢)
- 有快取 = 巷口便利商店就有(記憶體中的資料)
- 便利商店的庫存有限,而且飲料會過期(快取有容量和時效限制)
- 但是取貨速度快了 100 倍!
為什麼需要快取?
沒有快取:
使用者 → API → 資料庫查詢(10ms-100ms)→ 回傳結果
使用者 → API → 資料庫查詢(10ms-100ms)→ 回傳結果
每次都要查資料庫,資料庫壓力山大!
有快取:
使用者 → API → 快取命中(<1ms)→ 回傳結果 ✅ 超快!
使用者 → API → 快取未命中 → 資料庫查詢 → 存入快取 → 回傳結果
第一次慢,之後都超快!
IMemoryCache:記憶體內快取
ASP.NET Core 內建的記憶體快取,資料存在應用程式的記憶體中。
// 1. 在 Program.cs 註冊服務
// 加入記憶體快取服務到 DI 容器
builder.Services.AddMemoryCache();
// 2. 在 Controller 或 Service 中注入使用
using Microsoft.Extensions.Caching.Memory;
public class ProductService
{
// 注入 IMemoryCache
private readonly IMemoryCache _cache;
// 注入資料庫 Context
private readonly AppDbContext _db;
// 透過建構式注入取得快取和資料庫
public ProductService(IMemoryCache cache, AppDbContext db)
{
_cache = cache;
_db = db;
}
// 取得所有產品(有快取版本)
public async Task<List<Product>> GetAllProductsAsync()
{
// 定義快取的 Key(每種資料用不同的 Key)
var cacheKey = "products_all";
// TryGetValue:嘗試從快取取得資料
// 如果快取有資料,直接回傳(不用查資料庫)
if (_cache.TryGetValue(cacheKey, out List<Product>? products))
{
// 快取命中!直接回傳
return products!;
}
// 快取未命中,從資料庫查詢
products = await _db.Products.ToListAsync();
// 設定快取選項
var cacheOptions = new MemoryCacheEntryOptions()
// 絕對過期時間:5 分鐘後一定過期
.SetAbsoluteExpiration(TimeSpan.FromMinutes(5))
// 滑動過期時間:2 分鐘沒人存取就過期
.SetSlidingExpiration(TimeSpan.FromMinutes(2));
// 把資料存入快取
_cache.Set(cacheKey, products, cacheOptions);
// 回傳資料庫查詢的結果
return products;
}
}
Set、Get、TryGetValue
// IMemoryCache 的三個核心方法
// 1. Set:存入快取
// 把 "hello" 這個值存入快取,Key 是 "greeting"
_cache.Set("greeting", "hello");
// Set 也可以指定過期時間
// 存入快取,10 分鐘後過期
_cache.Set("user_count", 42, TimeSpan.FromMinutes(10));
// Set 搭配 MemoryCacheEntryOptions 做更細的設定
var options = new MemoryCacheEntryOptions()
// 快取大小(搭配 SizeLimit 使用)
.SetSize(1)
// 優先順序:記憶體不足時,Low 的會先被清除
.SetPriority(CacheItemPriority.High);
// 用選項存入快取
_cache.Set("important_data", myData, options);
// 2. Get:取得快取(可能為 null)
// 從快取取值,如果不存在會回傳 null
var greeting = _cache.Get<string>("greeting");
// 要檢查是否為 null
if (greeting != null)
{
// 使用快取的值
Console.WriteLine(greeting);
}
// 3. TryGetValue:安全地取得快取(推薦用法)
// 回傳 bool 表示是否有找到,值透過 out 參數傳出
if (_cache.TryGetValue("user_count", out int count))
{
// 有找到快取,count 已經有值了
Console.WriteLine($"使用者數量:{count}");
}
else
{
// 快取中沒有這個 Key
Console.WriteLine("快取中找不到資料");
}
快取過期策略:Absolute vs Sliding
兩種過期策略的比較:
絕對過期(Absolute Expiration):
├── 設定一個固定的過期時間
├── 不管有沒有人存取,時間到就過期
├── 比喻:便當的有效期限,不管你有沒有吃,時間到就丟
└── 適合:資料有時效性,像是匯率、天氣
滑動過期(Sliding Expiration):
├── 每次被存取就重新計時
├── 只有「連續 N 分鐘沒人存取」才會過期
├── 比喻:圖書館的書,有人借就延長,沒人借才下架
└── 適合:熱門資料,越多人用就越值得保留
最佳實踐:兩個一起用!
├── Sliding = 2 分鐘(沒人用就釋放記憶體)
└── Absolute = 30 分鐘(保證資料不會太舊)
// 同時設定兩種過期策略
var options = new MemoryCacheEntryOptions()
// 滑動過期:2 分鐘沒人存取就過期
.SetSlidingExpiration(TimeSpan.FromMinutes(2))
// 絕對過期:不管多熱門,30 分鐘後一定更新
.SetAbsoluteExpiration(TimeSpan.FromMinutes(30));
// 存入快取
_cache.Set("hot_data", myData, options);
// 範例:GetOrCreate 簡化寫法(推薦!)
// GetOrCreateAsync 會自動處理「有就拿、沒有就建立」的邏輯
var products = await _cache.GetOrCreateAsync("products", async entry =>
{
// 設定快取選項
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
entry.SlidingExpiration = TimeSpan.FromMinutes(2);
// 這裡的程式碼只有在快取未命中時才會執行
return await _db.Products.ToListAsync();
});
分散式快取:Redis 概念
記憶體快取的限制:
├── 只存在單一伺服器的記憶體中
├── 伺服器重啟就消失
└── 多台伺服器無法共享
分散式快取(Redis):
├── 獨立的快取伺服器
├── 所有應用程式伺服器共享同一份快取
├── 伺服器重啟也不會消失
└── 支援更複雜的資料結構
比喻:
├── IMemoryCache = 每個員工桌上的便條紙(只有自己看得到)
└── Redis = 公司公告欄(所有人都看得到)
什麼時候用哪種?
├── 單一伺服器、資料量小 → IMemoryCache
├── 多台伺服器、需要共享 → Redis
└── 兩者可以搭配使用(多層快取)
// ASP.NET Core 使用 Redis 分散式快取
// 1. 安裝套件:dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
// 2. 在 Program.cs 註冊 Redis 快取服務
builder.Services.AddStackExchangeRedisCache(options =>
{
// Redis 連線字串
options.Configuration = "localhost:6379";
// 設定 Key 的前綴(避免跟其他應用程式衝突)
options.InstanceName = "MyApp_";
});
// 3. 注入 IDistributedCache 使用
using Microsoft.Extensions.Caching.Distributed;
public class ProductService
{
// 分散式快取介面
private readonly IDistributedCache _cache;
// 透過建構式注入
public ProductService(IDistributedCache cache)
{
_cache = cache;
}
// 取得產品(Redis 快取版本)
public async Task<string?> GetProductJsonAsync(string productId)
{
// Redis 存的是 byte[] 或 string,不是物件
var cachedJson = await _cache.GetStringAsync($"product:{productId}");
// 如果快取有資料就直接回傳
if (cachedJson != null)
return cachedJson;
// 快取沒有,從資料庫查並存入快取
var product = await _db.Products.FindAsync(productId);
// 序列化成 JSON 字串
var json = JsonSerializer.Serialize(product);
// 設定分散式快取選項
var options = new DistributedCacheEntryOptions
{
// 絕對過期時間
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
};
// 存入 Redis
await _cache.SetStringAsync($"product:{productId}", json, options);
// 回傳 JSON
return json;
}
}
Cache-Aside 模式
Cache-Aside(旁路快取)是最常見的快取模式:
讀取流程:
1. 先查快取
2. 快取命中 → 直接回傳
3. 快取未命中 → 查資料庫 → 結果存入快取 → 回傳
寫入流程:
1. 更新資料庫
2. 刪除(或更新)快取
3. 下次讀取時會自動重新建立快取
比喻:查字典
1. 先看筆記本有沒有抄過(快取)
2. 有 → 直接看筆記本
3. 沒有 → 查字典(資料庫)→ 抄到筆記本 → 告訴你答案
// Cache-Aside 完整範例
public class OrderService
{
// 快取服務
private readonly IMemoryCache _cache;
// 資料庫
private readonly AppDbContext _db;
// 建構式注入
public OrderService(IMemoryCache cache, AppDbContext db)
{
_cache = cache;
_db = db;
}
// 讀取:先查快取,沒有再查資料庫
public async Task<Order?> GetOrderAsync(int orderId)
{
// 組合快取 Key
var key = $"order:{orderId}";
// 使用 GetOrCreateAsync 簡化 Cache-Aside 邏輯
return await _cache.GetOrCreateAsync(key, async entry =>
{
// 設定 10 分鐘過期
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
// 只有快取沒有時才會執行資料庫查詢
return await _db.Orders
.Include(o => o.OrderItems)
.FirstOrDefaultAsync(o => o.Id == orderId);
});
}
// 寫入:更新資料庫後,清除快取
public async Task UpdateOrderAsync(Order order)
{
// 先更新資料庫
_db.Orders.Update(order);
await _db.SaveChangesAsync();
// 刪除快取(下次讀取時會重新建立)
_cache.Remove($"order:{order.Id}");
// 也要清除相關的快取(例如訂單列表)
_cache.Remove("orders_all");
}
}
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:Cache Stampede(快取雪崩)
// ❌ 錯誤:快取過期時,大量請求同時打到資料庫
// 假設有 1000 個使用者同時存取
public async Task<List<Product>> GetProductsBadAsync()
{
// 快取過期的瞬間,1000 個請求都發現快取沒了
if (!_cache.TryGetValue("products", out List<Product>? products))
{
// 1000 個請求同時查資料庫!資料庫炸了!
products = await _db.Products.ToListAsync();
// 1000 個請求都在設定同一個快取
_cache.Set("products", products, TimeSpan.FromMinutes(5));
}
return products!;
}
// ✅ 正確:用 SemaphoreSlim 防止重複查詢
private static readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<List<Product>> GetProductsGoodAsync()
{
// 先嘗試取快取(不需要鎖)
if (_cache.TryGetValue("products", out List<Product>? products))
return products!;
// 只讓一個請求去查資料庫,其他的等
await _semaphore.WaitAsync();
try
{
// 再次檢查快取(可能在等待期間已經被其他人設定了)
if (_cache.TryGetValue("products", out products))
return products!;
// 只有一個請求會執行這裡
products = await _db.Products.ToListAsync();
_cache.Set("products", products, TimeSpan.FromMinutes(5));
return products;
}
finally
{
// 記得釋放鎖
_semaphore.Release();
}
}
❌ 錯誤 2:快取了使用者專屬資料卻用通用 Key
// ❌ 錯誤:所有使用者共用同一個快取 Key
public async Task<UserProfile> GetProfileBadAsync(int userId)
{
// 所有使用者都用同一個 Key,會拿到別人的資料!
return await _cache.GetOrCreateAsync("user_profile", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
// 使用者 A 的資料被快取後,使用者 B 也會拿到 A 的資料!
return await _db.Users.FindAsync(userId);
});
}
// ✅ 正確:每個使用者用不同的 Key
public async Task<UserProfile> GetProfileGoodAsync(int userId)
{
// 用 userId 區分不同使用者的快取
var key = $"user_profile:{userId}";
return await _cache.GetOrCreateAsync(key, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
// 每個使用者各自有自己的快取
return await _db.Users.FindAsync(userId);
});
}
❌ 錯誤 3:快取了過時的資料卻不清除
// ❌ 錯誤:更新資料庫但忘記清除快取
public async Task UpdateProductBadAsync(Product product)
{
// 更新了資料庫
_db.Products.Update(product);
await _db.SaveChangesAsync();
// 忘記清除快取!使用者會一直看到舊資料!
}
// ✅ 正確:更新資料後同時清除快取
public async Task UpdateProductGoodAsync(Product product)
{
// 更新資料庫
_db.Products.Update(product);
await _db.SaveChangesAsync();
// 清除相關快取(確保下次讀取時拿到最新資料)
_cache.Remove($"product:{product.Id}");
// 也要清除列表快取
_cache.Remove("products_all");
}
💡 重點整理
| 概念 | 說明 |
|---|---|
| IMemoryCache | ASP.NET Core 內建的記憶體快取,適合單機 |
| Redis | 分散式快取,適合多伺服器架構 |
| Absolute Expiration | 固定時間後過期,不管有沒有被存取 |
| Sliding Expiration | 沒人存取才過期,被存取就重新計時 |
| Cache-Aside | 先查快取,沒有再查資料庫,結果存入快取 |
| Cache Stampede | 快取過期瞬間大量請求打到資料庫,需要加鎖防護 |