Gyorsítótárazás a .NET-ben

Ebben a cikkben megismerheti a különböző gyorsítótárazási mechanizmusokat. A gyorsítótárazás az adatok közbenső rétegben való tárolásának művelete, amely felgyorsítja a későbbi adatlekéréseket. A gyorsítótárazás elméletileg teljesítményoptimalizálási stratégia és tervezési szempont. A gyorsítótárazás jelentősen javíthatja az alkalmazások teljesítményét azáltal, hogy a ritkán változó (vagy költséges) adatok könnyebben elérhetővé válnak. Ez a cikk bemutatja a gyorsítótárazás két elsődleges típusát, és mindkettőhöz biztosít minta forráskódot:

Fontos

A .NET-ben két MemoryCache osztály található, az egyik a System.Runtime.Caching névtérben, a másik a Microsoft.Extensions.Caching névtérben:

Bár ez a cikk a gyorsítótárazással foglalkozik, nem tartalmazza a System.Runtime.Caching NuGet-csomagot. Minden hivatkozás MemoryCache a Microsoft.Extensions.Caching névtérben található.

Az összes csomag készen áll a Microsoft.Extensions.* függőséginjektálásra (DI), mind az IMemoryCacheIDistributedCache interfészek használhatók szolgáltatásként.

Memóriában való gyorsítótárazás

Ebben a szakaszban megismerheti a Microsoft.Extensions.Caching.Memory csomagot. A jelenlegi implementáció IMemoryCache egy burkoló a ConcurrentDictionary<TKey,TValue>funkciógazdag API körül. A gyorsítótárban lévő bejegyzéseket a ICacheEntry, és bármely object. A memórián belüli gyorsítótárazási megoldás kiválóan alkalmas olyan alkalmazásokhoz, amelyek egyetlen kiszolgálón futnak, ahol az összes gyorsítótárazott adat memóriát ad ki az alkalmazás folyamatában.

Tipp.

Többkiszolgálós gyorsítótárazási forgatókönyvek esetén az elosztott gyorsítótárazási módszert érdemes a memóriabeli gyorsítótárazás alternatívaként használni.

Memóriabeli gyorsítótárazási API

A gyorsítótár fogyasztója a csúszás és az abszolút lejárat felett is szabályozhatja a következő műveleteket:

A lejárat beállítása miatt a gyorsítótár bejegyzései törlődnek, ha nem férnek hozzá a lejárati idő kiosztott időszakán belül. A felhasználók további lehetőségeket is használhatnak a gyorsítótárbejegyzések szabályozására a MemoryCacheEntryOptions. Mindegyik ICacheEntry párosítva MemoryCacheEntryOptions van, amellyel elérhetővé teszi a lejárati kiürítési funkciót a prioritási beállításokkal IChangeTokenés a ICacheEntry.SizevezérlővelCacheItemPriority. Vegye figyelembe a következő bővítménymetelyeket:

Példa a memóriabeli gyorsítótárra

Az alapértelmezett IMemoryCache implementáció használatához hívja meg a AddMemoryCache bővítménymetódust, hogy regisztrálja az összes szükséges szolgáltatást a DI-ben. A következő kódmintában az általános gazdagépet használják a DI-funkciók elérhetővé fogadására:

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

A .NET-alapú számítási feladattól függően előfordulhat, hogy másként éri el azokat, például konstruktorinjektálást IMemoryCache . Ebben a példában a példányt IServiceProvider használja, és hívja meg az host általános GetRequiredService<T>(IServiceProvider) bővítménymetódust:

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

A memóriabeli gyorsítótárazási szolgáltatások regisztrálva és a DI-n keresztül oldva készen állnak a gyorsítótárazásra. Ez a minta az "A" betűvel a Z betűn keresztül halad végig. A record AlphabetLetter típus tartalmazza a levélre mutató hivatkozást, és létrehoz egy üzenetet.

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

Tipp.

A file hozzáférési módosító a típuson AlphabetLetter van használva, mivel az a Program.cs fájlon belül van definiálva, és csak azokból érhető el. További információ: fájl (C# referencia). A teljes forráskód megtekintéséhez tekintse meg a Program.cs szakaszt.

A minta tartalmaz egy segédfüggvényt, amely az ábécé betűivel halad végig:

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

    Console.WriteLine();
}

Az előző C# kódban:

  • Az Func<char, Task> asyncFunc egyes iterációkra vár, és átadja az aktuálisat letter.
  • Az összes betű feldolgozása után a rendszer egy üres sort ír a konzolra.

Ha elemeket szeretne hozzáadni a gyorsítótárhoz, hívja meg az Createegyik , vagy Set API-t:

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;

Az előző C# kódban:

  • A változó addLettersToCacheTask delegáltjai a következőre IterateAlphabetAsync várnak:
  • A Func<char, Task> asyncFunc lambdával érvelnek.
  • A MemoryCacheEntryOptions példányosított példány abszolút lejárattal rendelkezik a jelenleg érvényeshez képest.
  • A rendszer regisztrál egy kilakoltatás utáni visszahívást.
  • Az AlphabetLetter objektum példányosítva lesz, és át lesz adva Set a következővel letter együtt: és options.
  • A rendszer gyorsítótárazottként írja a levelet a konzolra.
  • Végül egy Task.Delay lesz visszaadva.

Az ábécé minden betűje esetében a gyorsítótárbejegyzés lejárattal és a kilakoltatás utáni visszahívással lesz megírva.

A kilakoltatás utáni visszahívás a konzolra kiürített érték részleteit írja le:

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

Most, hogy a gyorsítótár feltöltődött, egy újabb hívásra IterateAlphabetAsync vár, de ezúttal a következőt fogja hívni 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;

Ha a cache kulcs tartalmazza a letter kulcsot, és az value egy, a konzolra írt példány AlphabetLetter . Ha a letter kulcs nincs a gyorsítótárban, a kulcs ki lett állítva, és a kilakoltatás utáni visszahívása meg lett hívva.

További bővítménymetelyek

Számos IMemoryCache kényelmi alapú bővítménymetalógussal rendelkezik, köztük aszinkron GetOrCreateAsync:

Az alkalmazás összeállítása

A teljes mintaalkalmazás-forráskód egy legfelső szintű program, és két NuGet-csomagot igényel:

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.";
}

Nyugodtan módosíthatja az MillisecondsDelayAfterAdd értékeket, MillisecondsAbsoluteExpiration hogy megfigyelje a viselkedés változásait a gyorsítótárazott bejegyzések lejáratához és kiürítéséhez. Az alábbi mintakimenet a kód futtatásából származik. A .NET-események nem determinisztikus jellege miatt a kimenet eltérő lehet.

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.

Mivel az abszolút lejárat (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow) be van állítva, az összes gyorsítótárazott elem végül törlődik.

Feldolgozói szolgáltatás gyorsítótárazása

Az adatok gyorsítótárazásának egyik gyakori stratégiája a gyorsítótár frissítése a felhasználó adatszolgáltatásoktól függetlenül. A Worker Service-sablon kiváló példa, mivel a BackgroundService futtatás független (vagy a háttérben) a másik alkalmazáskódtól. Amikor egy alkalmazás elindul, amely a IHostedServicevégrehajtást üzemelteti, a megfelelő implementáció (ebben az esetben a BackgroundService "feldolgozó") ugyanabban a folyamatban fog futni. Ezeket a üzemeltetett szolgáltatásokat a bővítménymetóduson keresztül AddHostedService<THostedService>(IServiceCollection) regisztráljuk a DI-ben önállóan. Más szolgáltatások bármilyen szolgáltatási élettartammal regisztrálhatók a DI-ben.

Fontos

A szolgáltatás élettartamát nagyon fontos megérteni. Amikor meghívja AddMemoryCache az összes memóriabeli gyorsítótárazási szolgáltatás regisztrálását, a szolgáltatások egyszeriként lesznek regisztrálva.

Fotószolgáltatás forgatókönyve

Tegyük fel, hogy olyan fotószolgáltatást fejleszt, amely a HTTP-n keresztül elérhető külső API-ra támaszkodik. Ezek a fényképadatok nem változnak túl gyakran, de sok van belőle. Minden fényképet egy egyszerű record:

namespace CachingExamples.Memory;

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

Az alábbi példában számos szolgáltatás regisztrálva lesz a DI-ben. Minden szolgáltatásnak egyetlen felelőssége van.

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

Az előző C# kódban:

A PhotoService megadott feltételeknek (vagy 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();
        }
    }
}

Az előző C# kódban:

  • A konstruktorhoz egy IMemoryCache, CacheSignal<Photo>és ILogger.
  • A GetPhotosAsync módszer:
    • Definiál egy paramétert Func<Photo, bool> filter , és visszaad egy IAsyncEnumerable<Photo>.
    • Hívások és várakozás a _cacheSignal.WaitAsync() kiadásra, ez biztosítja, hogy a gyorsítótár a gyorsítótár elérése előtt fel legyen töltve.
    • Hívások _cache.GetOrCreateAsync(), aszinkron módon leküldi az összes fényképet a gyorsítótárba.
    • Az factory argumentum naplóz egy figyelmeztetést, és egy üres fényképtömböt ad vissza – ez soha nem fordulhat elő.
    • A gyorsítótárban lévő összes fénykép iterated, filtered és materialized with yield return.
    • Végül a gyorsítótár jelét alaphelyzetbe állítja.

A szolgáltatás felhasználói szabadon felhívják GetPhotosAsync a metódust, és ennek megfelelően kezelik a fényképeket. Nem HttpClient szükséges, mivel a gyorsítótár tartalmazza a fényképeket.

Az aszinkron jel egy beágyazott SemaphoreSlim példányon alapul, egy általános típusú korlátozott egytonnán belül. A CacheSignal<T> következő példányra támaszkodik 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();
}

Az előző C#-kódban a dekorátorminta a SemaphoreSlim. Mivel a CacheSignal<T> rendszer egyszeriként van regisztrálva, a szolgáltatás minden élettartama alatt használható bármilyen általános típussal – ebben az esetben a Photo. Feladata a gyorsítótár vetésének jelzése.

A CacheWorker következő alosztálya 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;
            }
        }
    }
}

Az előző C# kódban:

  • A konstruktorhoz egy ILogger, HttpClientés IMemoryCache.
  • A _updateInterval beállítás három órán keresztül van definiálva.
  • A ExecuteAsync módszer:
    • Hurkok az alkalmazás futtatása közben.
    • HTTP-kérést készít, "https://jsonplaceholder.typicode.com/photos"és objektumtömbként leképezi Photo a választ.
    • A fényképek tömbje a IMemoryCache kulcs alá "Photos" kerül.
    • A _cacheSignal.Release() hívás, felszabadítja a fogyasztók, akik vártak a jel.
    • A hívásra Task.Delay a frissítési időköz miatt várni kell.
    • Három óra késleltetés után a gyorsítótár ismét frissül.

Az ugyanabban a folyamatban lévő felhasználók kérhetik a IMemoryCache fényképeket, de a gyorsítótár frissítéséért a CacheWorker felelős.

Elosztott gyorsítótárazás

Bizonyos esetekben elosztott gyorsítótárra van szükség – ez több alkalmazáskiszolgáló esetében is így van. Az elosztott gyorsítótárak a memóriabeli gyorsítótárazási módszernél nagyobb mértékű felskálázást támogatnak. Az elosztott gyorsítótár használata kiosztja a gyorsítótár memóriáját egy külső folyamatba, de extra hálózati I/O-t igényel, és egy kicsit nagyobb késést eredményez (még akkor is, ha névleges).

Az elosztott gyorsítótárazási absztrakciók a Microsoft.Extensions.Caching.Memory NuGet-csomag részét képezik, és még egy AddDistributedMemoryCache bővítménymetódus is létezik.

Figyelemfelhívás

Ez AddDistributedMemoryCache csak fejlesztési és/vagy tesztelési forgatókönyvekben használható, és nem életképes éles megvalósítás.

Vegye figyelembe az alábbi csomagok bármelyik elérhető implementációját IDistributedCache :

Elosztott gyorsítótárazási API

Az elosztott gyorsítótárazási API-k valamivel primitívebbek, mint a memórián belüli gyorsítótárazási API-k. A kulcs-érték párok egy kicsit alaposabbak. A memóriabeli gyorsítótárazási kulcsok egy object, míg az elosztott kulcsok egy string. A memórián belüli gyorsítótárazás esetén az érték bármilyen erős típusú általános lehet, míg az elosztott gyorsítótárazási értékek megmaradnak byte[]. Ez nem azt jelzi, hogy a különböző implementációk nem teszik közzé az erősen gépelt általános értékeket, de ez a megvalósítás részletei.

Értékek létrehozása

Ha értékeket szeretne létrehozni az elosztott gyorsítótárban, hívja meg a beállított API-k egyikét:

AlphabetLetter A memóriabeli gyorsítótár példájából származó rekord használatával szerializálhatja az objektumot JSON-ra, majd kódolhatja a string következőkéntbyte[]:

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

A memóriabeli gyorsítótárazáshoz hasonlóan a gyorsítótárbejegyzések is segíthetnek a gyorsítótárban való létezésük finomhangolásában – ebben az esetben a DistributedCacheEntryOptions.

Bővítménymetelyek létrehozása

Számos kényelmi alapú bővítménymetódus létezik az értékek létrehozásához, amelyek segítenek elkerülni az objektumok ábrázolásának kódolásátstring:byte[]

Értékek olvasása

Ha az elosztott gyorsítótárból szeretne értékeket olvasni, hívja meg a get API-k egyikét:

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

Ha a gyorsítótár-bejegyzést kiolvassa a gyorsítótárból, lekérheti az UTF8 kódolású string ábrázolást a byte[]

A bővítmények olvasási módszerei

Az értékek olvasásához számos kényelmi alapú bővítménymetódus létezik, amelyek segítenek elkerülni az objektumok ábrázolására való dekódolást byte[]string :

Értékek frissítése

Az elosztott gyorsítótár értékeit nem lehet egyetlen API-hívással frissíteni, ehelyett az értékek a frissítési API-k egyikével alaphelyzetbe állíthatják a csúszó lejáratukat:

Ha a tényleges értéket frissíteni kell, törölnie kell az értéket, majd újra hozzá kell adnia.

Értékek törlése

Az elosztott gyorsítótár értékeinek törléséhez hívja meg az egyik eltávolítási API-t:

Tipp.

Bár a fent említett API-k szinkron verziói léteznek, vegye figyelembe, hogy az elosztott gyorsítótárak implementációi a hálózati I/O-ra támaszkodnak. Ezért gyakrabban érdemes használni az aszinkron API-kat.

Lásd még