C# 中的 MemoryCache使用方法

作者:微信公众号:【架构师老卢】
9-18 18:18
205

MemoryCache 是 C# 应用程序的内存中缓存的标准实现。它有助于提高性能和可扩展性,但代价是内存消耗增加。本文介绍了有关何时以及如何使用 .MemoryCache

我们什么时候应该使用内存缓存?

内存中缓存几乎适用于所有类型的应用程序:移动、服务器端或客户端 Web、云和桌面。当计算或请求是资源密集型或耗时的,并且以后可能会重复使用时,它特别有用。将缓存视为抵御高额计算和云消耗成本的第一道防线。

作为一般准则,如果操作至少具有以下属性中的三个,则应缓存该操作:

  • 体积小,因此不会占用太多内存。
  • 常用。
  • 获得成本高。
  • 随着时间的推移而稳定(不经常变化)。

例如:

  • _产品目录_非常适合缓存,因为它不经常更改,体积小,并且通常需要数十次数据库调用才能构建。
  • _订单列表_很大并且经常更改,因此可能不应缓存它。

ASP.NET Core 中的实际示例:在不缓存的情况下检索货币数据

为了说明缓存的用例,我们将使用一个小型的 ASP.NET Core 应用程序,它显示两种法定货币和两种加密货币的价格。

我们的目标是向网页用户显示以美元计价的当前比特币和以太坊价格数据。因此,我们查询从 CoinCap 提供实时数据的 API 并为用户呈现数据。

模型类中的方法从 HTTP 服务中检索其数据。依赖项是使用主构造函数拉取的。GetCurrencyDataIHttpClientFactory

public class NoCachingModel( IHttpClientFactory httpClientFactory )   
  : BaseModel  
{  
    public async Task<CoinCapData> GetCurrencyData( string id )  
    {  
        using var httpClient = httpClientFactory.CreateClient();  
        var response = await httpClient.GetFromJsonAsync<CoinCapResponse>(   
                               $"https://api.coincap.io/v2/rates/{id}" );  
  
        return response!.Data;  
    }  
}

货币列表在 base model class 中定义。

public class BaseModel : PageModel  
{  
    public IReadOnlyList<string> Currencies { get; } =  
    [  
        "bitcoin",  
        "ethereum",  
        "euro",  
        "british-pound-sterling"  
    ];  
}

以下是显示货币的页面代码:

@page "/"
@using System.Globalization
@model NoCachingModel
@{
    ViewData["Title"] = "Home page";
}

<div class="text-center">
    @{
        foreach (var currency in Model.Currencies)
        {
            var data = await Model.GetCurrencyData( currency );

            // Output a paragraph with symbol and rate.
            @:<p>@( data.Symbol )(@data.Type): 
           @data.RateUsd.ToString( "F3", CultureInfo.InvariantCulture )
             </p>
        }
    }
</div>

顺便说一句,我们已经包含了使用 ASP.NET 中间件的代码来监控呈现页面所需的时间。这将在页面上显示内部处理时间,我们将在整篇文章中引用:

processed in 397 ms.

但是,我们页面的每个视图都会触发来自服务的四次数据检索。这会导致几个问题:

  • 延迟。 在我所在的位置,页面加载时间约为 400 毫秒。此延迟可能会累积,如果不加以解决,可能会将加载时间增加到几秒钟。
  • 成本。 我们的服务器使用 API,这可能会导致更高的云托管成本。
  • 可靠性。 数据提供程序通常会强制实施速率限制。在我们的示例中,Coincap.io 每秒允许大约 200 个请求,这似乎很高。但是,我们的页面每次页面加载都会发出 4 个请求,每秒 50 个请求的峰值是合理的。

我们可以通过雇用 来解决所有这些问题。MemoryCache

MemoryCache 有多快?

MemoryCache_非常快_。缓存的数据驻留在使用它们的同一进程中,并存储为常规 .NET 对象。所做的只是存储对对象的_引用_,将其与键关联并防止它被垃圾回收。MemoryCache

相比之下,对于像 Redis 这样的分布式缓存服务器,缓存位于不同的机器上,通常是专用机器,并在多台服务器之间共享。分布式缓存不存储 .NET 对象,而是存储二进制或文本(通常为 JSON 或 XML)数据。因此,.NET 对象应在存储到缓存中之前进行序列化,并在检索后进行反序列化。使用 ,序列化和反序列化不是必需的。MemoryCache

那么,有多快呢?在我的开发笔记本上,我测量了:MemoryCache

  • TryGetValue.TryGetValue需要 ~50 ns。
  • TryGetValue.Set如果项目被覆盖,则需要 ~110 ns,如果项目是新项目,则需要 ~100 ns。

这意味着,即使您的应用程序每秒和每个实例处理数万个请求,您也不必担心其性能。MemoryCache

MemoryCache 是 C# 最快的缓存吗?

即使它非常快_,也不是 C_# 最快的缓存。但是,它在功能和性能之间提供了良好的平衡。MemoryCache

  • 如果您不想从缓存中删除任何内容,则更快。但是,除非您缓存一个小型数据集,否则您可能会遇到内存不足的风险。ConcurrentDictionary<string,object>
  • 要缓存更多性能关键型数据,构建基于 的缓存键可能会成为性能瓶颈。考虑使用 ConditionalWeakTable,使用延迟初始化Memoization 模式。string

内存缓存的缺点是什么?

  1. 分布式应用程序中的缓存不连贯性。如果您的应用程序部署到多个实例,您将拥有多个内存中缓存,每个缓存位于不同的计算机中,并且需要同步它们。一个节点可能不知道第二个节点所做的更改,即使数据库是一致的,由于它自己的本地缓存,它也不会访问数据库。不连贯的缓存可能会导致用户看到来自不同服务器的不同数据。这是分布式缓存解决的问题之一。
  2. 缓存失效。 与每个缓存实现一样,向 a 添加数据并取回数据是容易的部分,但是当它不再有效时删除它可能非常具有挑战性。MemoryCache
  3. 可变对象的危险。 由于不序列化/反序列化对象,因此一个常见的错误是将可变对象添加到缓存中。您只能将不可变对象添加到缓存中,否则您将为数小时的痛苦调试做好准备。MemoryCache

如何将 MemoryCache 添加到 ASP.NET Core 应用程序?

该类包含在包中。此软件包自动包含在 ASP.NET Core 应用程序中。如果要构建不同类型的应用程序,则可能需要将此包添加到项目中。通常,我们不会直接针对类编写代码,而是针对 的 抽象编写代码。MemoryCacheMicrosoft.Extensions.Caching.MemoryMemoryCacheIMemoryCache

在最低级别,该接口提供以下基本方法:IMemoryCache

  • ICacheEntry CreateEntry(object key)- 在给定缓存键的缓存中添加一个条目。如果该条目已存在,则将其覆盖。
  • bool TryGetValue(object key, out object? value)- 检索给定键的缓存中存储的值(如果存在)。
  • void Remove(object key)- 删除给定键的条目。

大多数时候,我们不会在代码中使用这些基本方法,而是使用 high level 或 extension 方法。给定一个缓存键,这些方法将:GetOrCreateGetOrCreateAsync

  1. 检查缓存是否包含我们感兴趣的结果。如果是,请将其返回给调用方。
  2. 调用逻辑以获取数据。
  3. 将数据添加到缓存中。

步骤 1.初始化 MemoryCache

首先,我们需要将内存缓存组件添加到我们的服务集合中。

在 中,将内存缓存添加到如下所示:Program.csWebApplicationBuilder

builder.Services.AddMemoryCache();

步骤 2。注入 IMemoryCache

下一步是将 注入到你的页面或模型中。IMemoryCache

如果要直接将代码添加到页面,请使用以下指令将 注入到我们的页面模型中:IMemoryCache

@inject IMemoryCache Cache

如果要编写纯 C# 而不是 Razor,请拉取该服务。IMemoryCache

public class WithMemoryCacheModel(   
 IHttpClientFactory httpClientFactory, IMemoryCache memoryCache )   
 : BaseModel

步骤 3。将缓存逻辑添加到方法中

现在,您可以通过添加对 .IMemoryCache.GetOrCreateAsync

一种方便的模式是将原始方法逻辑移动到本地函数,如下面的代码片段所示。

public async Task<CoinCapData> GetCurrencyData( string id )
{
    return (await memoryCache.GetOrCreateAsync(
        $"{this.GetType().Name}.GetCurrencyData({id})",
        _ => GetData() ))!;

    async Task<CoinCapData> GetData()
    {
        using var httpClient = httpClientFactory.CreateClient();
        var response = await httpClient.GetFromJsonAsync<CoinCapResponse>( 
                                 $"https://api.coincap.io/v2/rates/{id}" );

        return response!.Data;
    }
}

每次调用该方法时,都会检查缓存。如果数据可用,则检索数据。否则,将调用本地函数,并将结果存储在缓存中。当用户访问我们的网站时,第一个请求会使用 的结果填充缓存,所有后续请求都会使用此数据。这种方法可显著降低用户延迟、降低 CPU 使用率并降低外部 API 使用率。因此,我们改善了用户体验,节省了成本,并增强了可靠性,所有这些都是为了一个小的内存权衡。GetData

请务必注意,所有方法参数都应添加到缓存键中。此外,我们需要确保我们方法的缓存键不会与其他方法的缓存键发生冲突。

运行该应用程序,我们可以看到示例的本地延迟现在已经从之前的 397 毫秒下降到 1 毫秒。

如何使缓存条目过期?

在上面的示例中,我们成功地改善了服务的延迟和可靠性。但是,我们无意中使其变得无用,因为它现在显示不变价格。我们已将数据添加到缓存中,但尚未实现任何刷新数据的机制。此机制称为 过期 或 过期。

假设我们希望数据最多为 30 秒。

在 中,我们追求的机制称为_绝对过期_。我们可以通过调用 provide 的对象的方法来设置它。MemoryCacheSetAbsoluteExpirationICacheEntryGetOrCreateAsync

public async Task<CoinCapData> GetCurrencyData( string id )  
{  
    return  
        (await memoryCache.GetOrCreateAsync(  
            $"{this.GetType().Name}.{id}",  
            async entry =>  
            {  
                entry.SetAbsoluteExpiration( TimeSpan.FromSeconds( 30 ) );  
  
                return await GetData();  
            } ))!;  
  
    async Task<CoinCapData> GetData()  
    {  
        using var httpClient = httpClientFactory.CreateClient();  
        var response = await httpClient.GetFromJsonAsync<CoinCapResponse>(  
                            $"https://api.coincap.io/v2/rates/{id}" );  
  
        return response!.Data;  
    }  
}

现在,将在创建条目大约 30 秒后删除该条目。大多数请求仍将具有 1 毫秒的延迟,但每 30 秒一个请求会很慢,因为它需要命中货币服务。MemoryCache

如何减少代码与缓存的强耦合?

对应用程序进行硬编码以使用并不总是最佳方法。如果将来可能想要更改缓存策略,请考虑在源代码和缓存组件之间使用中间层。IMemoryCache

Polly 是一个常用的 .NET 包,可帮助处理暂时性故障并增强应用程序弹性。其中一个可用策略是 ,它在内部使用 .Polly 提供了其他几个有用的策略。例如,如果操作失败,您可能希望重试该操作。对于 Polly,这很简单。Polly 还可以更轻松地从分布式缓存切换到分布式缓存。Polly.Caching.MemoryIMemoryCacheMemoryCache

让我们看看如何使用 Polly 为您的应用程序添加缓存。

步骤 1.添加 Polly 并配置缓存策略

要进行设置,我们需要在应用程序初始化期间添加以下代码:

builder.Services
  .AddSingleton<IAsyncCacheProvider, MemoryCacheProvider>();
builder.Services
 .AddSingleton<IReadOnlyPolicyRegistry<string>, PolicyRegistry>(
    serviceProvider =>
    {
        var cachingPolicy = Policy.CacheAsync(
            serviceProvider.GetRequiredService<IAsyncCacheProvider>(),
            TimeSpan.FromMinutes(0.5));

        var registry = new PolicyRegistry 
                                    { ["defaultPolicy"] = cachingPolicy };

        return registry;
    });

步骤 2。将策略注入到您的页面、模型或服务中

接下来,我们需要将 resilience 策略合并到我们的模型中:

private readonly IAsyncPolicy _cachePolicy;
private IHttpClientFactory _httpClientFactory;

public Step4Model( IReadOnlyPolicyRegistry<string> policyRegistry,
                  IHttpClientFactory httpClientFactory )
{
    this._httpClientFactory = httpClientFactory;
    this._cachePolicy = policyRegistry.Get<IAsyncPolicy>( "defaultPolicy" );
}

步骤 3。从方法中调用 Polly 策略

最后,我们可以在我们的方法中使用 cache 策略:GetCurrencyData

public async Task<CoinCapData> GetCurrencyData( string id )
{
    return await this._cachePolicy.ExecuteAsync(
        async _ => await GetData(),
        new Context( $"{this.GetType().Name}.GetCurrencyData({id})" ) );

    async Task<CoinCapData> GetData()
    {
        using var httpClient = this._httpClientFactory.CreateClient();
        var response = await httpClient.GetFromJsonAsync<CoinCapResponse>(
                                $"https://api.coincap.io/v2/rates/{id}" );

        return response!.Data;
    }
}

如果以后想要添加一些弹性功能,只需编辑初始化代码即可。在下一个代码段中,我们将向缓存策略添加重试策略。

builder.Services
   .AddSingleton<IAsyncCacheProvider, MemoryCacheProvider>();

builder
   .Services.AddSingleton<IReadOnlyPolicyRegistry<string>, PolicyRegistry>(
    serviceProvider =>
    {
        var cachingPolicy = Policy.CacheAsync(
            serviceProvider.GetRequiredService<IAsyncCacheProvider>(),
            TimeSpan.FromMinutes( 0.5 ) );

        var retryPolicy = Policy.Handle<Exception>().RetryAsync();

        var policy = Policy.WrapAsync( cachingPolicy, retryPolicy );

        var registry = new PolicyRegistry { ["defaultPolicy"] = policy };

        return registry;
    } );

替代方案:EasyCaching

直接在代码中使用的另一种替代方法是 EasyCaching。EasyCaching 的主要优点是它为多个缓存服务器提供实现,使您可以根据需要更轻松地迁移到分布式缓存。它还处理 .NET 对象从二进制或文本数据到二进制或文本数据的序列化和反序列化。MemoryCache

如何通过缓存避免样板代码?

您可能已经注意到,上面的示例需要大量的重复代码,因为每个方法都需要包装在委托调用中并生成缓存键。

通过使用 Metalama(一种用于 C# 的代码生成和验证工具包),可以消除这种重复的代码。如果您只想将其用于缓存,免费版就足够了。

有两种方法可以使用 Metalama 实现缓存。

一种是手动编写代码模板(称为 aspect)。这超出了本文的范围,您可以查看缓存示例以了解更多信息。

第二种方法是使用已经实现该方面的 Metalama.Patterns.Caching.Aspects 包。

初始设置很简单,在我们的Program.cs只需要两行代码:

builder.Services.AddMetalamaCaching();

然后,使用 the 属性标记方法:[Cache]

[Cache( AbsoluteExpiration = 0.5 )]  
public async Task<CoinCapData> GetCurrencyData( string id )  
{  
    using var httpClient = httpClientFactory.CreateClient();  
    var response = await httpClient.GetFromJsonAsync<CoinCapResponse>(  
                              $"https://api.coincap.io/v2/rates/{id}" );  
  
    return response!.Data;  
}

就这样!在构建项目时,Metalama 会生成所有必要的代码,而无需我们修改方法中的任何内容。

与 EasyCaching 一样,它与缓存提供程序无关。它还处理序列化,如果你尝试缓存不可缓存的内容(例如,枚举器),你的构建将失败。与其他解决方案不同,它处理了生成缓存密钥的麻烦。Metalama.Patterns.Caching

MemoryCache是内存中缓存的 C# 实现。您可以使用它来提高应用程序性能并减少其资源消耗,但代价是内存使用量增加。我们讨论了不适合使用缓存的情况 - 要么是由于操作的性质,要么是由于部署的拓扑。

尽管您可以直接在代码中使用,但如果您想保持源代码的健壮性和可维护性,这可能不是最佳选择。使用 Polly、EasyCache 或 Metalama Caching 等中间层将使将来的代码发展变得更加容易。此外,Metalama 显著减少了您需要编写的代码量,因为它归结为单个自定义属性。

相关留言评论
昵称:
邮箱:
阅读排行