vrijdag 24 mei 2013

Caching in Windows Azure

Wanneer je jouw eerste website hebt gemaakt, wil je die natuurlijk delen met anderen. Daarvoor ga je op zoek naar een hostingpartij, iemand die een heleboel computers in de lucht houdt die dag en nacht bereikbaar zijn. Zo'n hostingprovider verzorgt ook de hele infrastructuur. Het enige wat jij hoeft te doen is jouw website te publiceren. In het begin ga je natuurlijk de goedkoopste uitzoeken. Wanneer blijkt dat je interessante inhoud hebt die veel bezoekers trekt, zul je op een gegeven moment merken dat de site erg traag wordt. De provider is jou aan het afknijpen want je gebruikt teveel resources (die je deelt met anderen) waardoor andere websites daar hinder van ondervinden. Op dat moment wordt het tijd om een dedicated webserver te huren en die kosten, afhankelijk van de hardware die je wenst, aanzienlijk meer. Ook die dedicated webserver kan op een gegeven moment aan zijn limiet komen en dan kun je een aantal dingen doen:
  1. Meer hardware kopen (processor, geheugen, bandbreedte)
  2. Kijken waarom die machine aan zijn capaciteit zit
De eerste is eigenlijk geen optie, dat kan altijd nog. De tweede wil ik hier bespreken.

Jouw website wordt gehost op een dedicated server en hij is niet meer vooruit te branden. Na een korte analyse zie je dat de processor eigenlijk altijd de 100% aantikt en het geheugen vol loopt. Wat is er aan de hand? Wat is die machine allemaal aan het doen?
Bij de meeste websites worden de webpagina's gegenereerd door programma's zoals asp.net, php en ruby on rails. Dat genereren kost natuurlijk tijd. Daarnaast moet er data uit de database gehaald worden en dat kost natuurlijk ook weer tijd en resources. Je kunt je afvragen of dat genereren en ophalen van data echt telkens opnieuw moet gebeuren. In veel gevallen is dat niet nodig omdat de inhoud van de pagina niet continu verandert. Die pagina's wil je na de eerste generatie eigenlijk opslaan zodat de volgende bezoeker de opgeslagen versie te zien krijgt, en dat is nu caching. Het beste is om die gecachte pagina in het geheugen van de server op te slaan omdat toegang tot het geheugen supersnel is, maar ook het geheugen is beperkt. Je zult je dus de volgende vragen moeten stellen:
  1. Welke pagina's ga je cachen? Dat hangt van een aantal factoren af
    1. Kost het veel (tijd, resources) om de pagina te genereren?
    2. Wordt de pagina vaak geraadpleegd?
  2. Hoe lang ga je een pagina cachen? Dat bepaal je door je af te vragen hoe vaak de inhoud verandert. Pagina's die eens per maand veranderen, kun je langer in het geheugen houden dan pagina's die eens per dag wijzigen.
  3. Ga je de hele pagina cachen of slechts bepaalde onderdelen van de pagina? Stel je hebt een productpagina, ga je dan de hele pagina voor ieder product bewaren of sla je alleen de productdata op zodat de toegang tot de database verminderd wordt.
Bij BesteProduct hebben we met al deze factoren te maken gehad en redelijk opgelost met de hulpmiddelen die ASP.NET ons biedt, maar toch lopen we tegen de hardwarelimieten aan. Reden om de hele website in Windows Azure te hangen. Hiermee hebben we wel weer een nieuw probleem gecreëerd. Omdat we nu met meerdere machines werken, betekent dit dat de data nu ook op verschillende machines gecacht wordt. De cache zal dus alleen goed werken als het verzoek op op de juiste machine aankomt en dat is bij Windows Azure niet het geval omdat de loadbalancer bepaalt welke machine het verzoek afhandelt. Azure gebruikt hiervoor de zogenaamde "round robin" benadering, ieder verzoek wordt telkens naar een andere server afgevuurd (zie afbeelding 1)

Afbeelding 1. De round robin benadering. Er worden door diverse clients negen verzoeken afgevuurd. Ieder verzoek wordt telkens door een andere server afgehandeld (bron: Citrix support).
Bijkomend nadeel is dat dezelfde pagina meerdere keren wordt gecacht, en dat is natuurlijk zonde van de resources.

Windows Azure biedt gelukkig uitkomst met een gedistribueerde cache: Windows Azure Caching (voorheen Velocity). Met Windows Azure Caching kun je de cache van de afzonderlijke servers delen (co-located) of je kunt een aparte server inrichten die louter en alleen geheugen aanlevert (dedicated). Laten we eens gaan bekijken hoe dat werkt.
Met de oude benadering, waarbij je de pagina caching middels het OutputCache attribute instelt (zie listing hieronder), zou je het resultaat uit afbeelding 2 krijgen.


public class HomeController : Controller
{
    [OutputCache(Duration=900)]
    public ActionResult Index()
    {
        ViewBag.RoleID = RoleEnvironment.CurrentRoleInstance.Id;
        ViewBag.CurrentTime = DateTime.Now.ToLongTimeString();
        return View();
    }
}
Afbeelding 2. Het gevolg van de standaard caching. Op de eerste server bestaat een gecachte pagina met een andere tijd dan op de tweede server. Dit is dus niet wenselijk.
Om met de gedistribueerde caching te werken, installeer je eerst de Windows Azure SDK (als je dat nog niet gedaan hebt) en, via NuGet,  de package Windows Azure Caching. Daarna selecteer je de betreffende Azure Web Role in Visual Studio 2012 en ga je naar het "Caching" menu (zie afbeelding 3).


Afbeelding 3. Het Caching menu van de Web Role "CacheTest".
In dit scherm schakel je de caching in (Enable Caching), geef je aan of je co-located wilt of dedicated. In het geval van co-located, waarbij het geheugen geleverd wordt door de webservers zelf, geef je aan hoeveel procent van het geheugen je voor caching wil inzetten (niet teveel anders heeft de webserver zelf niet genoeg geheugen meer). Je zult ook een blobstorage moeten opgeven waarin de cache configuratie wordt opgeslagen en eventueel kun je het cachegedrag instellen. In de configuratie van de website (web.config) pas je de volgende sectie aan (wordt met de installatie van Windows Azure caching automatisch toegevoegd) waarin het "identifier" attribuut wordt ingesteld op de naam van de web role (CacheTest in dit voorbeeld)


<dataCacheClients>
  <dataCacheClient name="default">
    <autoDiscover isEnabled="true" identifier="CacheTest" />
  </dataCacheClient>
</dataCacheClients>

Deze handeling leidt nu tot het resultaat uit afbeelding 4.


Afbeelding 4. Distributed cache geactiveerd. Je krijgt nu op alle instanties dezelfde gecachte pagina te zien.
Het feit dat we hier telkens server 1 zien is natuurlijk ook een gevolg van de page output cache. Om die te laten variëren en de tijd gelijk te houden moeten we de tijd handmatig in de cache opslaan. Normaliter zou je hier HttpContext.Cache voor gebruiken, maar dat werkt niet in het gedistribueerde systeem. We moeten nu een beroep doen op de classen DataCacheFactory en DataCache die uit de namespace Microsoft.ApplicationServer.Caching komen. De DataCacheFactory is verantwoordelijk voor het aanleveren van een DataCache object waarop je uiteindelijk werkt. Dit doe je door op het DataCacheFactory de methode "GetCache("cache_name")" aan te roepen ("cache_name" verwijst naar een cache configuratie die je instelt in het "Caching" menu (zie afbeelding 3, helemaal onderaan. "Named Cache Settings").
Hierbij moet je opletten dat meerdere servers deze gedeelde pot tegelijk kunnen benaderen hetgeen weer tot ongewenste bijverschijnselen kan leiden. Eerst maar eens de centrale toegang tot de DataCache inregelen. Omdat het aanmaken van het DataCache object vrij duur is, is het handig om hiervoor het singleton patroon in te zetten.


public class HomeController : Controller
{
    private static object stick = new object();
    private static DataCache _cache = null;

    private static DataCache Cache
    {
        get
        {
            if (_cache == null)
            {
                DataCacheFactory factory = new DataCacheFactory();
                lock (stick)
                {
                    _cache = factory.GetCache("my_cache");
                }
            }
            return _cache;
        }
    }
}

DataCache heeft de methodes Put, Add en Get om de cache te benaderen, maar houden geen rekening met het tegelijkertijd benaderen door alle servers (concurrency). Het is dus heel goed mogelijk dat deze methodes een fout zullen opleveren als de resource in gebruik is of als een resource nog niet bestaat. Vandaar dat het handig kan zijn om er speciale toegangsfuncties voor te definiëren. Hieronder volgt een implementatie van hoe die functies eruit zouden kunnen zien. 


private static bool TryCacheGet(string key, out object cachedItem, int counter = 0)
{
    cachedItem = null;
    try
    {
        cachedItem = Cache.Get(key);
        return cachedItem != null;
    }
    catch (DataCacheException e)
    {
        if (e.ErrorCode == DataCacheErrorCode.KeyDoesNotExist) return false;
        if (e.ErrorCode == DataCacheErrorCode.RetryLater)
        {
            if (counter > 9) return false;
            Thread.Sleep(100);
            return TryCacheGet(key, out cachedItem, ++counter);
        }
    }
    return false;
}

private static bool TryCachePut(string key, object cachedItem, int counter = 0)
{
    DataCacheLockHandle locker;
    try
    {
        object result = Cache.GetAndLock(key, TimeSpan.FromSeconds(1), out locker);
        Cache.PutAndUnlock(key, cachedItem, locker);
        return true;
    }
    catch (DataCacheException e)
    {
        if (e.ErrorCode == DataCacheErrorCode.KeyDoesNotExist)
        {
            Cache.Put(key, cachedItem);
            return true;
        }
        if (e.ErrorCode == DataCacheErrorCode.ObjectLocked)
        {
            if (counter > 9) return false;
            Thread.Sleep(100);
            return TryCachePut(key, cachedItem, ++counter);
        }
    }
    return false;
}

De GetAndLock en PutAndUnlock zijn handige functies die veel concurrency problemen voor hun rekening nemen. 
Het gebruik in een MVC-action ziet er dan als volgt uit:


public ActionResult Index()
{
    ViewBag.RoleID = RoleEnvironment.CurrentRoleInstance.Id;
    object cachedItem;
    if (TryCacheGet("time"out cachedItem))
    {
        ViewBag.CurrentTime = cachedItem as string;
    }
    else
    {
        ViewBag.CurrentTime = DateTime.Now.ToLongTimeString();
        TryCachePut("time", ViewBag.CurrentTime);
    }
    return View();
}
Tenslotte moet je in de web.config de volgende sectie uitcommentariëren

<caching>
  <outputCache defaultProvider="AFCacheOutputCacheProvider">
    <providers>
      <add name="AFCacheOutputCacheProvider" type="Microsoft.Web.DistributedCache.DistributedCacheOutputCacheProvider, Microsoft.Web.DistributedCache" cacheName="default" dataCacheClientName="default" />
    </providers>
  </outputCache>
</caching>

Bij mij gaf deze actie de volgende foumelding:

Could not load file or assembly 'Microsoft.WindowsAzure.ServiceRuntime, Version=2.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35' or one of its dependencies. The system cannot find the file specified.


Dat klopt want mijn ServiceRuntime versie is 1.8.0.0. Kennelijk is de Windows Azure Cache package gecompileerd met een ServiceRuntime versie 2.0.0.0, een probleem dat met een binding redirect eenvoudig op te lossen is (hopelijk raak ik daarbij geen fancy functionaliteiten kwijt).

<runtime>
  <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
    <dependentAssembly>
      <assemblyIdentity name="Microsoft.WindowsAzure.ServiceRuntime" publicKeyToken="31bf3856ad364e35" />
      <bindingRedirect oldVersion="2.0.0.0" newVersion="1.8.0.0" />
    </dependentAssembly>
  </assemblyBinding>
</runtime>

Het resultaat is nu in afbeelding 5 te bewonderen

Afbeelding 5. De tijd gecacht in de gedeelde cache van Windows Azure.
Wanneer je nu besluit om een dedicated cache server in te richten, maak je een nieuwe Worker Role aan (we noemen die voor het gemak even CacheRole) waarin je de Cache aanzet (afbeelding 3) en vervolgens de "Dedicated" optie selecteert. Het geheugengebruik kun je niet langer instellen omdat al het bruikbare geheugen nu voor cache wordt ingezet. In de web.config van de website geef je voor de "identifier" nu de naam van de CacheRole op.


<dataCacheClients>
  <dataCacheClient name="default">
    <autoDiscover isEnabled="true" identifier="CacheRole" />
  </dataCacheClient>
</dataCacheClients>

Voor de rest verandert er niets.

Geen opmerkingen:

Een reactie posten