Antimönstret Trafikintensiva I/O

Den ackumulerade effekten av ett stort antal I/O-begäranden kan ha en betydande påverkan på prestanda och tillgänglighet.

Problembeskrivning

Nätverksanrop och andra I/O-åtgärder är till sin natur långsamma jämfört med beräkningsuppgifter. Varje I/O-begäran har normalt betydande overhead och den ackumulerade effekten av många I/O-åtgärder kan göra systemet långsamt. Här är några vanliga orsaker till trafikintensiva I/O.

Läsning och skrivning av enskilda poster till en databas som distinkta begäranden

Följande exempel läser från en databas med produkter. Det finns tre tabeller: Product, ProductSubcategory och ProductPriceListHistory. Koden hämtar alla produkter i en underkategori, tillsammans med prisinformationen, genom att köra ett antal frågor:

  1. Fråga underkategorin i tabellen ProductSubcategory.
  2. Hitta alla produkter i den underkategorin genom att fråga tabellen Product.
  3. Fråga prisdata i tabellen ProductPriceListHistory för varje produkt.

Programmet använder Entity Framework till att fråga databasen. Du hittar hela exemplet här.

public async Task<IHttpActionResult> GetProductsInSubCategoryAsync(int subcategoryId)
{
    using (var context = GetContext())
    {
        // Get product subcategory.
        var productSubcategory = await context.ProductSubcategories
                .Where(psc => psc.ProductSubcategoryId == subcategoryId)
                .FirstOrDefaultAsync();

        // Find products in that category.
        productSubcategory.Product = await context.Products
            .Where(p => subcategoryId == p.ProductSubcategoryId)
            .ToListAsync();

        // Find price history for each product.
        foreach (var prod in productSubcategory.Product)
        {
            int productId = prod.ProductId;
            var productListPriceHistory = await context.ProductListPriceHistory
                .Where(pl => pl.ProductId == productId)
                .ToListAsync();
            prod.ProductListPriceHistory = productListPriceHistory;
        }
        return Ok(productSubcategory);
    }
}

Det här exemplet visar problemet uttryckligen men ibland kan ett O/RM maskera problemet, om det implicit hämtar underordnade poster en i taget. Det här är känt som ”N + 1-problemet”.

Implementera en enskild logisk åtgärd som en rad HTTP-begäranden

Det händer ofta när utvecklare försöker följa ett objektorienterat paradigm och behandla fjärrobjekt som om de vore lokala objekt i minnet. Det kan resultera i för många nätverksöverföringar. Till exempel exponerar följande webb-API de enskilda egenskaperna för User-objekt via enskilda HTTP GET-metoder.

public class UserController : ApiController
{
    [HttpGet]
    [Route("users/{id:int}/username")]
    public HttpResponseMessage GetUserName(int id)
    {
        ...
    }

    [HttpGet]
    [Route("users/{id:int}/gender")]
    public HttpResponseMessage GetGender(int id)
    {
        ...
    }

    [HttpGet]
    [Route("users/{id:int}/dateofbirth")]
    public HttpResponseMessage GetDateOfBirth(int id)
    {
        ...
    }
}

Det finns inget tekniskt fel med det här tillvägagångssättet men de flesta klienter behöver troligen hämta flera egenskaper för varje User, vilket leder till följande klientkod.

HttpResponseMessage response = await client.GetAsync("users/1/username");
response.EnsureSuccessStatusCode();
var userName = await response.Content.ReadAsStringAsync();

response = await client.GetAsync("users/1/gender");
response.EnsureSuccessStatusCode();
var gender = await response.Content.ReadAsStringAsync();

response = await client.GetAsync("users/1/dateofbirth");
response.EnsureSuccessStatusCode();
var dob = await response.Content.ReadAsStringAsync();

Läsning och skrivning till en fil på disken

Fil-I/O innebär att öppna en fil och flytta till den lämpliga punkten innan data läses eller skrivs. När åtgärden är klar kan filen stängas för att spara resurser i operativsystemet. Ett program som ständigt läser och skriver små mängder information till en fil genererar betydande I/O-overhead. Små skrivbegäranden kan också leda till filfragmentering, vilket gör efterföljande I/O-åtgärder ännu långsammare.

I följande exempel används en FileStream för att skriva ett Customer-objekt till en fil. När du skapar FileStream öppnas filen och om du tar bort den stängs filen. (Instruktionen using bortser automatiskt från FileStream objektet.) Om programmet anropar den här metoden upprepade gånger när nya kunder läggs till kan I/O-omkostnaderna ackumuleras snabbt.

private async Task SaveCustomerToFileAsync(Customer customer)
{
    using (Stream fileStream = new FileStream(CustomersFileName, FileMode.Append))
    {
        BinaryFormatter formatter = new BinaryFormatter();
        byte [] data = null;
        using (MemoryStream memStream = new MemoryStream())
        {
            formatter.Serialize(memStream, customer);
            data = memStream.ToArray();
        }
        await fileStream.WriteAsync(data, 0, data.Length);
    }
}

Åtgärda problemet

Minska antalet I/O-begäranden genom att paketera data i större, färre begäranden.

Hämta data från en databas som en enskild fråga, istället för flera mindre begäranden. Här är en reviderad version av koden som hämtar produktinformation.

public async Task<IHttpActionResult> GetProductCategoryDetailsAsync(int subCategoryId)
{
    using (var context = GetContext())
    {
        var subCategory = await context.ProductSubcategories
                .Where(psc => psc.ProductSubcategoryId == subCategoryId)
                .Include("Product.ProductListPriceHistory")
                .FirstOrDefaultAsync();

        if (subCategory == null)
            return NotFound();

        return Ok(subCategory);
    }
}

Följ REST-designprinciper för webb-API:er. Här är en reviderad version av webb-API från det tidigare exemplet. Istället för separata GET-metoder för varje egenskap finns det en enda GET-metod som returnerar User. Resultatet är fler antal svar per begäran men varje klient gör troligen färre API-anrop.

public class UserController : ApiController
{
    [HttpGet]
    [Route("users/{id:int}")]
    public HttpResponseMessage GetUser(int id)
    {
        ...
    }
}

// Client code
HttpResponseMessage response = await client.GetAsync("users/1");
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadAsStringAsync();

För fil-I/O kan du buffra data i minnet och sedan skriva de buffrade data till en fil som en enda åtgärd. Den här metoden minskar overhead från att upprepat öppna och stänga filen och hjälper till att minska fragmenteringen av filen på disken.

// Save a list of customer objects to a file
private async Task SaveCustomerListToFileAsync(List<Customer> customers)
{
    using (Stream fileStream = new FileStream(CustomersFileName, FileMode.Append))
    {
        BinaryFormatter formatter = new BinaryFormatter();
        foreach (var customer in customers)
        {
            byte[] data = null;
            using (MemoryStream memStream = new MemoryStream())
            {
                formatter.Serialize(memStream, customer);
                data = memStream.ToArray();
            }
            await fileStream.WriteAsync(data, 0, data.Length);
        }
    }
}

// In-memory buffer for customers.
List<Customer> customers = new List<Customers>();

// Create a new customer and add it to the buffer
var customer = new Customer(...);
customers.Add(customer);

// Add more customers to the list as they are created
...

// Save the contents of the list, writing all customers in a single operation
await SaveCustomerListToFileAsync(customers);

Att tänka på

  • I de två första exemplen görs färre I/O-anrop men varje anrop hämtar mer information. Du måste gör en avvägning mellan de två faktorerna. Rätt svar beror på de faktiska användningsmönstren. I till exempel webb-API-exemplet kan det visa sig att klienterna ofta bara behöver just användarnamnet. I så fall kan det vara bra att exponera det som ett separat API-anrop. Mer information finns i antimönstret överflödig hämtning.

  • När du läser data ska du inte göra för stora I/O-begäranden. Ett program bör bara hämta den information som den troligen kommer att använda.

  • Ibland hjälper det att partitionera informationen för ett objekt i två segment, ofta använda data som står för de flesta begäranden och mindre ofta använda data som används sällan. Ofta är de data som används mesta en relativt liten del av ett objekts totala data, så att bara returnera den delen kan spara betydande I/O-overhead.

  • När du skriver data ska du undvika att låsa resurser längre än nödvändigt, för att minska risken för konkurrens under en långvarig åtgärd. Om den skrivåtgärd sträcker sig över flera datalager, filer eller tjänster antar du en slutligen konsekvent metod. Se Vägledning om datakonsekvens.

  • Om du buffrar data i minnet innan du skriver dem är data sårbara om processen kraschar. Om datafrekvensen normalt har ökningar eller är relativt gles kan det vara säkrare att buffra data i en extern varaktig kö som Event Hubs.

  • Du bör cachelagra data som du hämtar från en tjänst eller en databas. Det kan hjälpa dig att minska mängden I/O genom att undvika upprepade begäranden för samma data. Mer information finns i Metodtips för cachelagring.

Identifiera problemet

Symtom på trafikintensiva I/O är lång svarstid och lågt dataflöde. Slutanvändare rapporterar troligen längre svarstider eller fel som orsakas av uppnådda tidsgränser i tjänsterna, på grund av ökad konkurrens om I/O-resurser.

Du kan göra följande för att identifiera orsaken till problemen:

  1. Utför processbearbetning av produktionssystemet för att identifiera åtgärder med långa svarstider.
  2. Utför belastningstestning för varje åtgärd som identifierats i föregående steg.
  3. Under belastningstesterna samlar du in telemetridata om dataåtkomstbegäranden som gjorts av varje åtgärd.
  4. Samla in detaljerad statistik för varje begäran som skickats till ett datalager.
  5. Profilera programmet i testmiljön för att fastställa var möjliga I/O-flaskhalsar kan förekomma.

Titta av något av de här symtomen:

  • Ett stort antal små I/O-begäranden gjorda till samma fil.
  • Ett stort antal nätverksbegäranden gjorda av en programinstans till samma tjänst.
  • Ett stort antal små begäranden gjorda av en programinstans till samma datalager.
  • Program och tjänster blir I/O-bundna.

Exempeldiagnos

Följande avsnitt använder de här stegen i exemplet ovan som frågar en databas.

Belastningstesta programmet

Det här diagrammet visar resultatet av belastningstestning. Medianen för svarstid mäts i tiotal sekunder per begäran. Diagrammet visar mycket långa svarstider. Med en belastning på 1 000 användare kan användaren behöva vänta i nästan en minut innan frågeresultatet visas.

Belastningstestresultatets viktiga indikatorer för exempelprogrammet med trafikintensiva I/O

Kommentar

Programmet har distribuerats som en Azure App Service-webbapp som använder Azure SQL Database. Belastningstestet använde en simulerad stegbelasting på upp till 1 000 samtidiga användare. Databasen har konfigurerats med en anslutningspool som stöder upp till 1 000 samtidiga anslutningar, för att minska risken att konkurrensen om anslutningar skulle påverka resultatet.

Övervaka programmet

Du kan använda ett APM-paket (application performance management) för att samla in och analysera de nyckelmått som kan identifiera chattiga I/O. Vilka mått som är viktiga beror på I/O-arbetsbelastningen. I det här exemplet var de intressanta I/O-begäranden databasfrågorna.

I följande bild visas resultat som genereras med New Relic APM. Den genomsnittliga svarstiden för databasen nådde sin topp vid ungefär 5,6 sekunder per begäran under maximal arbetsbelastning. Systemet kunde stödja i genomsnitt 410 begäranden per minut genom testet.

Översikt över trafik i AdventureWorks2012-databasen

Samla in detaljerad dataåtkomstinformation

En djupdykning i övervakningsdata visar att programmet kör tre olika SQL SELECT-instruktioner. De här motsvarar begäranden genererade av Entity Framework för att hämta data från tabellerna ProductListPriceHistory, Product och ProductSubcategory. Vidare är frågan som hämtar data från tabellen ProductListPriceHistory den överlägset oftast utförda SELECT-instruktionen, i storleksordning.

Frågor utförda av exempelprogrammet som testas

Det visar sig att metoden GetProductsInSubCategoryAsync (som visas ovan) utför 45 SELECT-frågor. Varje fråga gör så att programmet öppnar en ny SQL-anslutning.

Frågestatistik för exempelprogrammet som testas

Kommentar

Den här bilden visar spårningsinformation för den långsammaste instansen av åtgärden GetProductsInSubCategoryAsync i belastningstestet. I en produktionsmiljö är det användbart att undersöka spåren för de långsammaste instanserna, för att se om det finns ett mönster som tyder på ett problem. Om du bara tittar på de genomsnittliga värdena kanske du missar problem som blir mycket värre under belastning.

Nästa bild visar de faktiska SQL-instruktionerna som har utfärdats. Frågan som hämtar prisinformation körs för varje enskild produkt i produktunderkategorin. Att använda en koppling skulle avsevärt minska antalet databasanrop.

Frågedetaljer för exempelprogrammet som testas

Om du använder en O/RM, till exempel Entity Framework, kan spårning av SQL-frågorna ge insikter om hur O/RM översätter programmatiska anrop till SQL-instruktioner och anger områden där dataåtkomsten kan optimeras.

Implementera lösningen och verifiera resultatet

Omskrivning av anropet till Entity Framework gav följande resultat.

Belastningstestresultatets viktiga indikatorer för chunky-API för exempelprogrammet med trafikintensiva I/O

Det här belastningstestet utfördes på samma distribution, med samma belastningsprofil. Den här gången visar diagrammet mycket kortare svarstider. Den genomsnittliga tiden för begäran vid 1 000 användare är mellan 5 och 6 sekunder, ned från nästan en minut.

Den här gången hanterade systemet i genomsnitt 3 970 begäranden per minut, jämfört med 410 för det tidigare testet.

Transaktionsöversikt för chunky-API

Spårning av SQL-instruktionen visar att alla data hämtas i en enda SELECT-instruktion. Trots att frågan är betydligt mer komplex utförs den bara en gång per åtgärd. Och medan komplexa kopplingar kan bli dyra är relationsdatabassystem optimerade för den här typen av fråga.

Frågedetaljer för chunky-API