在 .NET 中快取

在本文中,您將認識各種快取機制。 快取是將資料儲存於中繼層的動作,以便加速後續資料擷取。 在概念上而言,快取是一種效能最佳化策略及設計考量。 快取可讓資料無須經常變更 (或耗費大量資源來擷取) ,進而大幅改善應用程式效能。 本文介紹兩種主要的快取類型,並提供兩者的範例原始程式碼:

重要

.NET 中有兩個 MemoryCache 類別,一個位於 System.Runtime.Caching 命名空間,另一個位於 Microsoft.Extensions.Caching 命名空間:

本文著重於快取,其中不含 System.Runtime.Caching NuGet 套件。 MemoryCache 的所有參考皆位於 Microsoft.Extensions.Caching 命名空間。

所有 Microsoft.Extensions.* 套件皆會使用相依性插入 (DI),IMemoryCacheIDistributedCache 介面皆可作為服務。

記憶體內部快取

在本節中,您將了解 Microsoft.Extensions.Caching.Memory 套件。 IMemoryCache 的目前實作為圍繞 ConcurrentDictionary<TKey,TValue> 的包裝函式,用於公開一項功能豐富的 API。 快取內的項目以 ICacheEntry 表示,且可為任何 object。 記憶體內部快取解決方案非常適合用於單一伺服器上執行的應用程式,其中所有快取資料皆會暫用應用程式處理序的記憶體。

提示

若為多個伺服器的快取案例,請考慮使用分散式快取方法作為記憶體內部快取的替代方案。

記憶體內部快取 API

快取的取用者可控制彈性和絕對到期值:

設定到期日將可收回快取中的項目 (若未於到期時程內存取)。 取用者可透過 MemoryCacheEntryOptions 使用其他控制快取項目的選項。 每個 ICacheEntry 皆與 MemoryCacheEntryOptions 配對,其會使用 IChangeToken 公開到期收回的功能、使用 CacheItemPriority 進行優先順序設定,並控制 ICacheEntry.Size。 請考慮下列擴充方法:

記憶體內部快取範例

若要使用預設 IMemoryCache 實作,請呼叫 AddMemoryCache 擴充方法,透過 DI 登錄所有必要服務。 下列程式碼範例使用一般主機來公開 DI 功能:

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMemoryCache();
using IHost host = builder.Build();

您可使用不同方式 (如建構函式插入) 存取 IMemoryCache,視您的 .NET 工作負載而定。 在此範例中,您會在 host 上使用 IServiceProvider 執行個體來呼叫泛型 GetRequiredService<T>(IServiceProvider) 擴充方法:

IMemoryCache cache =
    host.Services.GetRequiredService<IMemoryCache>();

完成登錄記憶體內部快取服務,並透過 DI 進行解析後,便已準備好開始快取。 此範例會從 'A' 到 'Z' 逐一查看英文字母。 record AlphabetLetter 類型會保存字母的參考,並產生訊息。

file record AlphabetLetter(char Letter)
{
    internal string Message =>
        $"The '{Letter}' character is the {Letter - 64} letter in the English alphabet.";
}

提示

file 存取修飾詞用於 AlphabetLetter 型別,因為它是在 Program.cs 檔案中定義的並且只能從其中存取。 如需詳細資訊,請參閱 檔案 (C# 參考)。 若要查看完整的原始程式碼,請參閱 Program.cs 一節。

此範例包含一項協助程式函式,可逐一查看英文字母:

static async ValueTask IterateAlphabetAsync(
    Func<char, Task> asyncFunc)
{
    for (char letter = 'A'; letter <= 'Z'; ++letter)
    {
        await asyncFunc(letter);
    }

    Console.WriteLine();
}

在上述 C# 程式碼中:

  • Func<char, Task> asyncFunc 會於每個反覆項目等候,並傳遞目前的 letter
  • 處理完所有字母後,則會將空白行寫入主控台。

若要將項目新增至快取,請呼叫其中一個 Create,或 Set API:

var addLettersToCacheTask = IterateAlphabetAsync(letter =>
{
    MemoryCacheEntryOptions options = new()
    {
        AbsoluteExpirationRelativeToNow =
            TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
    };

    _ = options.RegisterPostEvictionCallback(OnPostEviction);

    AlphabetLetter alphabetLetter =
        cache.Set(
            letter, new AlphabetLetter(letter), options);

    Console.WriteLine($"{alphabetLetter.Letter} was cached.");

    return Task.Delay(
        TimeSpan.FromMilliseconds(MillisecondsDelayAfterAdd));
});
await addLettersToCacheTask;

在上述 C# 程式碼中:

  • 變數 addLettersToCacheTask 會委派給 IterateAlphabetAsync 並等候。
  • Func<char, Task> asyncFunc 與 Lambda 搭配運作。
  • MemoryCacheEntryOptions 為具現化,包含相對於目前的絕對到期值。
  • 系統會登錄收回後的回呼。
  • AlphabetLetter 物件為具現化,且與 letteroptions 一併傳遞至 Set
  • 該字母會在快取時寫入主控台。
  • 最後會傳回 Task.Delay

針對每個英文字母,快取項目會寫入到期時間及收回後的回呼。

收回後的回呼寫入已收回主控台的值詳細資料:

static void OnPostEviction(
    object key, object? letter, EvictionReason reason, object? state)
{
    if (letter is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{alphabetLetter.Letter} was evicted for {reason}.");
    }
};

現在已填入快取,正等候另一項 IterateAlphabetAsync 呼叫,但這次您將呼叫 IMemoryCache.TryGetValue

var readLettersFromCacheTask = IterateAlphabetAsync(letter =>
{
    if (cache.TryGetValue(letter, out object? value) &&
        value is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{letter} is still in cache. {alphabetLetter.Message}");
    }

    return Task.CompletedTask;
});
await readLettersFromCacheTask;

cache 包含 letter 索引鍵,且 value 是寫入主控台的 AlphabetLetter 執行個體。 當 letter 機碼不在快取中時即已收回,且已叫用其收回後的回呼。

其他擴充方法

IMemoryCache 提供許多便利的擴充方法,包含非同步 GetOrCreateAsync

組合在一起

整個範例應用程式的原始程式碼為最上層程式,需要兩個 NuGet 套件:

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMemoryCache();
using IHost host = builder.Build();

IMemoryCache cache =
    host.Services.GetRequiredService<IMemoryCache>();

const int MillisecondsDelayAfterAdd = 50;
const int MillisecondsAbsoluteExpiration = 750;

static void OnPostEviction(
    object key, object? letter, EvictionReason reason, object? state)
{
    if (letter is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{alphabetLetter.Letter} was evicted for {reason}.");
    }
};

static async ValueTask IterateAlphabetAsync(
    Func<char, Task> asyncFunc)
{
    for (char letter = 'A'; letter <= 'Z'; ++letter)
    {
        await asyncFunc(letter);
    }

    Console.WriteLine();
}

var addLettersToCacheTask = IterateAlphabetAsync(letter =>
{
    MemoryCacheEntryOptions options = new()
    {
        AbsoluteExpirationRelativeToNow =
            TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
    };

    _ = options.RegisterPostEvictionCallback(OnPostEviction);

    AlphabetLetter alphabetLetter =
        cache.Set(
            letter, new AlphabetLetter(letter), options);

    Console.WriteLine($"{alphabetLetter.Letter} was cached.");

    return Task.Delay(
        TimeSpan.FromMilliseconds(MillisecondsDelayAfterAdd));
});
await addLettersToCacheTask;

var readLettersFromCacheTask = IterateAlphabetAsync(letter =>
{
    if (cache.TryGetValue(letter, out object? value) &&
        value is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{letter} is still in cache. {alphabetLetter.Message}");
    }

    return Task.CompletedTask;
});
await readLettersFromCacheTask;

await host.RunAsync();

file record AlphabetLetter(char Letter)
{
    internal string Message =>
        $"The '{Letter}' character is the {Letter - 64} letter in the English alphabet.";
}

您可隨意調整 MillisecondsDelayAfterAddMillisecondsAbsoluteExpiration 值,以便觀察快取項目到期和收回的行為變更。 以下是執行此程式碼的範例輸出。 由於 .NET 事件的不具決定性本質,您的輸出可能會不同。

A was cached.
B was cached.
C was cached.
D was cached.
E was cached.
F was cached.
G was cached.
H was cached.
I was cached.
J was cached.
K was cached.
L was cached.
M was cached.
N was cached.
O was cached.
P was cached.
Q was cached.
R was cached.
S was cached.
T was cached.
U was cached.
V was cached.
W was cached.
X was cached.
Y was cached.
Z was cached.

A was evicted for Expired.
C was evicted for Expired.
B was evicted for Expired.
E was evicted for Expired.
D was evicted for Expired.
F was evicted for Expired.
H was evicted for Expired.
K was evicted for Expired.
L was evicted for Expired.
J was evicted for Expired.
G was evicted for Expired.
M was evicted for Expired.
N was evicted for Expired.
I was evicted for Expired.
P was evicted for Expired.
R was evicted for Expired.
O was evicted for Expired.
Q was evicted for Expired.
S is still in cache. The 'S' character is the 19 letter in the English alphabet.
T is still in cache. The 'T' character is the 20 letter in the English alphabet.
U is still in cache. The 'U' character is the 21 letter in the English alphabet.
V is still in cache. The 'V' character is the 22 letter in the English alphabet.
W is still in cache. The 'W' character is the 23 letter in the English alphabet.
X is still in cache. The 'X' character is the 24 letter in the English alphabet.
Y is still in cache. The 'Y' character is the 25 letter in the English alphabet.
Z is still in cache. The 'Z' character is the 26 letter in the English alphabet.

由於已設定絕對到期值 (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow),因此最終會收回所有快取項目。

背景工作服務快取

快取資料的其中一項常見策略,即是獨立更新取用資料服務的快取。 背景工作服務範本就是個好例子,因為 BackgroundService 執行獨立於其他應用程式程式碼 (或於背景執行)。 當應用程式開始執行託管 IHostedService 的實作時,對應的實作 (在此情況下為 BackgroundService 或「worker」) 會在相同處理序中開始執行。 透過 AddHostedService<THostedService>(IServiceCollection) 擴充方法,這些託管的服務會使用 DI 登錄為單一項目。 其他服務可透過 DI 登錄任何服務存留期

重要

了解服務存留期相當重要。 當您呼叫 AddMemoryCache 以登錄所有的記憶體內部快取服務時,服務會登錄為單一項目。

相片服務案例

假設您正在開發一項相片服務,該服務仰賴透過 HTTP 存取的協力廠商 API。 此相片資料不會頻繁變更,但資料量很大。 每張相片以 record 簡單表示:

namespace CachingExamples.Memory;

public readonly record struct Photo(
    int AlbumId,
    int Id,
    string Title,
    string Url,
    string ThumbnailUrl);

在下列範例中,您將看到使用 DI 登錄的幾項服務。 每個服務都有單一責任。

using CachingExamples.Memory;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<CacheWorker>();
builder.Services.AddHostedService<CacheWorker>();
builder.Services.AddScoped<PhotoService>();
builder.Services.AddSingleton(typeof(CacheSignal<>));

using IHost host = builder.Build();

await host.StartAsync();

在上述 C# 程式碼中:

PhotoService 負責取得符合指定準則 (或 filter) 的相片:

using Microsoft.Extensions.Caching.Memory;

namespace CachingExamples.Memory;

public sealed class PhotoService(
        IMemoryCache cache,
        CacheSignal<Photo> cacheSignal,
        ILogger<PhotoService> logger)
{
    public async IAsyncEnumerable<Photo> GetPhotosAsync(Func<Photo, bool>? filter = default)
    {
        try
        {
            await cacheSignal.WaitAsync();

            Photo[] photos =
                (await cache.GetOrCreateAsync(
                    "Photos", _ =>
                    {
                        logger.LogWarning("This should never happen!");

                        return Task.FromResult(Array.Empty<Photo>());
                    }))!;

            // If no filter is provided, use a pass-thru.
            filter ??= _ => true;

            foreach (Photo photo in photos)
            {
                if (!default(Photo).Equals(photo) && filter(photo))
                {
                    yield return photo;
                }
            }
        }
        finally
        {
            cacheSignal.Release();
        }
    }
}

在上述 C# 程式碼中:

  • 建構函式需要 IMemoryCacheCacheSignal<Photo>ILogger
  • GetPhotosAsync 方法:
    • 定義 Func<Photo, bool> filter 參數,並傳回 IAsyncEnumerable<Photo>
    • 呼叫並等候 _cacheSignal.WaitAsync() 釋出,如此可確保快取會在存取前填入。
    • 呼叫 _cache.GetOrCreateAsync(),以非同步方式取得快取中的所有相片。
    • factory 引數會記錄警告,並傳回空的相片陣列 - 此狀況應絕不會發生。
    • 快取的每張相片皆會逐一查看、篩選,並透過 yield return 具體化。
    • 最後則重設快取訊號。

此服務的取用者可自行呼叫 GetPhotosAsync 方法,並以此方式處理相片。 由於快取包含相片,因此不需要 HttpClient

非同步訊號以封裝的 SemaphoreSlim 執行個體為基礎,在限制為一般類型的單一項目內。 CacheSignal<T> 依賴 SemaphoreSlim 執行個體:

namespace CachingExamples.Memory;

public sealed class CacheSignal<T>
{
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    /// <summary>
    /// Exposes a <see cref="Task"/> that represents the asynchronous wait operation.
    /// When signaled (consumer calls <see cref="Release"/>), the 
    /// <see cref="Task.Status"/> is set as <see cref="TaskStatus.RanToCompletion"/>.
    /// </summary>
    public Task WaitAsync() => _semaphore.WaitAsync();

    /// <summary>
    /// Exposes the ability to signal the release of the <see cref="WaitAsync"/>'s operation.
    /// Callers who were waiting, will be able to continue.
    /// </summary>
    public void Release() => _semaphore.Release();
}

在上述 C# 程式碼中,裝飾項目模式用於包裝 SemaphoreSlim 的執行個體。 由於 CacheSignal<T> 登錄為單一項目,因此可在任何一般類型的所有服務存留期中使用 (在此案例中為 Photo)。 其負責發出快取植入的訊號。

CacheWorkerBackgroundService 的子類別:

using System.Net.Http.Json;
using Microsoft.Extensions.Caching.Memory;

namespace CachingExamples.Memory;

public sealed class CacheWorker(
    ILogger<CacheWorker> logger,
    HttpClient httpClient,
    CacheSignal<Photo> cacheSignal,
    IMemoryCache cache) : BackgroundService
{
    private readonly TimeSpan _updateInterval = TimeSpan.FromHours(3);

    private bool _isCacheInitialized = false;

    private const string Url = "https://jsonplaceholder.typicode.com/photos";

    public override async Task StartAsync(CancellationToken cancellationToken)
    {
        await cacheSignal.WaitAsync();
        await base.StartAsync(cancellationToken);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            logger.LogInformation("Updating cache.");

            try
            {
                Photo[]? photos =
                    await httpClient.GetFromJsonAsync<Photo[]>(
                        Url, stoppingToken);

                if (photos is { Length: > 0 })
                {
                    cache.Set("Photos", photos);
                    logger.LogInformation(
                        "Cache updated with {Count:#,#} photos.", photos.Length);
                }
                else
                {
                    logger.LogWarning(
                        "Unable to fetch photos to update cache.");
                }
            }
            finally
            {
                if (!_isCacheInitialized)
                {
                    cacheSignal.Release();
                    _isCacheInitialized = true;
                }
            }

            try
            {
                logger.LogInformation(
                    "Will attempt to update the cache in {Hours} hours from now.",
                    _updateInterval.Hours);

                await Task.Delay(_updateInterval, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                logger.LogWarning("Cancellation acknowledged: shutting down.");
                break;
            }
        }
    }
}

在上述 C# 程式碼中:

  • 建構函式需要 ILoggerHttpClientIMemoryCache
  • _updateInterval 定義為三小時。
  • ExecuteAsync 方法:
    • 應用程式時執行會迴圈執行。
    • "https://jsonplaceholder.typicode.com/photos" 發出 HTTP 要求,並將其回應對應為一群 Photo 物件。
    • 相片群放置於 "Photos" 機碼下的 IMemoryCache
    • 系統會呼叫 _cacheSignal.Release(),釋出等候訊號的所有取用者。
    • 若為更新間隔,Task.Delay 呼叫則會等候。
    • 延遲三小時後,快取會再次更新。

相同處理序中的取用者可能會要求 IMemoryCache 提供相片,但 CacheWorker 負責更新快取。

分散式快取

某些情況下需要分散式快取,例如:當有多個應用程式伺服器時。 相較於記憶體內部快取方法,分散式快取支援的向外擴充性更高。 使用分散式快取會將快取記憶體卸載至外部處理序,但需要額外的網路 I/O,並產生些許延遲 (即使為標稱)。

分散式快取抽象概念為 NuGet 套件的 Microsoft.Extensions.Caching.Memory 一部分,甚至有 AddDistributedMemoryCache 擴充方法。

警告

AddDistributedMemoryCache 應只用於開發和/或測試案例,且不是實際執行環境的可行實作。

請考慮下列套件中 IDistributedCache 的任何可用實作:

分散式快取 API

相較於對應的記憶體內部快取 API,分散式快取 API 更為原始。 該機碼/值組較為基礎。 記憶體內部快取機碼以 object 為基礎,而分散式金鑰則為 string。 使用記憶體內部快取時,該值可為任何強型別的一般值,分散式快取中的值則會保存為 byte[]。 這不表示各類實作不公開強型別一般值,但此為實作詳細資料。

建立值

若要在分散式快取中建立值,請呼叫其中一個設定的 API:

您可使用記憶體內部快取範例的 AlphabetLetter 記錄,將物件序列化為 JSON,並將 string 編碼為 byte[]

DistributedCacheEntryOptions options = new()
{
    AbsoluteExpirationRelativeToNow =
        TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
};

AlphabetLetter alphabetLetter = new(letter);
string json = JsonSerializer.Serialize(alphabetLetter);
byte[] bytes = Encoding.UTF8.GetBytes(json);

await cache.SetAsync(letter.ToString(), bytes, options);

如同記憶體內部快取,快取項目有些選項可協助微調快取中的內容,在此案例中為 DistributedCacheEntryOptions

建立擴充方法

幾項方便的擴充方法可用於建立值,可協助避免讓 string 物件表示編碼為 byte[]

讀取值

若要讀取分散式快取的值,請呼叫其中一個取得 API:

AlphabetLetter? alphabetLetter = null;
byte[]? bytes = await cache.GetAsync(letter.ToString());
if (bytes is { Length: > 0 })
{
    string json = Encoding.UTF8.GetString(bytes);
    alphabetLetter = JsonSerializer.Deserialize<AlphabetLetter>(json);
}

讀取該快取的快取項目後,則可從 byte[] 取得 UTF8 編碼的 string 表示

讀取擴充方法

幾項方便的擴充方法可用於讀取值,可協助避免讓 string 物件表示編碼為 byte[]

更新值

使用單一 API 呼叫時,無法更新分散式快取中的值,但可使用其中一個重新整理 API,以便重設彈性到期值:

若必須更新實際值,則須刪除該值並重新新增。

刪除值

若要刪除分散式快取的值,請呼叫其中一個移除 API:

提示

上述 API 雖有同步版本,但也請考量到分散式快取實作仰賴網路 I/O。 有鑑於此,建議大多使用非同步 API。

另請參閱