在本文中,您將了解各種快取機制。 將資料儲存在中繼層的過程稱為快取,使後續的資料擷取更快速。 就概念上而言,快取是一種效能優化策略以及設計上的考量。 快取可大幅改善應用程式效能,方法是讓不常變更或擷取成本高的數據更容易存取。 本文介紹兩種主要快取類型,並提供兩者的範例原始碼:
這很重要
.NET 內有兩 MemoryCache
個類別,一個在 System.Runtime.Caching
命名空間中,另一個在 命名空間中 Microsoft.Extensions.Caching
:
雖然本文著重於快取,但不包含 System.Runtime.Caching
NuGet 套件。 對MemoryCache
的所有參考都在Microsoft.Extensions.Caching
命名空間內。
所有 Microsoft.Extensions.*
套件都已準備好用於相依性注入(DI),IMemoryCache 和 IDistributedCache 介面都可以作為服務使用。
記憶體緩存
在本節中,您將瞭解 Microsoft.Extensions.Caching.Memory 套件。 目前的IMemoryCache實作是ConcurrentDictionary<TKey,TValue>的包裝,公開了一個功能豐富的API。 快取中的項目由ICacheEntry表示,而且可以是任何object
。 記憶體內部快取解決方案非常適合在單一伺服器上執行的應用程式,其中所有快取的數據都會租用應用程式進程中的記憶體。
小提示
針對多伺服器快取案例,請考慮 分散式 快取方法作為記憶體內部快取的替代方案。
內存快取 API
快取的取用者可控制滑動和絕對到期:
- ICacheEntry.AbsoluteExpiration
- ICacheEntry.AbsoluteExpirationRelativeToNow
- ICacheEntry.SlidingExpiration
如果快取中的條目未在過期時間內被存取,設置過期時間將導致快取中的條目被收回。 消費者有額外的選項來控制快取項目,透過 MemoryCacheEntryOptions。 每一個 ICacheEntry 都與 MemoryCacheEntryOptions 配對,公開具 IChangeToken 的到期收回功能、使用 CacheItemPriority 設定優先順序,並控制 ICacheEntry.Size。 請考慮下列擴充方法:
- MemoryCacheEntryExtensions.AddExpirationToken
- MemoryCacheEntryExtensions.RegisterPostEvictionCallback
- MemoryCacheEntryExtensions.SetSize
- MemoryCacheEntryExtensions.SetPriority
記憶體內部快取範例
若要使用預設 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();
視您的 .NET 工作負載而定,您可能會以不同的方式存取 IMemoryCache
,例如建構函式插入。 在此範例中,您會在 IServiceProvider
上使用 host
實例,並呼叫泛型 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
類型使用了 存取修飾詞,因為它是定義在其中且僅從該檔案進行存取。 如需詳細資訊,請參閱檔案(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
,並等候。 - 透過 lambda 與
Func<char, Task> asyncFunc
進行討論。 -
MemoryCacheEntryOptions
的建立是基於相對現在的絕對到期時間。 - 註冊了驅逐後的回呼函數。
- 一個
AlphabetLetter
物件被實例化,並與Set和letter
一起傳遞至options
。 - 信件會在快取時寫入主控台。
- 最後, 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
:
- CacheExtensions.Get
- CacheExtensions.GetOrCreate
- CacheExtensions.GetOrCreateAsync
- CacheExtensions.Set
- CacheExtensions.TryGetValue
把所有東西放在一起
整個範例應用程式原始程式碼是最上層的程式,而且需要兩個 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.";
}
您可以隨意調整 MillisecondsDelayAfterAdd
和 MillisecondsAbsoluteExpiration
值,以觀察快取專案到期和收回的行為變更。 以下是執行此程式碼的範例輸出。 由於 .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),所有快取項目最終都會被清除。
背景工作服務快取
快取數據的其中一個常見策略是從取用的數據服務獨立更新快取。
Worker Service 範本是一個絕佳的範例,因為 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# 程式碼中:
- 使用 預設值建立泛型主機。
- 記憶體內部快取服務已註冊於 AddMemoryCache。
- 為
HttpClient
類別註冊CacheWorker
的AddHttpClient<TClient>(IServiceCollection)實例。 - 類別
CacheWorker
已向 AddHostedService<THostedService>(IServiceCollection) 註冊。 - 類別
PhotoService
已向 AddScoped<TService>(IServiceCollection) 註冊。 - 類別
CacheSignal<T>
已向 AddSingleton 註冊。 -
host
會從生成器實例化,並以異步方式啟動。
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# 程式碼中:
- 建構函式需要
IMemoryCache
、CacheSignal<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
。 它負責發出快取植入的訊號。
CacheWorker
是 的BackgroundService子類別:
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# 程式碼中:
- 建構函式需要
ILogger
、HttpClient
和IMemoryCache
。 -
_updateInterval
的定義時間為三小時。 -
ExecuteAsync
方法:- 在應用程式執行時迴圈。
- 向
"https://jsonplaceholder.typicode.com/photos"
發出 HTTP 請求,並將回應映射為物件陣列Photo
。 - 相片陣列放在
IMemoryCache
鍵底下的"Photos"
中。 - 當
_cacheSignal.Release()
被呼叫時,釋放所有正在等待信號的消費者。 - 由於更新間隔,對 Task.Delay 的呼叫正在等待。
- 延遲三個小時後,快取更新至最新狀態。
相同流程中的使用者可以要求IMemoryCache
相片,但CacheWorker
負責更新快取。
分散式快取
在某些情況下,需要分散式快取,例如多個應用程式伺服器的情況。 分散式快取支援比記憶體內部快取方法更高的向外延展。 使用分散式快取會將快取記憶體卸除至外部進程,但確實需要額外的網路 I/O,並引入更多延遲(即使名義上)。
分散式快取抽象概念是 NuGet 套件的 Microsoft.Extensions.Caching.Memory
一部分,甚至還有擴充 AddDistributedMemoryCache
方法。
謹慎
AddDistributedMemoryCache應該只用於開發和/或測試案例,而且不是可行的生產實作。
請考慮下列套件中任何可用的 實作 IDistributedCache
:
Microsoft.Extensions.Caching.SqlServer
Microsoft.Extensions.Caching.StackExchangeRedis
NCache.Microsoft.Extensions.Caching.OpenSource
分散式快取 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);
}
當快取項目讀出後,您可以從 string
取得 UTF8 編碼的 byte[]
表示形式。
讀取擴充方法
有數種基於便利性的擴充方法可用來讀取值,有助於避免將byte[]
解碼為string
的物件表示法。
更新值
您無法使用單一 API 呼叫來更新分散式快取中的值,但值可以使用其中一個重新整理 API 重設其滑動到期日:
如果需要更新實際值,您必須刪除該值,然後重新新增該值。
刪除值
若要刪除分散式快取中的值,請呼叫其中一個移除 API:
小提示
雖然上述 API 有同步版本,但請考慮分散式快取實作相依於網路 I/O 的事實。 基於這個理由,一般來說,通常偏好使用異步 API。