← tutti gli articoli

System Design

Perché lo scaling conta anche prima che la tua applicazione diventi grande

Lo scaling non riguarda un traffico che non hai ancora. Alcune abitudini .NET, tenere l'app stateless, async fino in fondo, e non chiamare il database in un loop, non costano niente adesso e sono dolorose da aggiungere più avanti.

Perché lo scaling conta anche prima che la tua applicazione diventi grande

"Non devi preoccuparti dello scaling ancora" è un consiglio che ho sentito spesso, ed è per lo più giusto. Progettare per milioni di utenti quando non ne hai nessuno è il modo in cui i progetti non vengono mai lanciati.

Ma esiste una categoria di decisioni che non costa niente se la prendi presto ed è genuinamente dolorosa da aggiungere dopo, e da sviluppatore junior quelle sono le decisioni che vale la pena conoscere. Non perché i miei progetti abbiano traffico, ma perché alcune di queste sono la differenza tra aggiungere un secondo server come modifica di config o come riscrittura.

Tieni lo stato fuori dall'applicazione

Questa è quella grossa. Se la mia API salva qualcosa di importante nella propria memoria, allora un request deve tornare alla stessa istanza per funzionare correttamente, e questo significa che non posso mai far girare due copie.

Dati di session in memoria, una lista in cache in un campo static, un file caricato scritto sul disco locale. Ognuna di queste cose lega silenziosamente un utente a un'istanza specifica. Spostale fuori, in un database, in una cache distribuita, nel blob storage, e all'improvviso non importa più quale istanza risponde a un request.

Mi sono imbattuto in questo concretamente con l'URL shortener che ho costruito. Tre copie dell'app dietro un load balancer funzionano solo perché nessuna di esse tiene niente in locale. Ogni copia vede gli stessi dati perché il dato vive in Cassandra e Redis, non nell'app. Non era un'ottimizzazione di scaling, era semplicemente come l'app doveva essere scritta perché il load balancing fosse possibile in primo luogo.

Async fino in fondo

In ASP.NET Core, un thread bloccato in attesa di una chiamata al database è un thread che non può servire un altro request. Con un utente questo è invisibile. Con cento request concorrenti è la differenza tra gestirli e metterli in coda.

// blocca un thread mentre aspetta
var product = _context.Products.Find(id);

// rilascia il thread di nuovo al pool
var product = await _context.Products.FindAsync(id);

La versione async non è più veloce per un singolo request. Significa solo che il thread torna al pool invece di restarsene lì senza fare niente, ed è ciò che permette a un server piccolo di gestire molto più lavoro concorrente di quanti thread abbia.

Scrivere await fin dall'inizio non costa niente. Convertire un codebase sincrono in async più avanti significa toccare ogni metodo nella catena di chiamate, perché async deve andare fino in fondo.

Non chiamare il database in un loop

L'errore classico, e uno che ho fatto anch'io:

var orders = await _context.Orders.ToListAsync();

foreach (var order in orders)
{
    // una query extra per ogni ordine
    order.Customer = await _context.Customers.FindAsync(order.CustomerId);
}

Con dieci ordini sono undici query e nessuno se ne accorge. Con mille ordini sono mille e una, e l'endpoint va in timeout. La soluzione è chiedere in anticipo quello di cui hai bisogno:

var orders = await _context.Orders
    .Include(o => o.Customer)
    .ToListAsync();

Ciò che rende questo degno di essere colto presto è che funziona bene in sviluppo. Il mio database di test ha cinque righe dentro. Il problema compare solo con dati reali, che è il momento peggiore per scoprirlo.

Prendi solo quello di cui hai bisogno

Abitudine correlata: AsNoTracking() sulle query di sola lettura, e selezionare le colonne specifiche invece di intere entity quando mi servono solo un paio di campi.

var names = await _context.Products
    .AsNoTracking()
    .Select(p => new { p.Id, p.Name })
    .ToListAsync();

Il change tracker di EF Core esiste per poter rilevare cosa hai modificato e generare update. Su una query dove niente viene modificato, quella è contabilità senza motivo.

Metti gli indici presto

Se so che una colonna verrà interrogata, l'indice appartiene alla migration che crea la tabella. Aggiungere un indice a una tabella vuota è istantaneo. Aggiungerne uno a una tabella grande in produzione è un'operazione attorno a cui devi pianificare.

Cosa ignorerei ancora

Livelli di cache, message queue, read replica, sharding, qualsiasi cosa del genere. Quelle risolvono problemi che posso misurare, e non posso ancora misurarli. Aggiungere una cache a un progetto senza traffico significa solo più codice e una nuova classe di bug dove la cache e il database sono in disaccordo.

La linea che traccio davvero

La domanda che mi faccio non è "questo scalerà." È "se questo dovrà cambiare più avanti, è una modifica di config o una riscrittura?"

Stateless, async, niente query nei loop, indici sensati — sono tutte cose "scrivilo così fin dall'inizio ed è gratis." Caching, queue, e replica sono tutte cose "aggiungile quando hai una misurazione che dice che ti servono." Fare bene la prima lista presto è ciò che rende la seconda lista una scelta invece che un'emergenza.