🏗️ 快取策略模式:Cache-Aside、Write-Through、Write-Behind
📌 四種常見的快取策略
| 策略 | 讀取 | 寫入 | 適用場景 |
|---|---|---|---|
| Cache-Aside | App 先查快取,Miss 再查 DB | App 寫 DB,再清快取 | 最通用 |
| Read-Through | 快取自動從 DB 載入 | - | 讀多寫少 |
| Write-Through | - | 同時寫快取和 DB | 資料一致性高 |
| Write-Behind | - | 先寫快取,非同步寫 DB | 寫入量大 |
📌 Cache-Aside(旁路快取)— 最常用!
工作流程
讀取:
1. App 先查 Redis
2. 命中 → 直接回傳
3. 未命中 → 查 DB → 寫入 Redis → 回傳
寫入:
1. App 更新 DB
2. 刪除 Redis 中的快取(而非更新)
為什麼是「刪除」而非「更新」快取?
更新快取可能導致 並發問題: 兩個請求同時更新,A 先寫 DB 但後寫快取,快取就會是舊資料。 刪除快取讓下一次讀取自動載入最新資料,更安全。
.NET 完整實作
public class OrderService
{
private readonly AppDbContext _db;
private readonly IDistributedCache _cache;
private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(10);
public OrderService(AppDbContext db, IDistributedCache cache)
{
_db = db;
_cache = cache;
}
// 讀取:Cache-Aside 模式
public async Task<Order?> GetOrderAsync(int orderId)
{
var cacheKey = $"order:{orderId}";
// Step 1: 查快取
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null)
{
Console.WriteLine($"[Cache HIT] order:{orderId}");
return JsonSerializer.Deserialize<Order>(cached);
}
// Step 2: 快取 Miss,查 DB
Console.WriteLine($"[Cache MISS] order:{orderId}");
var order = await _db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null) return null;
// Step 3: 寫入快取
await _cache.SetStringAsync(cacheKey,
JsonSerializer.Serialize(order),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _cacheExpiry
});
return order;
}
// 寫入:更新 DB 後刪除快取
public async Task UpdateOrderStatusAsync(int orderId, string status)
{
var order = await _db.Orders.FindAsync(orderId);
if (order == null) throw new Exception("Order not found");
order.Status = status;
await _db.SaveChangesAsync();
// 刪除快取(不是更新!)
await _cache.RemoveAsync($"order:{orderId}");
Console.WriteLine($"[Cache INVALIDATED] order:{orderId}");
}
}
📌 Read-Through(穿透讀取)
快取層自動處理 DB 查詢,應用程式只跟快取互動。
// 概念實作:快取自動載入
public class ReadThroughCache<T>
{
private readonly IDatabase _redis;
private readonly Func<string, Task<T?>> _dataLoader;
private readonly TimeSpan _ttl;
public ReadThroughCache(
IDatabase redis,
Func<string, Task<T?>> dataLoader,
TimeSpan ttl)
{
_redis = redis;
_dataLoader = dataLoader;
_ttl = ttl;
}
public async Task<T?> GetAsync(string key)
{
// 自動查快取
var cached = await _redis.StringGetAsync(key);
if (!cached.IsNullOrEmpty)
return JsonSerializer.Deserialize<T>(cached!);
// 自動從資料源載入
var data = await _dataLoader(key);
if (data != null)
{
await _redis.StringSetAsync(key,
JsonSerializer.Serialize(data), _ttl);
}
return data;
}
}
// 使用
var productCache = new ReadThroughCache<Product>(
db, key => dbContext.Products.FindAsync(int.Parse(key.Split(':')[1])).AsTask(),
TimeSpan.FromMinutes(10));
var product = await productCache.GetAsync("product:42");
📌 Write-Through(穿透寫入)
寫入時同時更新快取和 DB,確保兩者一致。
public class WriteThroughService
{
private readonly AppDbContext _db;
private readonly IDatabase _redis;
public async Task SaveProductAsync(Product product)
{
// 同時寫 DB 和快取
_db.Products.Update(product);
await _db.SaveChangesAsync();
var cacheKey = $"product:{product.Id}";
await _redis.StringSetAsync(cacheKey,
JsonSerializer.Serialize(product),
TimeSpan.FromMinutes(30));
// 兩者都成功才算完成
Console.WriteLine($"[Write-Through] product:{product.Id} synced");
}
}
優點: 快取永遠是最新的。 缺點: 寫入延遲增加(要寫兩個地方)。
📌 Write-Behind / Write-Back(回寫)
先寫入快取,非同步批量寫入 DB。
public class WriteBehindService
{
private readonly IDatabase _redis;
private readonly Channel<WriteTask> _writeQueue;
// 寫入:只寫快取,任務放入佇列
public async Task SaveAsync(string key, object data)
{
await _redis.StringSetAsync(key,
JsonSerializer.Serialize(data));
// 非同步寫入佇列
await _writeQueue.Writer.WriteAsync(
new WriteTask { Key = key, Data = data });
}
// 背景工作:批量寫入 DB
public async Task ProcessWriteQueueAsync(CancellationToken ct)
{
var batch = new List<WriteTask>();
await foreach (var task in _writeQueue.Reader.ReadAllAsync(ct))
{
batch.Add(task);
// 累積 100 筆或每 5 秒批量寫入
if (batch.Count >= 100)
{
await FlushToDatabase(batch);
batch.Clear();
}
}
}
}
優點: 寫入極快,DB 壓力小。 缺點: 快取掛掉可能丟失資料。
📌 策略比較表
| 特性 | Cache-Aside | Read-Through | Write-Through | Write-Behind |
|---|---|---|---|---|
| 實作複雜度 | 低 | 中 | 中 | 高 |
| 讀取效能 | 高 | 高 | 高 | 高 |
| 寫入效能 | 中 | - | 低 | 極高 |
| 資料一致性 | 最終一致 | 最終一致 | 強一致 | 弱一致 |
| 資料遺失風險 | 低 | 低 | 低 | 高 |
| 適用場景 | 通用 | 讀多寫少 | 一致性要求高 | 寫入量極大 |
🔑 重點整理
- Cache-Aside 是最常用的策略,先查快取再查 DB
- 寫入後應 刪除快取,而非更新快取,避免並發問題
- Write-Through 適合需要高一致性的場景
- Write-Behind 效能最好但有資料遺失風險
- 大多數 .NET 專案用 Cache-Aside 就夠了