在本文中,你将了解各种缓存机制。 缓存是将数据存储在中间层中的行为,使后续数据检索更快。 从概念上讲,缓存是性能优化策略和设计注意事项。 缓存可以通过使不经常更改(或检索成本高昂)数据更容易获得,从而显著提高应用性能。 本文介绍两种主要类型的缓存,并为这两种缓存提供示例源代码:

Microsoft.Extensions.Caching.Memory

Microsoft.Extensions.Caching.Distributed

重要

.NET 中有两个 MemoryCache 类,一个在 System.Runtime.Caching 命名空间中,另一个在命名空间中 Microsoft.Extensions.Caching :

System.Runtime.Caching.MemoryCache

Microsoft.Extensions.Caching.Memory.MemoryCache

虽然本文重点介绍缓存,但它不包括 System.Runtime.Caching NuGet 包。 对 MemoryCache 的所有引用都在 Microsoft.Extensions.Caching 命名空间中。

所有的Microsoft.Extensions.*包都已经支持依赖项注入(DI),IMemoryCache和IDistributedCache接口都可以用作服务。

内存缓存

在本部分中,你将了解 Microsoft.Extensions.Caching.Memory 包。

IMemoryCache 的当前实现是 ConcurrentDictionary 的包装器,公开功能丰富的 API。 缓存中的条目由 ICacheEntry该条目表示,可以是任意 object项。 内存缓存解决方案非常适合在单个服务器上运行的应用程序,其中所有缓存的数据都占用应用进程中的内存。

小窍门

对于多服务器缓存方案,请考虑 分布式缓存 方法作为内存中缓存的替代方法。

内存缓存 API

缓存的使用者可控制可调过期和绝对过期:

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();

根据 .NET 工作负载,可以采用不同的方式访问 IMemoryCache ,例如构造函数注入。 在此示例中,你在 IServiceProvider 上使用 host 实例,并调用泛型 GetRequiredService(IServiceProvider) 扩展方法:

IMemoryCache cache =

host.Services.GetRequiredService();

注册内存中缓存服务并通过 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 asyncFunc)

{

for (char letter = 'A'; letter <= 'Z'; ++letter)

{

await asyncFunc(letter);

}

Console.WriteLine();

}

在前述 C# 代码中:

Func 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 asyncFunc 使用 lambda 来表示。

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 包:

Microsoft.Extensions.Caching.Memory

Microsoft.Extensions.Hosting

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();

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 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(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();

builder.Services.AddHostedService();

builder.Services.AddScoped();

builder.Services.AddSingleton(typeof(CacheSignal<>));

using IHost host = builder.Build();

await host.StartAsync();

在前述 C# 代码中:

使用 默认值创建通用主机。

内存中缓存服务使用 AddMemoryCache 注册。

使用 HttpClient 为 CacheWorker 类注册了一个 AddHttpClient(IServiceCollection) 实例。

CacheWorker 类使用 AddHostedService(IServiceCollection) 注册。

PhotoService 类使用 AddScoped(IServiceCollection) 注册。

CacheSignal 类使用 AddSingleton 注册。

host 由生成器实例化,并异步启动。

PhotoService 负责获取符合给定条件(或 filter)的照片:

using Microsoft.Extensions.Caching.Memory;

namespace CachingExamples.Memory;

public sealed class PhotoService(

IMemoryCache cache,

CacheSignal cacheSignal,

ILogger logger)

{

public async IAsyncEnumerable GetPhotosAsync(Func? filter = default)

{

try

{

await cacheSignal.WaitAsync();

Photo[] photos =

(await cache.GetOrCreateAsync(

"Photos", _ =>

{

logger.LogWarning("This should never happen!");

return Task.FromResult(Array.Empty());

}))!;

// 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 和 ILogger。

GetPhotosAsync 方法:

定义参数 Func filter 并返回一个 IAsyncEnumerable

调用并等待 _cacheSignal.WaitAsync() 释放,这可确保在访问缓存之前填充缓存。

调用 _cache.GetOrCreateAsync(),异步获取缓存中的所有照片。

该 factory 参数记录一个警告,并返回一个空的照片数组 - 这不应该发生。

缓存中的每个照片都使用 yield return迭代、筛选和具体化。

最后,将重置缓存信号。

此服务的使用者可以自由调用 GetPhotosAsync 方法,并相应地处理照片。 不需要HttpClient,因为缓存已经包含照片。

异步信号基于一个封装的SemaphoreSlim实例,该实例位于一个受泛型类型约束的单例中。

CacheSignal依赖于SemaphoreSlim的一个实例:

namespace CachingExamples.Memory;

public sealed class CacheSignal

{

private readonly SemaphoreSlim _semaphore = new(1, 1);

///

/// Exposes a that represents the asynchronous wait operation.

/// When signaled (consumer calls ), the

/// is set as .

///

public Task WaitAsync() => _semaphore.WaitAsync();

///

/// Exposes the ability to signal the release of the 's operation.

/// Callers who were waiting, will be able to continue.

///

public void Release() => _semaphore.Release();

}

在前面的 C# 代码中,修饰器模式用于包装该 SemaphoreSlim对象的实例。 由于 CacheSignal 注册为单一实例,因此它可以在所有服务生存期中与任何泛型类型(在本例中为 Photo)一起使用。 它负责发出用于初始化缓存的信号。

CacheWorker 是 BackgroundService 的子类。

using System.Net.Http.Json;

using Microsoft.Extensions.Caching.Memory;

namespace CachingExamples.Memory;

public sealed class CacheWorker(

ILogger logger,

HttpClient httpClient,

CacheSignal 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(

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:

IDistributedCache.SetAsync

IDistributedCache.Set

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[]。

DistributedCacheExtensions.SetStringAsync

DistributedCacheExtensions.SetString

读取值

若要从分布式缓存中读取值,请调用其中一个获取 API:

IDistributedCache.GetAsync

IDistributedCache.Get

AlphabetLetter? alphabetLetter = null;

byte[]? bytes = await cache.GetAsync(letter.ToString());

if (bytes is { Length: > 0 })

{

string json = Encoding.UTF8.GetString(bytes);

alphabetLetter = JsonSerializer.Deserialize(json);

}

从缓存读取缓存项后,可以从 string 中获取 UTF8 编码的 byte[] 表示形式

读取扩展方法

有几种基于便捷的扩展方法可用于读取值,有助于避免将 byte[] 解码为对象的 string 表示形式。

DistributedCacheExtensions.GetStringAsync

DistributedCacheExtensions.GetString

更新值

无法通过单个 API 调用更新分布式缓存中的值,而是可以使用其中一个刷新 API 重置其滑动过期时间:

IDistributedCache.RefreshAsync

IDistributedCache.Refresh

如果需要更新实际值,则必须删除该值,然后重新添加该值。

删除值

若要删除分布式缓存中的值,请调用其中一个删除 API:

IDistributedCache.RemoveAsync

IDistributedCache.Remove

小窍门

虽然上述 API 有同步版本,但请考虑分布式缓存的实现依赖于网络 I/O 的事实。 因此,异步 API 更常被优先使用。

另请参阅

.NET 中的依赖关系注入

.NET 通用主机

.NET 中的工作者服务

面向 .NET 开发人员的 Azure

ASP.NET Core 中的内存中缓存

ASP.NET Core 中的分布式缓存