Analisi delle prestazioni e ottimizzazione — uno strumento potente per valutare l'adeguatezza delle prestazioni per i clienti.
L'analisi delle prestazioni può essere utilizzata per identificare i colli di bottiglia nel programma, impiegando un approccio scientifico per valutare gli esperimenti di ottimizzazione. Questo articolo delinea un approccio generale all'analisi delle prestazioni e all'ottimizzazione usando un server web Go come esempio.
Go è particolarmente adatto, poiché dispone di strumenti di profiling. pprof nella libreria standard.

Strategia
Creiamo un riepilogo per la nostra analisi strutturale. Cercheremo di utilizzare alcuni dati per prendere decisioni invece di apportare modifiche basate su intuizioni o congetture. Ecco come procederemo:
- Definiamo i confini dell'ottimizzazione (requisiti);
- Calcoliamo il carico di transazione per il sistema;
- Eseguiamo il test (generiamo dati);
- Osserviamo;
- Analizziamo: vengono rispettati tutti i requisiti?
- Ottimizziamo scientificamente, formuliamo un'ipotesi;
- Eseguiamo un esperimento per verificare questa ipotesi.

Architettura di un semplice server HTTP
Per questo articolo utilizzeremo un piccolo server HTTP scritto in Golang. Tutto il codice di questo articolo è disponibile. .
L'applicazione analizzata è un server HTTP che interroga Postgresql per ogni richiesta. Inoltre, ci sono Prometheus, node_exporter e Grafana per raccogliere e visualizzare le metriche dell'applicazione e del sistema.

Per semplificare, supponiamo che, per la scalabilità orizzontale (e per semplificare i calcoli), ciascun servizio e ogni database vengano distribuiti insieme:

Definiamo gli obiettivi
In questo passaggio definiamo l'obiettivo. Cosa stiamo cercando di analizzare? Come sappiamo quando è il momento di concludere? In questo articolo presupponiamo di avere clienti e che il nostro servizio dovrà gestire 10.000 richieste al secondo.
In esamineremo in dettaglio i metodi di selezione e modellazione. Procederemo nello stesso modo, creando modelli:
- Latenza: il 99% delle richieste deve essere completato in meno di 60 ms;
- Costo: il servizio deve consumare la minore somma di denaro che riteniamo ragionevolmente possibile. Per questo, massimizziamo la capacità di throughput;
- Pianificazione delle capacità: è necessario comprendere e documentare quanti esemplari dell'applicazione saranno necessari, inclusa la funzione generale di scalabilità, così come quanti esemplari serviranno per soddisfare i requisiti iniziali di carico e garantire .
La latenza può richiedere ottimizzazione oltre all'analisi, ma il throughput deve essere chiaramente analizzato. Utilizzando il processo SRE, il requisito di latenza proviene dal cliente o dall'azienda, rappresentato dal product owner. E il nostro servizio si impegnerà a rispettarlo fin dall'inizio, senza alcuna configurazione!
Configuriamo l'ambiente di test
Con l'ambiente di test saremo in grado di fornire un carico dosato nel nostro sistema. Per l'analisi saranno generati dati sulle prestazioni del web service.
Carico di transazione
Questo ambiente utilizza per generare una frequenza personalizzabile di richieste HTTP, fino a quando non viene interrotta:
$ make load-test LOAD_TEST_RATE=50
echo "POST http://localhost:8080" | vegeta attack -body tests/fixtures/age_no_match.json -rate=50 -duration=0 | tee results.bin | vegeta reportOsservazione
Durante l'esecuzione verrà applicato un carico transazionale. Oltre alle metriche dell'applicazione (numero di richieste, latenza di risposta) e del sistema operativo (memoria, CPU, IOPS), verrà eseguito un profiling dell'applicazione per comprendere dove si presentano problemi e come viene consumato il tempo della CPU.
Profiling
Il profiling è un tipo di misurazione che consente di vedere dove viene speso il tempo della CPU durante l'esecuzione dell'applicazione. Permette di identificare esattamente dove e quanto tempo CPU viene speso:

Questi dati possono essere utilizzati durante l'analisi per ottenere una visione del tempo della CPU sprecato e del lavoro non necessario eseguito. Go (pprof) può generare profili e visualizzarli sotto forma di flame graph, utilizzando un set di strumenti standard. Spiegherò come utilizzarli e fornirò indicazioni su come configurarli più avanti nell'articolo.
Esecuzione, osservazione, analisi.
Effettueremo un esperimento. Eseguiremo, osserveremo e analizzeremo finché le prestazioni non ci soddisferanno. Sceglieremo un carico inizialmente basso per applicarlo nel nostro primo round di osservazioni. In ogni passaggio successivo, aumenteremo il carico usando un fattore di scalabilità selezionato con una certa variabilità. Ogni esecuzione del test di carico verrà effettuata regolando il numero di richieste: make load-test LOAD_TEST_RATE=X.
50 richieste al secondo

Nota i due grafici in alto. Il grafico in alto a sinistra mostra che la nostra applicazione gestisce 50 richieste al secondo (secondo il suo parere), mentre quello in alto a destra mostra la durata di ciascuna richiesta. Entrambi i parametri ci aiutano a osservare e analizzare se stiamo rimanendo entro i nostri limiti di prestazioni o meno. La linea rossa sul grafico Latenza delle richieste HTTP mostra l'SLO a 60ms. Dalla linea, possiamo vedere che siamo ben al di sotto del nostro tempo massimo di risposta.
Guardiamo sotto l'aspetto dei costi:
10000 richieste al secondo / 50 richieste per server = 200 server + 1
Possiamo ancora migliorare questo valore.
500 richieste al secondo
Fatti più interessanti iniziano a verificarsi quando il carico raggiunge 500 richieste al secondo:

Ancora una volta, nel grafico in alto a sinistra si può vedere che l'applicazione registra un carico normale. Se non è così, c'è un problema con il server su cui è in esecuzione l'applicazione. Il grafico con la latenza delle risposte è posizionato in alto a destra e mostra che 500 richieste al secondo hanno portato a una latenza di risposta di 25-40ms. Il percentile 99 è ancora ben dentro l'SLO di 60ms scelto sopra.
Dal punto di vista dei costi:
10000 richieste al secondo / 500 richieste per server = 20 server + 1
C'è ancora spazio per miglioramenti.
1000 richieste al secondo

Ottimo avvio! L'applicazione mostra di aver gestito 1000 richieste al secondo, tuttavia il limite sulla latenza è stato superato dal lato dell'SLO. Questo è evidente dalla linea p99 nel grafico in alto a destra. Nonostante la linea p100 sia molto più alta, le latenze effettive superano il massimo di 60ms. Approfondiamo il profiling per capire cosa sta realmente facendo l'applicazione.
Profiling
Per il profiling, impostiamo un carico di 1000 richieste al secondo, quindi utilizziamo pprof per raccogliere dati su come l'applicazione spende il tempo del processore. Questo può essere fatto attivando l'endpoint HTTP pprof, dopodiché, sotto carico, salviamo i risultati usando curl:
$ curl http://localhost:8080/debug/pprof/profile?seconds=29 > cpu.1000_reqs_sec_no_optimizations.profI risultati possono essere visualizzati in questo modo:
$ go tool pprof -http=:12345 cpu.1000_reqs_sec_no_optimizations.prof
Il grafico mostra dove e quanto l'applicazione spende tempo della CPU. Dalla descrizione di :
Sull'asse X ci sono i profili degli stack, ordinati in ordine alfabetico (questo non è tempo), mentre l'asse Y mostra la profondità dello stack, contando da zero in [top]. Ogni rettangolo rappresenta un frame dello stack. Più largo è il frame, più frequentemente appare negli stack. Quello in alto utilizza la CPU, mentre quelli sotto sono gli elementi figli. I colori, di solito, non hanno alcun significato e vengono scelti casualmente per distinguere i frame.
Analisi - ipotesi
Per l'ottimizzazione, ci concentreremo sul tentativo di trovare sprechi di tempo della CPU. Cerceremo le fonti più grandi di sprechi inutili e le rimuoveremo. E dato che il profiling rivela molto precisamente dove l'applicazione spende il suo tempo del processore, potrebbe essere necessario farlo più volte e richiedere modifiche al codice sorgente dell'applicazione, riavviare i test e osservare se le prestazioni si avvicinano agli obiettivi predefiniti.
Seguendo le raccomandazioni di Brendan Gregg, leggeremo il grafico dall'alto verso il basso. Ogni riga rappresenta un frame dello stack (chiamata di funzione). La prima riga è il punto di ingresso nel programma, il genitore di tutte le altre chiamate (in altre parole, tutte le altre chiamate avranno questa nel loro stack). La riga successiva è già diversa:
![]()
Se si passa il cursore sul nome della funzione nel grafico, verrà visualizzato il tempo totale in cui è stata nello stack durante il debug. La funzione HTTPServe è stata lì per il 65% del tempo, mentre altre funzioni runtime, runtime.mcall, mstart e gc, hanno occupato il resto del tempo. Un fatto interessante: il 5% del tempo totale è stato speso per richieste DNS:

Gli indirizzi che il programma cerca appartengono a Postgresql. Clicchiamo su FindByAge:

Curioso, il programma mostra che ci sono fondamentalmente tre fonti principali che aggiungono latenza: apertura e chiusura delle connessioni, richiesta di dati e connessione al database. Nel grafico si può osservare che le richieste DNS, l'apertura e la chiusura delle connessioni occupano circa il 13% del tempo totale di esecuzione.
Ipotesi: il riutilizzo delle connessioni tramite un pool dovrebbe ridurre il tempo di ogni richiesta HTTP, consentendo una maggiore capacità e minori latenze..
Ottimizzazione dell'applicazione - esperimento
Aggiorniamo il codice sorgente, proviamo a rimuovere la connessione a Postgresql per ogni richiesta. La prima opzione è utilizzare a livello applicativo. In questo esperimento, configureremo db, err := sql.Open("postgres", dbConnectionString) db.SetMaxOpenConns(8)if err != nil { return nil, err }
Esecuzione, osservazione, analisiEsecuzione, monitoraggio, analisi
Dopo il riavvio del test con 1000 richieste al secondo, si può notare che il p99 dei ritardi è tornato nella norma con SLO di 60 ms!
Qual è il costo?
10000 richieste al secondo / su 1000 richieste per server = 10 server + 1
Facciamo ancora meglio!
2000 richieste al secondo

Il raddoppio del carico mostra lo stesso risultato, il grafico in alto a sinistra dimostra che l'applicazione riesce a gestire 2000 richieste al secondo, con p100 sotto i 60 ms e p99 che soddisfa l'SLO.
Dal punto di vista dei costi:
10000 richieste al secondo / su 2000 richieste per server = 5 server + 1
3000 richieste al secondo

In questo caso, l'applicazione può gestire 3000 richieste con ritardo p99 inferiore a 60 ms. L'SLO non è violato, e il costo è stato accettato come:
10000 richieste al secondo / su 3000 richieste per server = 4 server + 1 (l'autore ha arrotondato per eccesso, nota del traduttore)
Proviamo un altro round di analisi.
Analisi - ipotesi
Raccogliamo e mostriamo i risultati del debug dell'applicazione a 3000 richieste al secondo:

Ancora il 6% del tempo è dedicato all'installazione delle connessioni. La configurazione del pool ha migliorato le prestazioni, ma si vede ancora che l'applicazione continua a creare nuove connessioni con il database.
Ipotesi: Le connessioni, nonostante la presenza del pool, vengono ancora rifiutate e pulite, quindi l'applicazione deve reinstallarle. Impostare il numero di connessioni in attesa alla dimensione del pool dovrebbe aiutare a ridurre il ritardo, minimizzando il tempo che l'applicazione spende per creare una connessione..
Ottimizzazione dell'applicazione - esperimento
Proviamo a impostare uguale alla dimensione del pool (anche descritto ):
db, err := sql.Open("postgres", dbConnectionString)
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(8)
if err != nil {
return nil, err
}Esecuzione, monitoraggio, analisi
3000 richieste al secondo

p99 inferiore a 60 ms con un p100 significativamente ridotto!

Il controllo del flame graph mostra che l'installazione della connessione non è più evidente! Controlliamo più nel dettaglio pg(*conn).query — non notiamo nemmeno l'installazione della connessione qui.

Conclusione
L'analisi delle prestazioni è fondamentale per comprendere se le aspettative dei clienti e i requisiti non funzionali sono soddisfatti. L'analisi confrontando le osservazioni con le aspettative dei clienti può aiutare a determinare cosa sia accettabile e cosa no. Go fornisce strumenti efficaci, integrati nella libreria standard, che rendono l'analisi semplice e accessibile.
Fonte: habr.com
