Les memòries cau o memòria cache són memòries que permeten emmagatzemar dades ràpidament per ser consumides en instants posteriors. Aquest fet permet millorar el rendiment de les aplicacions reduint els temps de càrrega i minimitzant els accessos a altres fonts de dades.
Actualment estic treballant en un projecte on tenim una Api desenvolupada en .Net amb diverses capes de memòria cache. Bàsicament, utilitzem memòria cache local per emmagatzemar informació que no varia en el temps i despres utilitzem memòria cache distribuida Redis cache per accessos de diferents components i que puguin utilitzar la mateixa copia de la informació i així evitar errors d'integritat. En aquest document m'enfocaré en l'ús de la memòria local i com podem optimizar els accessos i el refresc de la informació amb l'ajuda de la nova funcionalitat de serveis en background que ens ofereix el nou framework de .net core.
Descripció de l'escenari
Com he comentat, estic treballant en un projecte on tenim una Api que accedeix a una sèrie d'informació recollida en una base de dades MongoDb. Per tal de minimitzar els accessos a MongoDb si la informació no ha canviat i necessitem disposar d'ella en un curt espai de temps. Apliquem el següent algorisme:
public async Task<StoreDetails> GetAsync(string storeId, string countryCode, CancellationToken cancellationToken)
{
if (!_cache.TryGetValue(CacheKeys.Store(storeId), out StoreDetails storeDetails))
{
storeDetails = await _store.ReadItemAsync<StoreDetails>(s => s.Id == storeId && s.CountryCode == countryCode, cancellationToken);
if (storeDetails != null)
{
_cache.Set(CacheKeys.Store(storeId), storeDetails, _options.StoreCacheTimeout);
}
}
return storeDetails;
}
Aquest algorisme, encara que ens ha funcionat força bé fins ara, no és una solució ideal ja que varis threads podrien estar realitzant la query "ReadItemAsync" a la vegada, ja que no hi ha cap restricció que ho impedeixi. En un sistema d'alta disponibiltiat és molt probable que un cop la copia local hagi caducat, varis fils concurrents accedeixin a la condició i realitzin diverses queries abans que la nova informació a la memòria cau estigui disponible. Per aquest i altres motius com el de tenir un codi menys acoblat m'han empés a dissenyar una nova forma de gestionar la memòria cau.
Solució plantejada
La idea principal es basa en desenvolupar un sistema que permeti refrescar totes les caches utilitzades de manera independent a les peticions dels usuaris i que és pugui incorporar la funcionalitat fàcilment a qualsevol repositori sense perdre la capa de transparència que una memòria d'aquest estil hauria d'oferir. Per a la creació d'aquest sistema m'ajudaré del patró de disseny Observer que actuarà com a disparador del refresc d'aquestes caches. Veiem una imatge simplificada:
Observer1 -> Subscriu -> Observable Observer2 -> Subscriu -> Observable Observer3 -> Subscriu -> Observable
Observable -> Notify -> Observer1, Observer2, Observer3
Implementació de la solució
- Què és el BackgroundHostService?
- Purgar una cache mitjançant el BackgroundHostService
- Acoplar CacheableRepository al IReadOnlyRepository
- Fer el BackgroundHostService Observable i que els repositoris es subscriguin
- Separar el BackgroundHostService i el ObservableCacheService
- Inicialització de les memòries cau
- Implementació pròpia del patró Observer
- Proves de rendiment
- Millores
- Conclusions
1. Què és el BackgroundHostService?
Des de la versió de dotnet 3.1, s'ha incorporat el que és coneix com a BackgroundHostService. És una classe amb lògica de tasques de background que implementa la interfície IHostedService. Per entendre aquest concepte de HostedService primer hauriem d'esta familiaritzats amb l'objecte Host de aspnetcore. L'objecte Host es un contenidor que ofereix varies funcionalitats encapsulades com la injecció de dependencies, la configuració, la telemetria, etc. Aquest contenidor pot ser configurat majoritariament per gestionar aplicacions de consola (CLIs) o aplicacions web (Web Apps). Dins d'aquestes funcionalitats que ofereix el Host, n'hi ha una que et permet realitzar tasques en background implementant la interfície base IHostedService. I, ja que el BackgroundHostService implementa l'interfície de IHostService, doncs ofereix facilitats per partir d'aquesta implementació i no construir una de zero a partir del BackgroundHostService.
La meva intenció es utilitzar aquest servei corrent en segon pla per purgar i refrescar les memòries cau en segon pla cada un cert temps. Per afegir aquest servei en segon pla a la nostre Api ho farem de la següent manera. Primer crearem un BackgroundService propi que heredi de la classe abstracte:
public class TimedBackgroundServiceSettings
{
public TimeSpan RefreshPeriod { get; set; }
}
public class TimedBackgroundCacheService : BackgroundService
{
private readonly ILogger<TimedBackgroundCacheService> _logger;
private readonly TimedBackgroundServiceSettings _settings;
public TimedBackgroundCacheService(ILogger<TimedBackgroundCacheService> logger,
IOptions<TimedBackgroundServiceSettings> options)
{
_logger = logger;
_settings = options.Value;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("{service} running.", nameof(TimedBackgroundCacheService));
await ExecuteJobAsync(stoppingToken);
_logger.LogInformation("{service} executed at: {now}.", nameof(TimedBackgroundCacheService), DateTime.UtcNow);
using PeriodicTimer timer = new(_settings.RefreshPeriod);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ExecuteJobAsync(stoppingToken);
_logger.LogInformation("{service} executed at: {now}.", nameof(TimedBackgroundCacheService), DateTime.UtcNow);
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("{service} is stopping.", nameof(TimedBackgroundCacheService));
}
}
private Task ExecuteJobAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
//TODO: Refresh cache here
}
}
i el registrarem com a dependencia a la nostre aplicació:
public static class IServiceCollectionExtensions
{
public static IServiceCollection ConfigureHostedService(this IServiceCollection services)
{
return services
.AddHostedService<TimedBackgroundCacheService>()
//.AddSingleton<ICacheableRepository<WeatherForecast>, Repository>()
.AddOptions<TimedBackgroundServiceSettings>()
.Configure<IConfiguration>((settings, config) => {
settings.RefreshPeriod = config.GetValue<TimeSpan>("TimedBackgroundServiceSettings:RefreshPeriod");
}).Services;
}
}
builder.Services.ConfigureHostedService();
Amb això executem la api i veiem que la nostre tasca en segon pla s'esta executant perfectament:
6. Inicialització de les memòries cau
En aquest punt ja tenim la cosa bastant avançada, s'ha aconseguit desacoplar les tasques en background del sistema de cache i també s'ha aconseguit notificar a cada sistema de cache perquè gestioni la seva copia local de les dades. Però encara ens queda un punt a millorar:
- Quan arranquem la nostre aplicació les memòries no s'inicialitzen fins que un usuari no realitza una petició a la api. Això és un fet problemàtic i que volem solucionar per tal de complir amb l'objectiu inicial. Ja que com que el nostre codi és concurrent podria passar que varis usuaris estiguessin anant a buscar la mateixa informació a base de dades en threads diferents. Per tal de solucionar aquest problema s'hauria d'intentar inicialitzar les caches un cop s'arrenca la nostre aplicació, per fer-ho ho realitzarem de la següent manera.
Crearem una extensió perque quan es truqui a un determinat path, s'inicialitzi l'instancia singleton:
public static class WarmUpExtensions
{
public static IApplicationBuilder WarmUp(this IApplicationBuilder app)
{
return app.Map("/warmup", a => a.Run(async (context) => {
context.RequestServices.GetService<IReadonlyRepository<WeatherForecast>>();
var cacheService = context.RequestServices.GetService<IObservableCacheService>();
await cacheService.RefreshCachesAsync(CancellationToken.None);
context.Response.StatusCode = 200;
}));
}
}
Registrem l'extensió a l'aplication builder del programa i mitjançant una request a l'endpoint /warmup ja podrem iniciar les caches.
7. Implementació pròpia del patró Observer
Estupendu, ja tenim les nostres memòries cau funcionant, però hi ha una part de la implementació del patró observer que no m'encaixa. Potser ja us aneu adonat. En el punt 4 he implementat el mètode OnNext així:
public override void OnNext(Message value)
{
var forecasts = this._dataStore.FindAllAsync(CancellationToken.None).Result;
base._memoryCache.Set(ForecastsCacheKey, forecasts);
this._logger.LogInformation("{repository} has cached {count} {obj} info", nameof(Repository), forecasts.Count() ,nameof(WeatherForecast));
}
Com he comentat abans aquest mètode es el responsable de notificar als observadors els nous canvis que hi han hagut. Per al nostre cas, utilitzem aquest mètode per notificar i a més a més per refrescar la copia local de les dades, per tant ens interessaria que el metode fos asíncron i d'aquesta forma no bloquejar la cpu per una crida a xarxa. Per tant, per evitar bloquejar el thread, he pensat en implementar un propi patró observador que s'adapati a les necessitats de l'aplicació i deixar d'utilitzar el que ens ofereix Microsoft per defecte.
Així doncs he substituit les següents interfícies per unes pròpies, com és pot veure en aquest commit:
8. Proves de rendiment
Ara que tenim implementada la nova funcionalitat és el moment de demostrar-nos si realment és tant eficient com nosaltres ens pensem, es per això que realitzaré un seguit de proves d'esforç utilitzant l'eina disponible k6.
9. Millores
- BackgroundHostService vs HTTP trigger
- Cada CacheRepository es fa responsable de saber si ha de refrescar la seva copia local en funció d'un interval de temps preconfigurat
- Nova funcionalitat per poder refrescar una memòria cau específica i sense fer broadcast
- Creació d'una memòria cau distribuida per evitar problemes d'integritat entre diversos microserveis, utilitzant Pub/Sub
- Millorar la informació del warmup per conèixer l'estat de les caches i quines no han pogut ser inicialitzades
- Implementació concreta del patró Observer per adaptar-lo a les nostres funcionalitats