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

快取策略 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 快取過期瞬間大量請求打到資料庫,需要加鎖防護

💡 大家的想法 · 0

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