Nota
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare ad accedere o modificare le directory.
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare a modificare le directory.
L'effetto cumulativo di un numero elevato di richieste di I/O può avere un impatto significativo sulle prestazioni e sulla velocità di risposta.
Descrizione del problema
Le chiamate di rete e altre operazioni di I/O sono intrinsecamente lente rispetto alle attività di calcolo. Ogni richiesta di I/O ha in genere un sovraccarico significativo e l'effetto cumulativo di numerose operazioni di I/O può rallentare il sistema. Ecco alcune cause comuni di I/O verboso.
Lettura e scrittura di singoli record in un database come richieste distinte
Nell'esempio seguente si legge un database di prodotti. Sono disponibili tre tabelle, Product
, ProductSubcategory
e ProductPriceListHistory
. Il codice recupera tutti i prodotti in una sottocategoria, insieme alle informazioni sui prezzi, eseguendo una serie di query:
- Consulta la sottocategoria nella tabella
ProductSubcategory
. - Trovare tutti i prodotti in tale sottocategoria eseguendo una query sulla tabella
Product
. - Per ogni prodotto, interroga la tabella
ProductPriceListHistory
per i dati sui prezzi.
L'applicazione usa Entity Framework per eseguire query sul 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);
}
}
Questo esempio mostra il problema in modo esplicito, ma a volte un O/RM può mascherare il problema, se recupera implicitamente i record figlio uno alla volta. Questo problema è noto come "N+1".
Implementazione di una singola operazione logica come una serie di richieste HTTP
Questo accade spesso quando gli sviluppatori tentano di seguire un paradigma orientato agli oggetti e considerano gli oggetti remoti come se fossero oggetti locali in memoria. Questo può comportare troppi trasferimenti di rete. Ad esempio, l'API Web seguente espone le singole proprietà degli User
oggetti tramite singoli metodi HTTP GET.
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)
{
...
}
}
Anche se non c'è nulla di tecnicamente errato con questo approccio, la maggior parte dei client probabilmente dovrà ottenere diverse proprietà per ogni User
, con conseguente codice client simile al seguente.
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();
Lettura e scrittura in un file su disco
L'I/O del file comporta l'apertura di un file e lo spostamento nel punto appropriato prima di leggere o scrivere dati. Al termine dell'operazione, il file potrebbe essere chiuso per salvare le risorse del sistema operativo. Un'applicazione che legge e scrive continuamente piccole quantità di informazioni in un file genererà un sovraccarico di I/O significativo. Le richieste di scrittura di piccole dimensioni possono anche causare la frammentazione dei file, rallentando ulteriormente le operazioni di I/O successive.
Nell'esempio seguente viene utilizzato un oggetto FileStream
per scrivere un Customer
oggetto in un file.
FileStream
La creazione di apre il file e la sua eliminazione chiude il file. L'istruzione using
elimina automaticamente l'oggetto FileStream
. Se l'applicazione chiama ripetutamente questo metodo man mano che vengono aggiunti nuovi clienti, l'overhead di I/O può accumularsi rapidamente.
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);
}
}
Come risolvere il problema
Ridurre il numero di richieste di I/O inserendo i dati in richieste di dimensioni maggiori e minori.
Recuperare i dati da un database con un'unica query, anziché con diverse query più piccole. Ecco una versione rivista del codice che recupera le informazioni sul prodotto.
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);
}
}
Seguire i principi di progettazione REST per le API Web. Ecco una versione rivista dell'API Web dell'esempio precedente. Anziché metodi GET separati per ogni proprietà, esiste un singolo metodo GET che restituisce .User
Ciò comporta un corpo di risposta più grande per ogni richiesta, ma è probabile che ogni client eseere meno chiamate API.
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();
Per l'I/O dei file, prendere in considerazione la possibilità di memorizzare nel buffer i dati in memoria e quindi di scrivere i dati memorizzati nel buffer in un file come singola operazione. Questo approccio riduce il sovraccarico dall'apertura e dalla chiusura ripetute del file e consente di ridurre la frammentazione del file su disco.
// 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);
Considerazioni
I primi due esempi effettuano meno chiamate di I/O, ma ognuna recupera più informazioni. È necessario considerare il compromesso tra questi due fattori. La risposta corretta dipenderà dai modelli di utilizzo effettivi. Nell'esempio dell'API Web, ad esempio, potrebbe risultare che i client spesso necessitano solo del nome utente. In tal caso, potrebbe essere opportuno esporlo come chiamata API separata. Per ulteriori informazioni, vedere l'antipattern Extraneous Fetching.
Durante la lettura dei dati, non effettuare richieste di I/O troppo grandi. Un'applicazione deve recuperare solo le informazioni che è probabile che vengano usate.
A volte è utile partizionare le informazioni di un oggetto in due blocchi: dati a cui si accede frequentemente, che rappresentano la maggior parte delle richieste, e dati a cui si accede raramente. Spesso i dati a cui si accede più di frequente sono una parte relativamente piccola dei dati totali per un oggetto, quindi la restituzione di una sola parte può risparmiare un sovraccarico di I/O significativo.
Quando si scrivono dati, evitare di bloccare le risorse per più tempo del necessario, per ridurre le probabilità di conflitti durante un'operazione prolungata. Se un'operazione di scrittura si estende su più archivi dati, file o servizi, adottare un approccio coerente alla fine. Vedere Indicazioni sulla coerenza dei dati.
Se si memorizzano nel buffer i dati in memoria prima di scriverli, i dati sono vulnerabili se il processo si arresta in modo anomalo. Se la frequenza dei dati ha in genere picchi o è relativamente scarsa, potrebbe essere più sicuro memorizzare nel buffer i dati in una coda durevole esterna, ad esempio Hub Eventi.
Prendere in considerazione la memorizzazione nella cache dei dati recuperati da un servizio o da un database. Ciò consente di ridurre il volume di I/O evitando richieste ripetute per gli stessi dati. Per altre informazioni, vedere Procedure consigliate per la memorizzazione nella cache.
Come rilevare il problema
I sintomi di un I/O verboso includono latenza elevata e bassa portata. È probabile che gli utenti finali segnalano tempi di risposta estesi o errori causati dal timeout dei servizi, a causa di una maggiore contesa per le risorse di I/O.
È possibile eseguire i passaggi seguenti per identificare le cause di eventuali problemi:
- Eseguire il monitoraggio dei processi del sistema di produzione per identificare le operazioni con tempi di risposta scarsi.
- Eseguire test di carico di ogni operazione identificata nel passaggio precedente.
- Durante i test di carico, raccogliere dati di telemetria sulle richieste di accesso ai dati effettuate da ogni operazione.
- Raccogliere statistiche dettagliate per ogni richiesta inviata a un archivio dati.
- Profilare l'applicazione nell'ambiente di test per stabilire dove i colli di bottiglia di I/O possano verificarsi.
Cercare uno di questi sintomi:
- Numero elevato di piccole richieste di I/O effettuate allo stesso file.
- Numero elevato di piccole richieste di rete effettuate da un'istanza dell'applicazione allo stesso servizio.
- Numero elevato di richieste di piccole dimensioni effettuate da un'istanza dell'applicazione nello stesso archivio dati.
- Applicazioni e servizi che diventano associati a I/O.
Diagnosi di esempio
Le sezioni seguenti applicano questi passaggi all'esempio illustrato in precedenza per eseguire query su un database.
Testare il carico dell'applicazione
Questo grafico mostra i risultati dei test di carico. Il tempo di risposta mediano viene misurato in decine di secondi per ogni richiesta. Il grafico mostra una latenza molto elevata. Con un carico di 1000 utenti, un utente potrebbe dover attendere quasi un minuto per visualizzare i risultati di una query.
Annotazioni
L'applicazione è stata distribuita come app Web del servizio app di Azure usando il database SQL di Azure. Il test di carico ha utilizzato un carico di lavoro graduale simulato con un massimo di 1000 utenti simultanei. Il database è stato configurato con un pool di connessioni che supporta fino a 1000 connessioni simultanee, per ridurre la probabilità che la contesa per le connessioni influisca sui risultati.
Monitorare l'applicazione
È possibile usare un pacchetto APM (Application Performance Management) per acquisire e analizzare le metriche chiave che potrebbero identificare le operazioni di I/O di chatty. Quali metriche sono importanti dipenderanno dal carico di lavoro di I/O. Per questo esempio, le richieste di I/O interessanti erano le query di database.
L'immagine seguente mostra i risultati generati con New Relic APM. Il tempo medio di risposta del database ha raggiunto un picco di circa 5,6 secondi per ogni richiesta durante il carico di lavoro massimo. Il sistema è stato in grado di supportare una media di 410 richieste al minuto durante il test.
Raccogliere informazioni dettagliate sull'accesso ai dati
L'analisi approfondita dei dati di monitoraggio mostra che l'applicazione esegue tre diverse istruzioni SQL SELECT. Questi corrispondono alle richieste generate da Entity Framework per recuperare i dati dalle ProductListPriceHistory
tabelle , Product
e ProductSubcategory
. Inoltre, la query che recupera i dati dalla ProductListPriceHistory
tabella è di gran lunga l'istruzione SELECT eseguita più di frequente, in base a un ordine di grandezza.
Si scopre che il GetProductsInSubCategoryAsync
metodo, illustrato in precedenza, esegue 45 query SELECT. Ogni query fa sì che l'applicazione apra una nuova connessione SQL.
Annotazioni
Questa immagine mostra le informazioni di traccia per l'istanza più lenta dell'operazione GetProductsInSubCategoryAsync
nel test di carico. In un ambiente di produzione è utile esaminare le tracce delle istanze più lente per verificare se esiste un modello che suggerisce un problema. Se si esaminano solo i valori medi, è possibile ignorare i problemi che potrebbero peggiorare notevolmente sotto il carico.
L'immagine successiva mostra le istruzioni SQL effettive rilasciate. La query che recupera le informazioni sul prezzo viene eseguita per ogni singolo prodotto nella sottocategoria del prodotto. L'uso di un join riduce notevolmente il numero di chiamate di database.
Se si usa un O/RM, ad esempio Entity Framework, la traccia delle query SQL può fornire informazioni dettagliate sul modo in cui O/RM converte le chiamate a livello di codice in istruzioni SQL e indicare le aree in cui l'accesso ai dati potrebbe essere ottimizzato.
Implementare la soluzione e verificare il risultato
La riscrittura della chiamata a Entity Framework ha prodotto i risultati seguenti.
Questo test di carico è stato eseguito nella stessa distribuzione, usando lo stesso profilo di carico. Questa volta il grafico mostra una latenza molto inferiore. Il tempo medio di richiesta per 1000 utenti è compreso tra 5 e 6 secondi, ridotto da quasi un minuto.
Questa volta il sistema supportava una media di 3.970 richieste al minuto, rispetto a 410 per il test precedente.
La traccia dell'istruzione SQL mostra che tutti i dati vengono recuperati in una singola istruzione SELECT. Anche se questa query è notevolmente più complessa, viene eseguita una sola volta per operazione. Mentre i join complessi possono diventare costosi, i sistemi di database relazionali sono ottimizzati per questo tipo di query.