☕ NEW! 完成新手任務即可參加抽獎!LINE 星巴克禮券等你拿,名額有限!        🎉 推廣活動:邀請好友註冊 DevLearn,累積推薦抽 LINE 星巴克禮券! 活動詳情 →        🔥 活動期間 2026/4/1 - 5/31 |已有 0 人參加       
redis 中級

⚠️ 快取三大問題:穿透、擊穿、雪崩

📌 問題概覽

問題 英文 原因 結果
穿透 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 / 多層快取

🔑 重點整理

  1. 穿透用空值快取擋住不存在的 key
  2. 擊穿用互斥鎖確保只有一個請求查 DB
  3. 雪崩用隨機 TTL 分散過期時間
  4. 多層快取 (L1 本地 + L2 Redis) 是終極防線
  5. 永遠要有 降級方案,DB 掛了也能回應

💡 大家的想法 · 0

載入中...
💬 即時聊天室 🟢 0 人在線
😀 😎 🤓 💻 🎮 🎸 🔥
➕ 新問題
📋 我的工單
💬 LINE 社群
🔒
需要註冊才能使用此功能
註冊帳號即可解鎖測驗、遊戲、簽到、筆記下載等所有功能,完全免費!
免費註冊