DrikWeb Technical Guide #7

Performance e diagnostica .NET

Perché un’applicazione .NET diventa lenta: come individuare il vero collo di bottiglia

“L’applicazione è lenta” descrive un sintomo, non una causa. Prima di riscrivere codice, aggiungere cache o aumentare le risorse del server bisogna capire quale operazione sta realmente consumando il tempo.

Spesso il collo di bottiglia si nasconde in una parte apparentemente secondaria: una query ripetuta, un binding, una chiamata esterna o il caricamento di dati non necessari.

Perché un’applicazione .NET diventa lenta: come individuare il vero collo di bottiglia

Quando una pagina impiega cinque, dieci o trenta secondi a caricarsi, la reazione più comune è cercare subito “la query lenta”, aumentare le risorse del server o iniziare a riscrivere il metodo più grande.

Il problema è che una pagina web attraversa diversi livelli: browser, controller o page model, servizi applicativi, ORM, database, file, immagini, API esterne e binding dell’interfaccia. Il tempo totale è la somma di tutte queste operazioni.

Per questo la prima attività non dovrebbe essere ottimizzare. Dovrebbe essere misurare.

“L’applicazione è lenta” non è una diagnosi

Una segnalazione di lentezza può indicare problemi molto diversi:

  • una query SQL che legge troppe righe;
  • decine di query veloci eseguite in sequenza;
  • un servizio esterno che risponde lentamente;
  • serializzazione di oggetti troppo grandi;
  • immagini o file caricati inutilmente;
  • binding ripetuti nell’interfaccia;
  • lock, concorrenza o attese sul database;
  • codice eseguito più volte durante la stessa richiesta.

Ottimizzare senza aver separato queste fasi significa procedere per tentativi. Si rischia di introdurre cache, parallelismo o complessità aggiuntiva senza modificare il punto che determina realmente il tempo di risposta.

La prima domanda utile

Quale singola operazione assorbe la maggior parte del tempo totale della richiesta?

Misurare prima di modificare

Per una prima analisi non servono necessariamente strumenti sofisticati. In molte applicazioni legacy è sufficiente inserire misurazioni mirate attorno ai metodi principali.

var stopwatch = Stopwatch.StartNew();

var items = LoadDropDownItems();

stopwatch.Stop();

Debug.WriteLine(
    $"Caricamento elementi: {stopwatch.ElapsedMilliseconds} ms");

Un semplice Stopwatch, insieme a System.Diagnostics.Debug.WriteLine, permette di dividere il tempo totale in blocchi:

  • lettura dei dati principali;
  • caricamento dei dati accessori;
  • trasformazione dei modelli;
  • binding dei controlli;
  • chiamate verso sistemi esterni.

Lo strumento è elementare, ma la strategia è corretta: misurare ogni fase separatamente e restringere progressivamente l’area da analizzare.

Separare database, applicazione e rendering

Prima di intervenire conviene costruire una piccola linea temporale della richiesta.

Richiesta HTTP
      |
      v
Caricamento dati principali
      |
      v
Caricamento dati correlati
      |
      v
Mapping / trasformazione
      |
      v
Binding e rendering
      |
      v
Risposta HTTP

Se il database impiega 200 millisecondi ma la pagina viene restituita dopo dieci secondi, il problema è altrove. Se invece una singola operazione applicativa genera cento round trip SQL, non è utile analizzare soltanto la durata di ogni query presa singolarmente.

Caso reale: una tendina con 60 record bloccava la pagina per oltre 30 secondi

Qualche mese fa ho lavorato sull’ottimizzazione di una pagina appartenente a un’applicazione legacy basata su .NET Framework 4.7 e SQL Server.

La pagina conteneva diversi controlli e operazioni, ma il vero collo di bottiglia non si trovava nella parte apparentemente più complessa. Il rallentamento principale era causato dal caricamento dei dati utilizzati per popolare un semplice menu a tendina.

Per ogni voce venivano recuperati:

  • identificativo;
  • nominativo;
  • una piccola immagine, simile a un logo, memorizzata nel database.

Con soltanto 16 record il caricamento richiedeva circa 3,36 secondi. Con 60 record il tempo saliva a 30,3 secondi.

RecordPrimaDopoMiglioramento
163,36 secondi0,30 secondiCirca 11 volte più veloce
6030,3 secondi0,6 secondiCirca 50 volte più veloce

La riduzione è stata di circa il 91% nel primo caso e di circa il 98% nel secondo.

La causa: query dentro il ciclo

Analizzando il codice è emerso che il caricamento iniziale recuperava un insieme di record e poi eseguiva altre due o tre query per ogni elemento.

foreach (var item in items)
{
    var detail = LoadDetail(item.Id);
    var image = LoadImage(item.Id);
    var additionalData = LoadAdditionalData(item.Id);

    result.Add(CreateDropDownItem(
        item,
        detail,
        image,
        additionalData));
}

Con 16 elementi venivano quindi eseguite decine di query. Con 60 elementi il numero di accessi al database cresceva rapidamente.

Era un tipico problema N+1: una query recuperava l’elenco e altre query venivano ripetute per ciascun record. Le entità erano inoltre caricate per intero, senza proiezioni mirate, anche se la tendina utilizzava soltanto pochi campi.

Perché compiled query e parallelizzazione non bastavano

Prima del mio intervento erano già stati fatti alcuni tentativi di ottimizzazione. Le query erano state compilate e le operazioni all’interno del ciclo erano state parallelizzate.

Entrambe le tecniche possono avere senso in scenari specifici, ma non eliminavano la causa principale: il database continuava a essere interrogato ripetutamente per ogni elemento.

Ottimizzare il sintomo

Una query leggermente più veloce resta un problema se viene eseguita decine o centinaia di volte durante la stessa richiesta.

Parallelizzare questi accessi può perfino peggiorare la situazione, aumentando il numero di connessioni contemporanee e il carico sul database.


Un primo intervento: caricare soltanto i dati necessari

Un primo intervento è stato creare un piccolo DTO con i soli dati necessari a popolare la tendina (Id, Nominativo, Immagine) e fare la projection della tabella di origine in questo DTO in modo da richiedere a SQL Server solo i dati che veramente ci interessavano e che venivano utilizzati.

var items = context.People
    .Select(x => new DropDownItemData
    {
        Id = x.Id,
        DisplayName = x.DisplayName,
        SmallImage = x.SmallImage
    })
    .ToList();

Caricare un’entità completa significa recuperare anche colonne, relazioni e dati che il codice chiamante potrebbe non utilizzare e nel mio caso non utilizzava sicuramente! La proiezione rende più esplicito il contratto e riduce quantità di dati trasferiti e materializzati.

Già con questa piccola modifica i tempi si sono ristretti di circa un 34% nel caso della tabella di 16 record. Non male ma si poteva fare di più.


La soluzione: preload e lookup in memoria

La modifica principale è stata eliminare gli accessi ripetuti a SQL Server dentro il ciclo.

I dati necessari sono stati caricati preliminarmente con un' unica query e organizzati in Dictionary in memoria, usando come chiave l’identificativo del record.

var detailsById = LoadDetails(ids)
    .ToDictionary(x => x.Id);

var imagesById = LoadImages(ids)
    .ToDictionary(x => x.Id);

var additionalDataById = LoadAdditionalData(ids)
    .ToDictionary(x => x.Id);

foreach (var item in items)
{
    detailsById.TryGetValue(item.Id, out var detail);
    imagesById.TryGetValue(item.Id, out var image);
    additionalDataById.TryGetValue(
        item.Id,
        out var additionalData);

    result.Add(CreateDropDownItem(
        item,
        detail,
        image,
        additionalData));
}

Il vantaggio non derivava soltanto dalla velocità del lookup nel dizionario. Il vero miglioramento consisteva nell’aver trasformato decine o centinaia di round trip verso il database in poche letture iniziali.

Con questo tipo di approccio l'intervento sul codice esistente è stato "chirurgico", poco impattante e molto limitato, riducendo così rischi di regressione e comportamenti non previsti nel resto dell'applicazione; il risultato è stato che la fix è potuta andare rapidamente in produzione e il cliente è stato molto soddisfatto.


Segnali che indicano query ripetute

  • il tempo cresce molto più velocemente del numero di record in modo non lineare;
  • le query singole risultano rapide ma la richiesta complessiva è lenta;
  • il log SQL mostra la stessa query ripetuta con parametri diversi;
  • il codice esegue repository o servizi dentro un foreach;
  • la parallelizzazione non produce benefici stabili;
  • il database riceve molte richieste brevi durante una sola pagina.

Quando questi segnali sono presenti, la domanda corretta non è “come rendere più veloce il ciclo?”, ma “perché il ciclo deve interrogare il database a ogni iterazione?”.

Quando usare strumenti più avanzati

Stopwatch e log di debug sono utili per restringere rapidamente il problema, ma non sostituiscono strumenti più evoluti quando la diagnosi coinvolge più componenti.

In base allo scenario può essere utile ricorrere a:

  • SQL Server Profiler o Extended Events;
  • piani di esecuzione;
  • logging strutturato;
  • Application Insights o OpenTelemetry;
  • profiler .NET;
  • metriche applicative e tracing distribuito.

La regola resta la stessa: introdurre lo strumento più complesso solo quando serve a rispondere a una domanda concreta.

Errori comuni nell’ottimizzazione

1. Aumentare subito le risorse del server

Più CPU e memoria possono mascherare temporaneamente il problema, ma non correggono un algoritmo o un pattern di accesso inefficiente.

2. Aggiungere cache senza conoscere la causa

La cache può nascondere un accesso inefficiente e introdurre problemi di invalidazione.

3. Parallelizzare query ripetute

Più concorrenza non significa meno lavoro. Può aumentare la pressione sul database.

4. Ottimizzare una query ignorando quante volte viene eseguita

Il costo complessivo dipende dalla durata della query moltiplicata per il numero di esecuzioni.

5. Riscrivere prima di misurare

Una modifica ampia aumenta rischio e tempi senza garantire di intervenire sul vero collo di bottiglia.

Checklist pratica

Misurazione

  • Hai separato i tempi dei singoli metodi?
  • Conosci il numero di query eseguite?
  • Hai confrontato dataset piccoli e grandi?
  • Hai ripetuto il test più volte?

Database

  • Esistono query dentro cicli?
  • Le entità vengono caricate per intero?
  • Le relazioni generano N+1?
  • Stai trasferendo immagini o blob non necessari?

Codice

  • Puoi precaricare i dati?
  • Puoi usare lookup in memoria?
  • Il metodo viene eseguito più volte?
  • La parallelizzazione è realmente utile?

Verifica

  • Hai misurato prima e dopo?
  • Il miglioramento cresce con il dataset?
  • Il comportamento è diventato lineare?
  • Hai controllato regressioni funzionali?

FAQ

Come posso capire rapidamente quale metodo rallenta una pagina .NET?

Misura separatamente le fasi principali con Stopwatch e logging. Quando individui il blocco più costoso, suddividilo ulteriormente.

Le compiled query risolvono il problema N+1?

No. Possono ridurre il costo di preparazione della singola query, ma non eliminano il numero eccessivo di accessi al database.

È corretto caricare tutti i dati in memoria?

Dipende dal volume. Per insiemi limitati e necessari alla stessa elaborazione, un preload mirato può essere molto efficace. Per dataset grandi servono paginazione, filtri o strategie diverse.

Le immagini nel database rallentano sempre?

Non necessariamente, ma aumentano la quantità di dati trasferiti. Vanno caricate soltanto quando servono e nelle dimensioni adeguate.

La parallelizzazione rende sempre più veloci le query?

No. Può aumentare connessioni, lock e carico. Prima bisogna ridurre il lavoro complessivo.

In sintesi

Le performance migliorano quando si elimina il lavoro inutile.

Nel caso analizzato, il salto da 30,3 a 0,6 secondi non è arrivato da più hardware o da maggiore parallelismo, ma dalla rimozione delle query ripetute, dall’uso di lookup in memoria e da una lettura razionale dal db dei soli dati interessati.

Guide correlate

La tua applicazione .NET è diventata lenta e non è chiaro perché?

Posso supportare software house e team di sviluppo nell’analisi delle performance, nell’individuazione dei colli di bottiglia e nell’ottimizzazione mirata di applicazioni .NET e SQL Server esistenti.

Parliamo del problema