培训
.NET 中的缓存
本文介绍各种缓存机制。 缓存指在中间层中存储数据的行为,该行为可使后续数据检索更快。 从概念上讲,缓存是一种性能优化策略和设计考虑因素。 缓存可以显著提高应用性能,方法是提高不常更改(或检索成本高)的数据的就绪性。 本文介绍两种主要的缓存,并提供这两种的示例源代码:
重要
.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
。 内存中缓存解决方案适用于在单个服务器中运行的应用,其中所有缓存数据在应用进程中租用内存。
提示
对于多服务器缓存场景,请考虑使用分布式缓存方法替代内存中缓存。
缓存的使用者可控制可调过期和绝对过期:
- ICacheEntry.AbsoluteExpiration
- ICacheEntry.AbsoluteExpirationRelativeToNow
- ICacheEntry.SlidingExpiration
设置过期后,如果未在过期时间安排内访问缓存中的项,将导致这些项被逐出。 使用者可通过 MemoryCacheEntryOptions 使用其他选项来控制缓存项。 每个 ICacheEntry 都与 MemoryCacheEntryOptionMemoryCacheEntryOptions 配对,后者使用 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();
可以以不同方式访问 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
对象已实例化,并随letter
和options
一起传递到 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
:
- 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),因此最终将逐出所有缓存项。
缓存数据的一种常见策略是独立于使用数据服务更新缓存。 辅助角色服务模板是一个很好的示例,因为 BackgroundService 独立于其他应用程序代码(或在后台)运行。 当托管 IHostedService 实现的应用程序开始运行时,相应的实现(在这种情况下为 BackgroundService
或“辅助角色”)开始在同一进程中运行。 这些托管服务通过 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 注册。
- 使用 AddHttpClient<TClient>(IServiceCollection) 为
CacheWorker
类注册了一个HttpClient
实例。 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
定义为 3 小时。ExecuteAsync
方法:- 在应用运行时循环。
- 向
"https://jsonplaceholder.typicode.com/photos"
发出 HTTP 请求,将响应映射为一组Photo
对象。 - 照片组放置在
"Photos"
键下的IMemoryCache
中。 _cacheSignal.Release()
被调用,释放任何正在等待信号的使用者。- 根据更新间隔,等待对 Task.Delay 的调用。
- 延迟 3 小时后,缓存将再次更新。
同一进程中的使用者可以向 IMemoryCache
请求获取照片,但 CacheWorker
负责更新缓存。
在某些场景中,需要使用分布式缓存,例如有多个应用服务器的情况。 分布式缓存支持比内存中缓存方法更广的横向扩展。 使用分布式缓存将缓存内存卸载到外部进程,但确实需要额外的网络 I/O 并会引入更多一点的延迟(即使是名义上)。
分布式缓存抽象是 Microsoft.Extensions.Caching.Memory
NuGet 包的一部分,甚至还存在一个 AddDistributedMemoryCache
扩展方法。
注意
AddDistributedMemoryCache 只应在开发和/或测试场景中使用,它不是可行的生产实现。
请考虑以下包中 IDistributedCache
的任何可用实现:
Microsoft.Extensions.Caching.SqlServer
Microsoft.Extensions.Caching.StackExchangeRedis
NCache.Microsoft.Extensions.Caching.OpenSource
分布式缓存 API 比对应的内存中缓存 API 更原始一些。 键值对更基本一些。 内存中缓存键基于 object
,而分布式键基于 string
。 对于内存中缓存,值可以是任何强类型的泛型,而分布式缓存中的值将保存为 byte[]
。 这并不是说各种实现不会公开强类型的泛型值,而是公开实现的详细信息。
若要在分布式缓存中创建值,请调用其中一个 set 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[]
:
若要从分布式缓存读取值,请调用其中一个 Get 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 调用更新分布式缓存中的值,但值可以使用其中一个 Refresh API 重置其可调过期:
如果需要更新实际值,则必须删除值,然后重新添加。
若要删除分布式缓存中的值,请调用其中一个 Remove API:
提示
尽管存在上述 API 的同步版本,但请注意分布式缓存的实现依赖于网络 I/O。 因此,在更多情况下,更倾向于使用异步 API。