Notitie
Voor toegang tot deze pagina is autorisatie vereist. U kunt proberen u aan te melden of de directory te wijzigen.
Voor toegang tot deze pagina is autorisatie vereist. U kunt proberen de mappen te wijzigen.
Het cumulatieve effect van een groot aantal I/O-aanvragen kan een aanzienlijke invloed hebben op de prestaties en reactiesnelheid.
Beschrijving van het probleem
Netwerkaanroepen en andere I/O-bewerkingen zijn inherent traag vergeleken met rekentaken. Elke I/O-aanvraag heeft doorgaans aanzienlijke overhead en het cumulatieve effect van talloze I/O-bewerkingen kan het systeem vertragen. Hier volgen enkele veelvoorkomende oorzaken van chatty I/O.
Afzonderlijke records lezen en schrijven naar een database als afzonderlijke aanvragen
In het volgende voorbeeld wordt gelezen uit een database met producten. Er zijn drie tabellen, Product
en ProductSubcategory
ProductPriceListHistory
. Met de code worden alle producten in een subcategorie opgehaald, samen met de prijsinformatie, door een reeks query's uit te voeren:
- Voer een query uit op de subcategorie uit de
ProductSubcategory
tabel. - Zoek alle producten in die subcategorie door een query uit te voeren op de
Product
tabel. - Voer voor elk product een query uit op de prijsgegevens uit de
ProductPriceListHistory
tabel.
De toepassing maakt gebruik van Entity Framework om een query uit te voeren op de database.
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);
}
}
In dit voorbeeld ziet u het probleem expliciet, maar soms kan een O/RM het probleem maskeren, als het impliciet onderliggende records één voor één ophaalt. Dit staat bekend als het probleem N+1.
Eén logische bewerking implementeren als een reeks HTTP-aanvragen
Dit gebeurt vaak wanneer ontwikkelaars een objectgeoriënteerd paradigma proberen te volgen en externe objecten behandelen alsof ze lokale objecten in het geheugen zijn. Dit kan leiden tot te veel netwerkrondes. Met de volgende web-API worden bijvoorbeeld de afzonderlijke eigenschappen van User
objecten beschikbaar gesteld via afzonderlijke HTTP GET-methoden.
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)
{
...
}
}
Hoewel er technisch niets mis is met deze benadering, moeten de meeste clients waarschijnlijk verschillende eigenschappen voor elk User
bestand krijgen, wat resulteert in clientcode zoals hieronder.
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();
Lezen en schrijven naar een bestand op schijf
Bestands-I/O omvat het openen van een bestand en het verplaatsen naar het juiste punt voordat u gegevens leest of schrijft. Wanneer de bewerking is voltooid, kan het bestand worden gesloten om besturingssysteembronnen op te slaan. Een toepassing die voortdurend kleine hoeveelheden informatie naar een bestand leest en schrijft, genereert aanzienlijke I/O-overhead. Kleine schrijfaanvragen kunnen ook leiden tot bestandsfragmentatie, waardoor volgende I/O-bewerkingen nog verder worden vertraagd.
In het volgende voorbeeld gebruikt men een FileStream
om een Customer
object naar een bestand te schrijven. Bij het aanmaken van de FileStream
wordt het bestand geopend, en bij het sluiten ervan wordt het bestand gesloten. (Met de using
instructie wordt het FileStream
object automatisch verwijderd.) Als de toepassing deze methode herhaaldelijk aanroept wanneer nieuwe klanten worden toegevoegd, kan de I/O-overhead snel oplopen.
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);
}
}
Het probleem oplossen
Verminder het aantal I/O-aanvragen door de gegevens in grotere, minder aanvragen te verpakken.
Gegevens uit een database ophalen als één query, in plaats van verschillende kleinere query's. Hier volgt een herziene versie van de code waarmee productgegevens worden opgehaald.
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);
}
}
Volg de REST-ontwerpprincipes voor web-API's. Hier volgt een herziene versie van de web-API uit het eerdere voorbeeld. In plaats van aparte GET-methoden voor elke eigenschap, is er één enkele GET-methode die de User
retourneert. Dit resulteert in een grotere antwoordtekst per aanvraag, maar elke client maakt waarschijnlijk minder API-aanroepen.
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();
Voor bestands-I/O kunt u overwegen om gegevens in het geheugen te bufferen en vervolgens de gebufferde gegevens als één bewerking naar een bestand te schrijven. Deze aanpak vermindert de overhead van het herhaaldelijk openen en sluiten van het bestand en helpt bij het verminderen van fragmentatie van het bestand op schijf.
// 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);
Overwegingen
De eerste twee voorbeelden maken minder I/O-oproepen, maar elk exemplaar haalt meer informatie op. U moet rekening houden met de afweging tussen deze twee factoren. Het juiste antwoord is afhankelijk van de werkelijke gebruikspatronen. In het voorbeeld van de web-API kan het bijvoorbeeld blijken dat clients vaak alleen de gebruikersnaam nodig hebben. In dat geval kan het zinvol zijn om deze beschikbaar te maken als een afzonderlijke API-aanroep. Voor meer informatie, zie de antipatroon Extraneous Fetching.
Wanneer u gegevens leest, moet u uw I/O-aanvragen niet te groot maken. Een toepassing mag alleen de informatie ophalen die waarschijnlijk wordt gebruikt.
Soms helpt het om de informatie voor een object te partitioneren in twee segmenten, vaak gebruikte gegevens die de meeste aanvragen verwerken en minder vaak gebruikte gegevens die zelden worden gebruikt. Vaak zijn de meest gebruikte gegevens een relatief klein deel van de totale gegevens voor een object, dus het retourneren van alleen dat gedeelte kan aanzienlijke I/O-overhead besparen.
Vermijd bij het schrijven van gegevens het vergrendelen van resources langer dan nodig om de kans op conflicten tijdens een langdurige bewerking te verminderen. Als een schrijfbewerking meerdere gegevensarchieven, bestanden of services omvat, wordt een uiteindelijk consistente benadering gebruikt. Zie richtlijnen voor gegevensconsistentie.
Als u gegevens in het geheugen buffert voordat u deze schrijft, zijn de gegevens kwetsbaar als het proces vastloopt. Als de gegevenssnelheid meestal pieken heeft of relatief schaars is, kan het veiliger zijn om de gegevens in een externe duurzame wachtrij, zoals Event Hubs, te bufferen.
Overweeg om gegevens die u ophaalt uit een service of database in de cache op te halen. Dit kan helpen om het volume van I/O te verminderen door herhaalde aanvragen voor dezelfde gegevens te voorkomen. Voor meer informatie, zie Aanbevolen procedures voor het cachen.
Het probleem detecteren
Symptomen van chatty I/O omvatten hoge latentie en lage doorvoer. Eindgebruikers melden waarschijnlijk verlengde reactietijden of fouten die worden veroorzaakt door time-outs van services, vanwege toegenomen concurrentie voor I/O-bronnen.
U kunt de volgende stappen uitvoeren om de oorzaken van eventuele problemen te identificeren:
- Voer procesbewaking van het productiesysteem uit om bewerkingen met slechte reactietijden te identificeren.
- Voer belastingstests uit van elke bewerking die in de vorige stap is geïdentificeerd.
- Verzamel tijdens de belastingstests telemetriegegevens over de aanvragen voor gegevenstoegang die door elke bewerking zijn gedaan.
- Verzamel gedetailleerde statistieken voor elke aanvraag die naar een gegevensarchief wordt verzonden.
- Profileer de toepassing in de testomgeving om vast te stellen waar mogelijk I/O-knelpunten optreden.
Zoek naar een van deze symptomen:
- Een groot aantal kleine I/O-aanvragen in hetzelfde bestand.
- Een groot aantal kleine netwerkaanvragen van een toepassingsexemplaar naar dezelfde service.
- Een groot aantal kleine aanvragen van een toepassingsexemplaar op dezelfde gegevensopslag.
- Toepassingen en services worden I/O gebonden.
Voorbeeld van diagnose
In de volgende secties worden deze stappen toegepast op het voorbeeld dat eerder is weergegeven om een query uit te voeren op een database.
De toepassing door middel van een belastingtest testen
In deze grafiek ziet u de resultaten van belastingstests. De mediaanresponstijd wordt gemeten in tientallen seconden per aanvraag. In de grafiek ziet u een zeer hoge latentie. Met een belasting van 1000 gebruikers moet een gebruiker mogelijk bijna een minuut wachten om de resultaten van een query te zien.
Opmerking
De toepassing is geïmplementeerd als een Azure App Service-web-app met behulp van Azure SQL Database. De belastingstest werd uitgevoerd met een gesimuleerde stapworkload van maximaal 1000 gelijktijdige gebruikers. De database is geconfigureerd met een verbindingsgroep die maximaal 1000 gelijktijdige verbindingen ondersteunt, om de kans te verminderen dat conflicten voor verbindingen van invloed zijn op de resultaten.
Het monitoren van de toepassing
U kunt een APM-pakket (Application Performance Management) gebruiken om de belangrijkste metrische gegevens vast te leggen en te analyseren die mogelijk chatty I/O identificeren. Welke metrische gegevens belangrijk zijn, is afhankelijk van de I/O-workload. In dit voorbeeld zijn de interessante I/O-aanvragen de databasequery's.
In de volgende afbeelding ziet u resultaten die zijn gegenereerd met New Relic APM. De gemiddelde reactietijd van de database piekte ongeveer 5,6 seconden per aanvraag tijdens de maximale workload. Het systeem kon gedurende de test gemiddeld 410 aanvragen per minuut ondersteunen.
Verzamel gedetailleerde informatie over gegevenstoegang
Door dieper in de bewakingsgegevens te graven, ziet u dat de toepassing drie verschillende SQL SELECT-instructies uitvoert. Deze komen overeen met de aanvragen die door Entity Framework worden gegenereerd om gegevens op te halen uit de ProductListPriceHistory
, Product
en ProductSubcategory
tabellen. Bovendien is de query waarmee gegevens uit de ProductListPriceHistory
tabel worden opgehaald, verreweg de meest uitgevoerde SELECT-instructie, op volgorde van grootte.
Het blijkt dat de GetProductsInSubCategoryAsync
methode, die eerder wordt weergegeven, 45 SELECT-query's uitvoert. Elke query zorgt ervoor dat de toepassing een nieuwe SQL-verbinding opent.
Opmerking
In deze afbeelding ziet u traceringsgegevens voor de traagste instantie van de GetProductsInSubCategoryAsync
bewerking in de belastingstest. In een productieomgeving is het handig om traceringen van de traagste exemplaren te onderzoeken om te zien of er een patroon is dat een probleem voorstelt. Als u alleen naar de gemiddelde waarden kijkt, kunt u problemen over het hoofd zien die aanzienlijk erger worden bij belasting.
In de volgende afbeelding ziet u de werkelijke SQL-instructies die zijn uitgegeven. De query waarmee prijsgegevens worden opgehaald, wordt uitgevoerd voor elk afzonderlijk product in de subcategorie van het product. Het gebruik van een join vermindert het aantal database-aanroepen aanzienlijk.
Als u een O/RM gebruikt, zoals Entity Framework, kan het traceren van de SQL-query's inzicht geven in de wijze waarop de O/RM programmatische aanroepen naar SQL-instructies vertaalt en gebieden aangeeft waar gegevenstoegang mogelijk is geoptimaliseerd.
De oplossing implementeren en het resultaat controleren
Het herschrijven van de aanroep naar Entity Framework heeft de volgende resultaten opgeleverd.
Deze belastingstest is uitgevoerd op dezelfde implementatie, met hetzelfde belastingsprofiel. Deze keer toont de grafiek veel lagere latentie. De gemiddelde aanvraagtijd bij 1000 gebruikers ligt tussen 5 en 6 seconden, van bijna een minuut.
Deze keer ondersteunde het systeem gemiddeld 3970 aanvragen per minuut, vergeleken met 410 voor de eerdere test.
Bij het traceren van de SQL-instructie ziet u dat alle gegevens worden opgehaald in één SELECT-instructie. Hoewel deze query aanzienlijk complexer is, wordt deze slechts één keer per bewerking uitgevoerd. En hoewel complexe joins duur kunnen worden, worden relationele databasesystemen geoptimaliseerd voor dit type query.