2024年7月9日
Redis 快取怎麼用才不會出事?Cache-Aside、資料一致性,與穿透、擊穿、雪崩三個問題
Redis 的資料結構與基本操作可以先參考 Redis 不只是快取。這篇專注在最常見的用法:把它放在資料庫前面當快取。
Cache-Aside:最常用的快取模式
Cache-Aside(旁路快取)是由應用程式自己負責讀寫快取,Redis 和資料庫彼此不知道對方存在:
讀取:
- 先查 Redis,有就直接回傳(Cache Hit)
- 沒有(Cache Miss)就查資料庫
- 把查到的結果寫進 Redis,設定過期時間,再回傳
寫入:
- 先更新資料庫
- 成功後刪除 Redis 裡對應的 Key
- 下一次有人讀取時,就會因為 Cache Miss 從資料庫載入最新的資料
public async Task<House?> GetHouseAsync(int id)
{
var key = $"house:{id}";
var cached = await _redis.StringGetAsync(key);
if (cached.HasValue)
return JsonSerializer.Deserialize<House>(cached.ToString());
var house = await _db.Houses.FindAsync(id);
if (house is not null)
await _redis.StringSetAsync(key, JsonSerializer.Serialize(house), TimeSpan.FromMinutes(10));
return house;
}
public async Task UpdateHouseAsync(House house)
{
_db.Houses.Update(house);
await _db.SaveChangesAsync(); // 1. 先更新資料庫
await _redis.KeyDeleteAsync($"house:{house.Id}"); // 2. 再刪除快取
}
為什麼是「刪除」快取,而不是「更新」快取?
直覺上,資料庫改了,順便把新值寫進 Redis 不是更好嗎?問題出在兩個請求同時更新的時候:
| 時間 | 請求 A(改價為 1000 萬) | 請求 B(改價為 1200 萬) |
|---|---|---|
| 1 | 更新資料庫 → 1000 萬 | |
| 2 | 更新資料庫 → 1200 萬 | |
| 3 | 更新快取 → 1200 萬 | |
| 4 | 更新快取 → 1000 萬 |
資料庫最後是 1200 萬,快取卻是 1000 萬,而且會一直錯到過期為止。網路延遲、GC 停頓都可能讓 A 的第二步比 B 慢。
改成刪除,不管誰先誰後,結果都是「快取裡沒有這個 Key」,下一次讀取一定從資料庫拿最新值。另外,很多資料改了之後不一定馬上有人讀,每次更新都重新寫快取也是浪費。
為什麼是「先更新資料庫,再刪快取」?
反過來先刪快取、再更新資料庫,在這兩步之間如果剛好有讀取請求進來,它會 Cache Miss、讀到資料庫的舊值、再把舊值寫回快取。資料庫更新完之後,快取裡就是一份舊資料。
「先更新資料庫、再刪快取」也不是完全沒有問題,但出錯需要一個很巧的時序:讀取請求在快取過期後讀到舊值,而且它寫回快取的動作剛好落在寫入請求刪除快取之後。因為寫回快取通常比更新資料庫快得多,機率低很多。
刪除失敗怎麼辦
資料庫更新成功了,刪除快取時 Redis 剛好連不上,快取就會一直是舊的。幾個層次的保險:
- 一定要設過期時間:最差的情況,也只會舊到過期為止。這是最後一道防線
- 刪除失敗重試:失敗時丟到訊息佇列,由背景重試刪除
- 延遲雙刪:更新資料庫後刪一次,過幾百毫秒再刪一次,處理前面提到那個「讀取請求把舊值寫回去」的情況
- 訂閱資料庫異動:由獨立的服務監聽資料異動(例如 CDC 或異動事件)來刪除快取,應用程式本身不用處理
要先接受一件事:快取和資料庫之間只能做到最終一致。像餘額、庫存這種一定要即時正確的資料,就不該從快取讀來做判斷。
三個常見問題
快取穿透:查詢根本不存在的資料
有人一直查 house:-1、house:99999999 這種資料庫裡也沒有的資料。因為查不到,就不會寫進快取,每一次都會打到資料庫。如果是惡意的大量請求,等於快取完全沒有作用。
解法:
- 快取空值:查不到時也寫一筆「空」進 Redis,設定比較短的過期時間(例如 1 分鐘)。下一次同樣的查詢在 Redis 就擋掉了
- 布隆過濾器(Bloom Filter):把所有存在的 ID 放進布隆過濾器,查詢前先問它,它說「不存在」就一定不存在,直接回傳。適合 ID 範圍很大、惡意請求可能每次都換不同 ID 的情況
- 參數驗證:ID 小於 0、格式不對的,在 API 入口就擋掉
快取擊穿:熱門 Key 過期的那一瞬間
首頁的熱門物件、活動頁面,每秒有上千個請求在讀同一個 Key。它一過期,這一千個請求同時 Cache Miss,同時去查資料庫、同時寫回快取。資料庫在那一秒突然多了一千個一模一樣的查詢。
解法:
- 互斥鎖:Cache Miss 時,只讓一個請求去查資料庫並重建快取,其他請求等它完成後再讀快取
- 邏輯過期:Redis 的 Key 不設過期時間,而是在值裡放一個「邏輯過期時間」。讀到已經邏輯過期的資料時,先回傳舊值,再由一個背景工作去更新。使用者永遠不會遇到 Cache Miss,代價是短時間內會讀到舊資料
- 熱門資料預先載入:已知會很熱門的資料(例如活動開始),在活動前就先寫進快取
快取雪崩:大量 Key 同時失效
和擊穿不同,雪崩是很多 Key 同時沒了:
- 批次匯入的資料全部設定了相同的過期時間,一小時後同時過期
- Redis 本身掛了,或是重啟後記憶體是空的
所有請求一起湧向資料庫,資料庫被壓垮,接著依賴資料庫的服務也一起倒下。
解法:
- 過期時間加隨機值:原本 10 分鐘,改成 10 分鐘加上 0~2 分鐘的隨機秒數,讓過期時間分散開來
- Redis 高可用:用 Sentinel 或 Cluster,單一節點掛掉時自動切換
- 多層快取:應用程式內再加一層短時間的本機快取(
IMemoryCache),Redis 短暫不可用時還能撐一下 - 限流與降級:資料庫前面加上限流,超過的請求直接回傳預設內容或錯誤,保護資料庫不被打垮,比全部一起倒下好
把三個解法放在一起
private static readonly ConcurrentDictionary<string, SemaphoreSlim> Locks = new();
private const string NullMarker = "__null__";
public async Task<House?> GetHouseAsync(int id)
{
if (id <= 0) return null; // 參數驗證,擋掉明顯不存在的 ID
var key = $"house:{id}";
var cached = await _redis.StringGetAsync(key);
if (cached.HasValue)
return cached == NullMarker ? null : JsonSerializer.Deserialize<House>(cached.ToString());
// 擊穿:同一個 Key 只讓一個請求去查資料庫(這是單一實例內的鎖)
var gate = Locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync();
try
{
// 拿到鎖之後再檢查一次,前一個請求可能已經重建好了
cached = await _redis.StringGetAsync(key);
if (cached.HasValue)
return cached == NullMarker ? null : JsonSerializer.Deserialize<House>(cached.ToString());
var house = await _db.Houses.FindAsync(id);
if (house is null)
{
// 穿透:不存在也快取,但時間短一點
await _redis.StringSetAsync(key, NullMarker, TimeSpan.FromMinutes(1));
return null;
}
// 雪崩:過期時間加上隨機值,避免同時過期
var ttl = TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(Random.Shared.Next(0, 120));
await _redis.StringSetAsync(key, JsonSerializer.Serialize(house), ttl);
return house;
}
finally
{
gate.Release();
}
}
這裡的 SemaphoreSlim 只在同一個應用程式實例內有效。部署了 10 個 Pod,最多就會有 10 個請求同時去查資料庫——通常這已經足夠,比一千個好太多了。如果連這 10 個都不能接受,才需要用 Redis 做跨實例的分散式鎖。
使用情境:物件詳細頁
物件詳細頁是房仲網站流量最大的頁面之一:
- 讀取:Cache-Aside,過期時間 10 分鐘加隨機值
- 物件改價、下架:更新資料庫後刪除
house:{id};同時發出物件異動事件,由同步服務再刪一次,處理刪除失敗與延遲雙刪的情況 - 爬蟲與惡意請求:ID 驗證加上空值快取,不存在的物件不會一直打到資料庫
- 新建案上架、廣告曝光:預先把物件寫進快取,並用互斥鎖避免過期瞬間的大量查詢
- Redis 維護或故障:Pod 內有 30 秒的本機快取,資料庫前面有限流,最差的情況是部分請求變慢,而不是整個網站掛掉
注意事項
- 不是所有資料都適合快取:讀多寫少、可以接受短暫不一致的才適合。寫入頻繁的資料,快取一直被刪,命中率很低,反而多了一次 Redis 往返
- 監控命中率:命中率太低代表 Key 設計或過期時間有問題,快取只是多一層開銷
- 快取的值不要太大:一個 Key 存好幾 MB 的 JSON,序列化、網路傳輸都很慢,還會變成 Big Key。只快取頁面真正需要的欄位
- 序列化的格式要有版本概念:物件類別加了欄位、改了型別,部署新版本後讀到舊格式的快取可能會反序列化失敗。改動結構時可以在 Key 加上版本號,例如
house:v2:{id} - Redis 掛掉不能讓整個服務掛掉:連不上 Redis 時,要降級成直接查資料庫(搭配限流),而不是直接拋出例外
一句話總結
用 Cache-Aside 時,讀取先查快取再查資料庫並寫回,寫入先更新資料庫再刪除快取,並一定要設過期時間當最後防線;查不存在的資料用空值快取或布隆過濾器防穿透,熱門 Key 過期用互斥鎖或邏輯過期防擊穿,大量 Key 同時失效用隨機過期時間、高可用與限流防雪崩。