⚠️ 快取三大問題:穿透、擊穿、雪崩
📌 問題概覽
| 問題 |
英文 |
原因 |
結果 |
| 穿透 |
Cache Penetration |
查詢不存在的資料 |
每次都打 DB |
| 擊穿 |
Cache Breakdown |
熱點 key 過期 |
瞬間大量打 DB |
| 雪崩 |
Cache Avalanche |
大量 key 同時過期 |
DB 被打爆 |
📌 Cache Penetration(快取穿透)
問題描述
請求 product:-1(不存在的 ID)
→ Redis 查不到 → DB 也查不到 → 不會寫入快取
→ 下次請求還是直接打 DB → 快取形同虛設!
攻擊者可以大量請求不存在的 ID,讓 DB 承受巨大壓力。
解法一:空值快取(Null Caching)
public async Task<Product?> GetProductSafe(int id)
{
var key = $"product:{id}";
var cached = await _cache.GetStringAsync(key);
// 快取命中(包括空值標記)
if (cached != null)
{
if (cached == "__NULL__") return null; // 空值標記
return JsonSerializer.Deserialize<Product>(cached);
}
// 查 DB
var product = await _db.Products.FindAsync(id);
if (product != null)
{
await _cache.SetStringAsync(key,
JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
});
}
else
{
// 關鍵:不存在的 key 也快取,但過期時間短
await _cache.SetStringAsync(key, "__NULL__",
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30)
});
}
return product;
}
解法二:參數驗證
public async Task<IActionResult> GetProduct(int id)
{
// 先驗證 ID 合理性
if (id <= 0 || id > 1_000_000)
return BadRequest("Invalid product ID");
var product = await _productService.GetProductSafe(id);
return product == null ? NotFound() : Ok(product);
}
解法三:布隆過濾器(Bloom Filter)
// 概念:用 BitArray 快速判斷 key 是否「可能存在」
// 如果布隆過濾器說不存在,就一定不存在
// 如果布隆過濾器說存在,有小機率是誤判
// 使用 Redis 的 BF 模組(需安裝 RedisBloom)
// BF.ADD product_filter 1001
// BF.EXISTS product_filter 9999 → 0 (一定不存在)
public async Task<Product?> GetProductWithBloom(int id)
{
// 先問布隆過濾器
bool mightExist = await _redis.ExecuteAsync(
"BF.EXISTS", "product_filter", id.ToString());
if (!mightExist)
return null; // 一定不存在,直接返回
// 可能存在,走正常快取流程
return await GetProductSafe(id);
}
📌 Cache Breakdown(快取擊穿)
問題描述
熱門商品 product:1001 的快取剛好過期
→ 同一瞬間 1000 個請求湧入
→ 全部 Cache Miss
→ 1000 個請求同時查 DB
→ DB 瞬間壓力爆表!
解法一:互斥鎖(Mutex Lock)
public async Task<Product?> GetProductWithLock(int id)
{
var key = $"product:{id}";
var lockKey = $"lock:product:{id}";
// 1. 嘗試從快取取得
var cached = await _cache.GetStringAsync(key);
if (cached != null)
return JsonSerializer.Deserialize<Product>(cached);
// 2. 嘗試取得鎖
var db = _redis.GetDatabase();
bool lockAcquired = await db.StringSetAsync(
lockKey, "1",
TimeSpan.FromSeconds(10), // 鎖的過期時間
When.NotExists); // 只在 key 不存在時設定
if (lockAcquired)
{
try
{
// 3. 取得鎖:查 DB 並更新快取
var product = await _dbContext.Products.FindAsync(id);
if (product != null)
{
await _cache.SetStringAsync(key,
JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
});
}
return product;
}
finally
{
await db.KeyDeleteAsync(lockKey);
}
}
else
{
// 4. 沒取得鎖:等待後重試
await Task.Delay(100);
return await GetProductWithLock(id); // 重試
}
}
解法二:永不過期 + 非同步更新
public class HotKeyService
{
// 快取永不過期,但記錄「邏輯過期時間」
public async Task<Product?> GetHotProduct(int id)
{
var key = $"product:{id}";
var data = await _redis.HashGetAllAsync(key);
if (data.Length == 0) return null;
var product = JsonSerializer.Deserialize<Product>(
data.First(f => f.Name == "data").Value!);
var expireAt = long.Parse(
data.First(f => f.Name == "expireAt").Value!);
// 檢查邏輯過期
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expireAt)
{
// 已過期,非同步更新(不阻塞當前請求)
_ = Task.Run(async () =>
{
var fresh = await _dbContext.Products.FindAsync(id);
if (fresh != null)
await SetHotProduct(key, fresh);
});
}
// 返回舊資料(可能略過時,但不會讓 DB 被打爆)
return product;
}
}
📌 Cache Avalanche(快取雪崩)
問題描述
凌晨 2:00 批量寫入 10000 筆商品快取,TTL 都設 8 小時
→ 上午 10:00,10000 筆快取同時過期
→ 大量請求同時穿透到 DB
→ DB 直接被打掛!
解法一:隨機過期時間
public async Task CacheProduct(Product product)
{
var key = $"product:{product.Id}";
var random = new Random();
// 基礎 TTL + 隨機偏移(避免同時過期)
var baseTtl = TimeSpan.FromMinutes(30);
var jitter = TimeSpan.FromMinutes(random.Next(0, 10));
var ttl = baseTtl + jitter; // 30~40 分鐘
await _cache.SetStringAsync(key,
JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl
});
}
解法二:多層快取
public class MultiLayerCache
{
private readonly IMemoryCache _l1; // L1: 本地記憶體
private readonly IDistributedCache _l2; // L2: Redis
public async Task<Product?> GetProduct(int id)
{
var key = $"product:{id}";
// L1: 先查本地快取(超快)
if (_l1.TryGetValue(key, out Product product))
return product;
// L2: 再查 Redis
var cached = await _l2.GetStringAsync(key);
if (cached != null)
{
product = JsonSerializer.Deserialize<Product>(cached)!;
// 寫回 L1(短 TTL)
_l1.Set(key, product, TimeSpan.FromSeconds(30));
return product;
}
// L3: 查 DB
product = await _db.Products.FindAsync(id);
if (product != null)
{
await _l2.SetStringAsync(key,
JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
});
_l1.Set(key, product, TimeSpan.FromSeconds(30));
}
return product;
}
}
解法三:熔斷降級
// 當 DB 壓力過大時,暫時返回預設值或舊資料
public async Task<Product?> GetProductWithFallback(int id)
{
try
{
return await GetProductFromCacheOrDb(id);
}
catch (Exception ex) when (ex is TimeoutException || ex is DbException)
{
_logger.LogWarning("DB 超時,返回降級資料");
return GetDefaultProduct(id); // 返回預設值
}
}
📌 三大問題對比
| 問題 |
觸發條件 |
核心解法 |
| 穿透 |
查不存在的 key |
空值快取 + 參數驗證 |
| 擊穿 |
熱點 key 過期 |
互斥鎖 / 永不過期 |
| 雪崩 |
大量 key 同時過期 |
隨機 TTL / 多層快取 |
🔑 重點整理
- 穿透用空值快取擋住不存在的 key
- 擊穿用互斥鎖確保只有一個請求查 DB
- 雪崩用隨機 TTL 分散過期時間
- 多層快取 (L1 本地 + L2 Redis) 是終極防線
- 永遠要有 降級方案,DB 掛了也能回應