ASP.NET Core 快取實戰:使用 IMemoryCache 降低資料庫查詢,避免 Cache Stampede

在 Web 系統中,有些資料會被頻繁查詢,但本身並不會頻繁變動。IMemoryCache
一段時間內只從資料庫讀取一次完整資料,後續查詢直接從 Web Server 記憶體取得。

在 Web 系統中,有些資料會被頻繁查詢,但本身並不會頻繁變動。

例如:

  • 使用者清單
  • 部門資料
  • 系統設定
  • 權限選項
  • 下拉選單資料
  • 國家、地區、廠區
  • 商品分類
  • 組織架構
  • 基礎主檔

假設系統有 5,000 名員工,使用者每輸入一次關鍵字搜尋,就執行一次:

SELECT *
FROM [USER]
WHERE ENABLE = 'Y'

即使資料庫查詢只需要 100ms,只要同時有很多使用者操作,資料庫仍會不斷處理幾乎相同的查詢。

這種情況非常適合使用:IMemoryCache

核心概念很簡單:

一段時間內只從資料庫讀取一次完整資料,後續查詢直接從 Web Server 記憶體取得。


1. 沒有 Cache 時會發生什麼?

假設畫面有一個使用者搜尋功能。

使用者輸入:bob

後端執行:

Request
   ↓
Controller
   ↓
Service
   ↓
Database
   ↓
取得全部使用者
   ↓
篩選 bob

下一個人搜尋:john
 

又再執行:

Request
   ↓
Controller
   ↓
Service
   ↓
Database

即使 [USER] 資料根本沒有改變。

假設:100 個 Request,就可能產生:100 次 Database Query,然而這些查詢大部分其實都是重複工作。


2. 加入 Cache 後的概念

加入快取後,流程變成:

第一次 Request
      ↓
檢查 Cache
      ↓
Cache 不存在
      ↓
Database
      ↓
取得完整資料
      ↓
放入 Cache
      ↓
回傳

第二次 Request
      ↓
檢查 Cache
      ↓
Cache 存在
      ↓
直接讀 Memory
      ↓
回傳

假設 Cache 設定:10 分鐘

那麼可能變成:

10 分鐘內 500 個 Request
            ↓
       Database Query
            ↓
          1 次

剩下的:499 次直接從記憶體讀取。


3. ASP.NET Core 的 IMemoryCache

ASP.NET Core 本身提供:IMemoryCache

它會將資料保存於目前 Web Server Process 的記憶體中。

先在 Program.cs 註冊:builder.Services.AddMemoryCache();

Service 中透過 DI 注入:

private readonly IMemoryCache _memoryCache;

public UserService(IMemoryCache memoryCache)
{
    _memoryCache = memoryCache;
}

之後就可以:

_memoryCache.Set(
    "SelectUser",
    userList,
    TimeSpan.FromMinutes(10)
);

取得資料:

if (_memoryCache.TryGetValue(
        "SelectUser",
        out List<SelectUserModel>? users))
{
    return users;
}

4. 最基本的 Cache 寫法

最簡單的實作:

/// <summary>
/// 使用者快取鍵值。
/// </summary>
private const string SelectUserCacheKey =
    "UserService.SelectUser";

/// <summary>
/// 使用者快取時間。
/// </summary>
private static readonly TimeSpan SelectUserCacheExpiration =
    TimeSpan.FromMinutes(10);

/// <summary>
/// 取得完整使用者清單。
/// </summary>
private async Task<List<SelectUserModel>> GetUsersAsync()
{
    /*
     * 快取存在:
     * 直接使用 Cache,不查詢資料庫。
     */
    if (_memoryCache.TryGetValue(
            SelectUserCacheKey,
            out List<SelectUserModel>? cachedUsers) &&
        cachedUsers != null)
    {
        return cachedUsers;
    }

    /*
     * Cache 不存在:
     * 從資料庫取得資料。
     */
    List<SelectUserModel> users =
        await GetSelectUser();

    /*
     * 保存 10 分鐘。
     */
    _memoryCache.Set(
        SelectUserCacheKey,
        users,
        SelectUserCacheExpiration
    );

    return users;
}

概念沒有錯。

但正式環境還有一個很重要的問題。


5. Cache Stampede 是什麼?

假設 Cache 在:10:10:00 剛好過期。

同一瞬間有 20 個 Request 進來。

Request A:Cache 不存在

Request B:Cache 不存在

Request C:Cache 不存在

一直到 Request T:Cache 不存在

結果:

Request A → DB
Request B → DB
Request C → DB
Request D → DB
...
Request T → DB

原本希望:10 分鐘只查一次 DB

結果 Cache 一過期:瞬間查 DB 20 次

這種問題通常稱為:Cache Stampede 或:Thundering Herd


6. 使用 SemaphoreSlim 解決 Cache Stampede

可以使用:SemaphoreSlim 控制同一時間只允許一個 Request 重新載入 Cache。

private static readonly SemaphoreSlim SelectUserCacheLock =
    new SemaphoreSlim(1, 1);

當 Cache 不存在:

Request A
    ↓
取得 Lock
    ↓
查 Database

Request B
    ↓
等待 Lock

Request C
    ↓
等待 Lock

A 完成後:

Request A
    ↓
建立 Cache
    ↓
Release Lock

B 取得 Lock 後,再檢查一次:Cache 已存在

所以:不需要查 Database


7. 正式版 IMemoryCache 實作

以下是一個比較完整的版本。

/// <summary>
/// 完整使用者清單快取鍵值。
/// </summary>
private const string SelectUserCacheKey =
    "UserService.SelectUser";

/// <summary>
/// 使用者清單快取有效時間。
/// </summary>
private static readonly TimeSpan SelectUserCacheExpiration =
    TimeSpan.FromMinutes(10);

/// <summary>
/// 防止 Cache Stampede。
///
/// Cache 過期時,只允許一個 Request
/// 重新從資料庫載入完整使用者資料。
/// </summary>
private static readonly SemaphoreSlim SelectUserCacheLock =
    new SemaphoreSlim(1, 1);

/// <summary>
/// ASP.NET Core Memory Cache。
/// </summary>
private readonly IMemoryCache _memoryCache;

/// <summary>
/// 取得完整使用者清單。
///
/// 行為:
/// 1. Cache 存在 → 直接回傳。
/// 2. Cache 不存在 → 等待載入鎖。
/// 3. 取得鎖後再次確認 Cache。
/// 4. 確認仍不存在才查詢 Database。
/// 5. 建立 Cache。
/// </summary>
private async Task<IReadOnlyList<SelectUserModel>>
    GetFilteredUsersAsync()
{
    /*
     * 第一次 Cache 檢查。
     *
     * 正常情況大部分 Request 都會在這裡直接 return。
     */
    if (_memoryCache.TryGetValue(
            SelectUserCacheKey,
            out IReadOnlyList<SelectUserModel>? cachedUsers
        ) &&
        cachedUsers != null)
    {
        return cachedUsers;
    }

    /*
     * Cache 不存在。
     *
     * 等待重新載入權限。
     */
    await SelectUserCacheLock.WaitAsync();

    try
    {
        /*
         * 第二次 Cache 檢查。
         *
         * 為什麼要檢查兩次?
         *
         * 因為目前 Request 等待 Lock 的期間,
         * 前一個 Request 很可能已經:
         *
         * 1. 查詢 Database
         * 2. 建立 Cache
         * 3. Release Lock
         *
         * 所以取得 Lock 後一定要再次確認。
         */
        if (_memoryCache.TryGetValue(
                SelectUserCacheKey,
                out cachedUsers
            ) &&
            cachedUsers != null)
        {
            return cachedUsers;
        }

        /*
         * 真正查詢資料庫。
         *
         * 同一個 Process 中,
         * 只有取得 SemaphoreSlim 的 Request
         * 可以執行到這裡。
         */
        List<SelectUserModel> databaseUsers =
            await GetSelectUser();

        /*
         * 建立資料快照。
         *
         * 避免後續程式直接修改原始 List。
         */
        List<SelectUserModel> snapshot =
            databaseUsers?
                .Where(user => user != null)
                .ToList()
            ?? new List<SelectUserModel>();

        /*
         * 使用 IReadOnlyList,
         * 避免呼叫端對快取執行:
         *
         * Add
         * Remove
         * Clear
         */
        IReadOnlyList<SelectUserModel> readOnlyUsers =
            snapshot.AsReadOnly();

        /*
         * Cache 設定。
         */
        MemoryCacheEntryOptions options =
            new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow =
                    SelectUserCacheExpiration,

                Priority =
                    CacheItemPriority.Normal
            };

        /*
         * 寫入 Cache。
         */
        _memoryCache.Set(
            SelectUserCacheKey,
            readOnlyUsers,
            options
        );

        return readOnlyUsers;
    }
    finally
    {
        /*
         * 無論成功、失敗、發生 Exception,
         * 都必須釋放 SemaphoreSlim。
         */
        SelectUserCacheLock.Release();
    }
}

這種設計又稱為:Double Check Locking

流程:

Cache ?
  │
  ├─ 有 → return
  │
  └─ 沒有
       ↓
      Lock
       ↓
   再檢查 Cache
       │
       ├─ 有 → return
       │
       └─ 沒有
            ↓
           DB
            ↓
          Cache

8. 為什麼要使用 IReadOnlyList?

一般寫法:List<SelectUserModel> 這有一個風險。

假設 Cache 裡面存:List<SelectUserModel> users ,某個方法取得後執行:users.Clear();

因為這個 List 很可能就是 Cache 裡的同一個 Object。

結果:整個 Cache 被清空,而另一段程式:users.RemoveAt(0); 也可能直接修改 Cache。

因此:IReadOnlyList<SelectUserModel> 可降低這類問題。

例如:

IReadOnlyList<SelectUserModel> users =
    await GetFilteredUsersAsync();

使用端主要只能:

Where
Select
OrderBy
FirstOrDefault
Any
Count

而不能直接:

Add
Remove
Clear

9. 實際搜尋時不要再查 Database

完整清單已經 Cache 後:

public async Task<List<SelectUserModel>> SearchUsersAsync(
    string keyword)
{
    if (string.IsNullOrWhiteSpace(keyword))
    {
        return new List<SelectUserModel>();
    }

    keyword = keyword.Trim();

    IReadOnlyList<SelectUserModel> users =
        await GetFilteredUsersAsync();

    return users
        .Where(user =>
            ContainsIgnoreCase(
                user.LogonID,
                keyword
            ) ||
            ContainsIgnoreCase(
                user.UserName,
                keyword
            ) ||
            ContainsIgnoreCase(
                user.EmployeeNO,
                keyword
            )
        )
        .OrderBy(user => user.UserName)
        .Take(10)
        .ToList();
}

共用字串搜尋:

/// <summary>
/// 不區分英文字母大小寫搜尋字串。
/// </summary>
private static bool ContainsIgnoreCase(
    string? source,
    string keyword)
{
    if (string.IsNullOrWhiteSpace(source) ||
        string.IsNullOrWhiteSpace(keyword))
    {
        return false;
    }

    return source.Contains(
        keyword,
        StringComparison.OrdinalIgnoreCase
    );
}

這樣:

搜尋 Bob
搜尋 John
搜尋 Mary
搜尋 FQ123456

全部都不需要重新查 Database。


10. 多個功能可以共用同一份 Cache

例如系統裡有:

SelectUser
GetChangeUserList
GetApproverList
GetUserSelector
GetSecurityUserList

如果它們底層都需要完整使用者清單,可以共用:

GetFilteredUsersAsync()

例如:

public async Task<List<SelectUserModel>>
    GetChangeUserList()
{
    IReadOnlyList<SelectUserModel> users =
        await GetFilteredUsersAsync();

    HashSet<string> allowedLogonIds =
        new HashSet<string>(
            GetAllowedLogonIds(),
            StringComparer.OrdinalIgnoreCase
        );

    return users
        .Where(user =>
            !string.IsNullOrWhiteSpace(user.LogonID) &&
            allowedLogonIds.Contains(user.LogonID)
        )
        .ToList();
}

因此:

SelectUser
        │
        │
GetChangeUserList
        │
        │
GetApproverList
        │
        ▼
GetFilteredUsersAsync
        │
        ▼
     IMemoryCache
        │
        ▼
Database

只有 Cache 不存在時才會到底層 Database。


11. Absolute Expiration

前面的範例使用:

AbsoluteExpirationRelativeToNow =
    TimeSpan.FromMinutes(10)

意思是:

不論這筆 Cache 有沒有被使用,建立 10 分鐘後一定過期。

例如:

10:00 建立 Cache

10:02 使用
10:05 使用
10:08 使用
10:09 使用

10:10 Cache 過期

即使一直有人使用:10:10 ,仍然會過期。

適合:

  • 員工資料
  • 組織資料
  • 系統設定
  • 權限資料
  • 定期允許同步的主檔

12. Sliding Expiration

另一種方式:

SlidingExpiration =
    TimeSpan.FromMinutes(10)

代表:

最後一次使用後 10 分鐘沒有再被使用,才過期。

例如:

10:00 建立

10:05 使用
→ 延長到 10:15

10:12 使用
→ 延長到 10:22

10:20 使用
→ 延長到 10:30

只要一直有人使用:Cache 可能永遠存在

適合:

  • 不常變動資料
  • 希望熱門資料長時間保留
  • Session-like Cache

但使用者、權限等資料通常不建議單獨使用 Sliding Expiration。

因為資料可能已經修改:DB 已停用某帳號,但 Cache 因為一直有人使用而永遠不過期。


13. Absolute + Sliding 同時使用

可以兩種一起設定:

MemoryCacheEntryOptions options =
    new MemoryCacheEntryOptions
    {
        SlidingExpiration =
            TimeSpan.FromMinutes(5),

        AbsoluteExpirationRelativeToNow =
            TimeSpan.FromMinutes(30)
    };

意思是:5 分鐘沒使用 → 過期

但是:即使一直使用 → 最長 30 分鐘仍一定過期,這很常見的設計。


14. Absolute 與 Sliding 比較

類型AbsoluteSliding
固定時間過期
使用後延長
可保證資料定期刷新
熱門資料可能永久存在
適合權限資料較適合不建議單獨使用
適合一般主檔適合視情況

如果是:使用者清單,我通常建議:

AbsoluteExpirationRelativeToNow =
    TimeSpan.FromMinutes(10)

簡單而且可預期。


15. 主動清除 Cache

不一定要等 10 分鐘。如果系統知道資料已經修改,可以主動清除:

public void ClearSelectUserCache()
{
    _memoryCache.Remove(
        SelectUserCacheKey
    );
}

例如新增使用者:

public async Task UpdateUserAsync(
    UserModel model)
{
    /*
     * 更新 Database。
     */
    await UpdateUserDatabaseAsync(model);

    /*
     * 更新成功後,
     * 立即使 Cache 失效。
     */
    ClearSelectUserCache();
}

下一次 Request:

Cache 不存在
      ↓
重新讀 Database
      ↓
產生新 Cache

這是一個非常實用的模式:

Time-based expiration
+
Event-based invalidation

也就是:

平常 10 分鐘自動刷新;如果系統知道資料被修改,就立即刷新。


16. 為什麼不是修改 Cache 裡的資料?

例如一名員工名字修改:Bob → Robert

理論上可以:cachedUser.UserName = "Robert";

但實務上通常不推薦。因為你很容易漏掉:

  • 其他 Cache
  • 其他欄位
  • 其他 Server
  • 權限資料
  • 關聯資料

比較安全的是:

Database 更新成功
        ↓
Remove Cache
        ↓
下一次重新建立

也就是:

_memoryCache.Remove(
    SelectUserCacheKey
);

17. IMemoryCache 的最大限制:每台 Server 各自一份

這是非常重要的一點。假設系統只有:Web Server A 那:IMemoryCache 非常適合。

但是如果部署:

Load Balancer
      │
 ┌────┴────┐
 ▼         ▼
Server A  Server B

Server A 有自己的:Memory Cache A 與 Server B 有自己的:Memory Cache B 是兩份 Cache 完全獨立。

例如:

Server A
10:00 查 DB
→ Cache A

Server B
10:01 查 DB
→ Cache B

因此:不是整個系統 10 分鐘查一次 DB,而是:每個 Web Server 最多 10 分鐘查一次!

如果有:4 台 Web Server,可能變成:10 分鐘最多 4 次! 通常仍然很少,所以不一定是問題。


18. 多 Server 時可以使用 Distributed Cache

當系統需要:所有 Web Server 共用同一份 Cache

可以使用:Distributed Cache

常見方案:

Redis
SQL Server Distributed Cache

架構:

           Redis
             ▲
             │
      ┌──────┴──────┐
      │             │
Server A          Server B
      │             │
      └──────┬──────┘
             ▼
          Database

Server A 與 Server B 都讀同一份 Redis。


19. IMemoryCache vs Distributed Cache

項目IMemoryCacheDistributed Cache
速度非常快
是否需要外部系統不需要通常需要 Redis / SQL
部署簡單非常簡單較複雜
多 Server 共用不行可以
序列化通常不用通常需要
網路延遲
Server RestartCache 消失通常仍存在
小型系統非常適合可能過度設計
大型多節點系統視需求適合

20. IMemoryCache 最大優點

優點一:速度非常快

Database:

Network
+
SQL Parsing
+
Query Execution
+
Result Transfer

IMemoryCache:直接讀 Process Memory
 

速度差距很大。


優點二:程式簡單

只需要:builder.Services.AddMemoryCache();

不需要:

Redis Server
Connection String
Redis Cluster
Network
Authentication
Monitoring

優點三:非常適合小型主檔

例如:

5,000 員工
500 部門
50 廠區
100 權限項目

這種資料很適合放 Memory。


21. IMemoryCache 缺點

缺點一:吃 Web Server RAM

Cache 本質上就是:RAM,如果亂放大型資料:500MB、1GB、2GB
可能增加:GC 壓力、Memory Pressure、OOM 的壓力,因此不能把所有資料都 Cache。


缺點二:Server Restart 就消失

例如:

Deploy
IIS Recycle
Application Restart
Container Restart

Cache 都會消失。

不過 Cache 本來就不應該被當成永久資料來源。

正確觀念:

Database = Source of Truth

Cache = 加速用副本

Cache 消失後:重新從 DB 建立即可
 


缺點三:多 Server 不共享

這是前面提到最大的架構限制。


22. 哪些資料適合 Cache?

非常適合:

使用者主檔
部門
廠區
Category
系統設定
角色定義
國家
語言
下拉選單
產品分類

共同特色:大量讀取+低頻率更新! 這類資料就是 Cache 最理想的使用情境。


23. 哪些資料不適合長時間 Cache?

例如:

銀行餘額
庫存數量
付款狀態
即時訂單
即時設備狀態
登入失敗次數
安全驗證 Token
即時審批結果

因為這些資料:正確性!  通常比:查詢速度重要。如果 Cache:庫存 = 10 實際 DB:庫存 = 0,就可能發生超賣。


24. 權限資料尤其要小心

權限資料可以 Cache,但時間不宜太長。

例如:使用者 Bob 原本是 Admin 在  10:00 管理員取消 Bob 權限。

但 Bob 的 Cache:10:00 ~ 10:10 仍可能保持:Admin = true

因此權限 Cache 建議:

短效 Cache
+
權限修改時立即 Clear Cache

例如:1~5 分鐘 或更新時:

_memoryCache.Remove(
    GetPermissionCacheKey(logonId)
);

25. Cache Key 設計非常重要

不推薦:

"User"
"Data"
"List"
"Cache"

很容易撞名。

推薦:

UserService.SelectUser
UserService.Profile.Bob
PermissionService.Bob
DepartmentService.All
CategoryService.Hardware

例如:

private const string SelectUserCacheKey =
    "UserService.SelectUser";

如果是每個使用者一份:

private static string GetUserCacheKey(
    string logonId)
{
    return $"UserService.User:{logonId.ToLowerInvariant()}";
}

26. 不同類型 Cache 要分開

例如:完整員工主檔 和:Bob 的權限,不要放成同一個 Cache。因為它們的生命週期不同。

應該:

UserService.Users.All
PermissionService.User:Bob

完整人員:10~30 分鐘 給予權限:1~5 分鐘,這樣比較設置合理。


27. Cache 時間不是越久越好

很多人一看到 Cache 就設定:24 小時!這通常不是好主意。

Cache 時間應該根據:

1.資料多久會改變?

2.錯誤資料可以接受多久?

3.Database Query 成本是多少?

例如:

資料建議概念
國家清單幾小時
系統分類30~60 分鐘
員工主檔5~30 分鐘
部門5~30 分鐘
使用者權限1~5 分鐘
即時庫存不建議普通 Cache
Session Token不應使用一般 Cache 邏輯

這不是絕對值,而是設計思考方向。


28. MemoryCache Priority

可設定:Priority = CacheItemPriority.Normal
 

ASP.NET Core 支援:

Low
Normal
High
NeverRemove

例如:

MemoryCacheEntryOptions options =
    new MemoryCacheEntryOptions
    {
        Priority =
            CacheItemPriority.Normal
    };

一般業務 Cache 建議:Normal

不要動不動使用:NeverRemove

否則記憶體壓力高時,Cache 不容易被清除。


29. 不要假設 Cache 一定存在 10 分鐘

即使設定:TimeSpan.FromMinutes(10)

並不代表一定存活整整 10 分鐘。在 Memory Pressure 等情況下,系統可能提早移除 Cache。

因此程式永遠要能處理:Cache Missing ,並重新建立。不能寫成:Cache 不可能不存在


30. Cache 不是 Database

最重要的設計觀念:

Database
    =
Source of Truth

Cache:

Cache
    =
Temporary Copy

所以系統應該符合:Cache 全部清空,依然可以正常運作。

頂多:第一次查詢比較慢,不能因為 Cache 消失:系統資料就不見


31. Cache Aside Pattern

目前介紹的設計其實就是非常常見的:

Cache-Aside Pattern

流程:

Application
     │
     ▼
Check Cache
     │
 ┌───┴───┐
 │       │
Hit      Miss
 │       │
 ▼       ▼
Return    DB
          │
          ▼
       Set Cache
          │
          ▼
        Return

Application 自己負責:

先查 Cache
Cache 沒有才查 DB
DB 查完放 Cache

這是 ASP.NET Core 最常見、也最好理解的 Cache Pattern。


32. Cache Hit 與 Cache Miss

Cache 領域常看到兩個術語。

Cache Hit

Cache 有資料

例如:

_memoryCache.TryGetValue(...)

取得成功。稱為:

Cache Hit

Cache Miss

Cache 沒有資料:

第一次查詢
Cache 過期
Cache 被 Remove
Server Restart

稱為:

Cache Miss

理想系統通常希望:

Cache Hit Rate

越高越好。


33. GetOrCreateAsync 可以嗎?

ASP.NET Core 也可以寫:

List<SelectUserModel> users =
    await _memoryCache.GetOrCreateAsync(
        SelectUserCacheKey,
        async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow =
                TimeSpan.FromMinutes(10);

            return await GetSelectUser();
        }
    );

程式非常漂亮。

完整版本:

private async Task<List<SelectUserModel>>
    GetUsersAsync()
{
    return await _memoryCache.GetOrCreateAsync(
        SelectUserCacheKey,
        async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow =
                TimeSpan.FromMinutes(10);

            entry.Priority =
                CacheItemPriority.Normal;

            return await GetSelectUser();
        }
    ) ?? new List<SelectUserModel>();
}

34. GetOrCreateAsync 優缺點

優點

非常簡潔:

少量程式碼
容易閱讀
容易維護

適合:

一般 Cache
低併發系統
DB Query 成本不高
 

缺點

如果非常在意:Cache 過期瞬間只能查 DB 一次,仍建議自行搭配:SemaphoreSlim,因為多 Request 同時 Cache Miss 時,需要明確控制資料載入行為。

所以:

一般系統
→ GetOrCreateAsync

高併發 + Query 很重
→ Double Check + SemaphoreSlim

35. 三種常見做法比較

方法一:每次查 Database

Request
  ↓
DB

優點

資料永遠最新
程式簡單
 

缺點

Database 壓力大
重複 Query 多
延遲較高
 

適合:

即時性非常重要、查詢頻率低


方法二:IMemoryCache

Request
  ↓
Memory
  ↓
必要時 DB

優點

最快
簡單
不需要額外 Infrastructure
 

缺點

Server 不共享
Restart 後消失
使用 Server RAM
 

適合:

單機
小型至中型企業系統
低變動主檔
 


方法三:Redis / Distributed Cache

Request
  ↓
Redis
  ↓
必要時 DB

優點

多台 Server 共用
Cache 集中管理
可支援大型系統
 

缺點

Infrastructure 複雜
有 Network latency
需要 Redis 維運
需要 Serialization
 

適合:

多 Server
Container / Kubernetes
大型系統
跨 Service


36. 實際架構選擇

如果系統是:ASP.NET Core + 一台 IIS+SQL Server + 5,000 員工

建議:IMemoryCache  就夠了。

沒必要為了 Cache 特別建立 Redis。

如果系統未來變成:

Load Balancer
        │
 ┌──────┼──────┐
 │      │      │
Web1   Web2   Web3

再考慮:

Redis

通常比較合理。

不要在系統還只有:

一台 Server

就先導入:

Redis Cluster
Distributed Lock
Message Queue

這很可能只是增加維護成本。


37. 完整實務架構建議

以使用者搜尋功能來說,我會設計成:

Controller
    │
    ▼
UserService
    │
    ▼
GetFilteredUsersAsync
    │
    ├──────── Cache Hit
    │             │
    │             ▼
    │           Memory
    │
    └──────── Cache Miss
                  │
                  ▼
             SemaphoreSlim
                  │
                  ▼
               Database
                  │
                  ▼
             IMemoryCache

然後:

SelectUser
GetChangeUserList
GetApproverList

全部共用:GetFilteredUsersAsync

這樣才真正做到:

完整使用者清單在一段時間內,只從資料庫讀取一次。


38. 最後整理

Cache 不只是:_memoryCache.Set(...)

真正需要考慮的是:

資料什麼時候載入?
資料保存多久?
Cache Miss 怎麼處理?
多 Request 同時 Miss 怎麼辦?
資料修改後如何失效?
多台 Server 是否共用?
資料是否允許短暫過期?
Cache 是否可能太大?

對一般企業內部 ASP.NET Core 系統而言,如果資料具有:

讀取非常頻繁
+
修改頻率低
+
資料量可放入 RAM
 

那:

IMemoryCache
+
Absolute Expiration
+
Cache Aside
+
SemaphoreSlim
+
主動 Cache Invalidation

是一套非常實用又不會過度複雜的設計。


39. 一句話理解整套設計

可以把 Cache 想像成辦公桌上的影印資料。

Database 是:檔案室裡的正式文件

IMemoryCache 是:桌上的影本

每次有人問資料,不需要跑去檔案室,直接看桌上的影本即可。

但過一段時間:重新去檔案室拿最新版,如果有人通知:正式文件剛剛修改了,就立刻:丟掉桌上的舊影本! 下一次再重新影印一份。

SemaphoreSlim 解決的是:

文件過期時,不要 20 個人一起衝去檔案室拿同一份文件,只需要一個人去拿,其他人等他把新影本放回桌上。

這就是這套快取設計最核心的概念。