Le tecnologie di archiviazione di Google alimentano alcune delle più grandi applicazioni al mondo. Tuttavia, la scalabilità non è sempre un risultato automatico dell'utilizzo di questi sistemi. I progettisti devono riflettere attentamente su come modellare i dati per garantire che la loro applicazione possa scalare e funzionare man mano che cresce in varie dimensioni.
Spanner è un database distribuito e il suo utilizzo efficace richiede un approccio diverso alla progettazione dello schema e ai modelli di accesso rispetto a quello che potresti adottare con i database tradizionali. I sistemi distribuiti, per loro natura, costringono i progettisti a pensare alla località di dati ed elaborazione.
Spanner supporta query e transazioni SQL con la possibilità di fare lo scale out. Spesso è necessaria una progettazione accurata per sfruttare appieno i vantaggi di Spanner. Questo documento illustra alcune delle idee chiave che ti aiuteranno a garantire che la tua applicazione possa scalare a livelli arbitrari e a massimizzare le sue prestazioni. Due strumenti in particolare hanno un grande impatto sulla scalabilità: la definizione delle chiavi e l'interleaving.
Layout tabella
Le righe di una tabella Spanner sono organizzate lessicograficamente in base a
PRIMARY KEY. A livello concettuale, le chiavi sono ordinate in base alla concatenazione delle
colonne nell'ordine in cui sono dichiarate nella clausola PRIMARY KEY. Questo
mostra tutte le proprietà standard della località:
- La scansione della tabella in ordine lessicografico è efficiente.
- Le righe sufficientemente vicine verranno archiviate negli stessi blocchi del disco e verranno lette e memorizzate nella cache insieme.
Spanner replica i dati in più zone per disponibilità e scalabilità. Ogni zona contiene una replica completa dei tuoi dati. Quando esegui il provisioning di un nodo di istanza Spanner, specifichi la relativa capacità di calcolo. La capacità di calcolo è la quantità di risorse di calcolo allocate alla tua istanza in ciascuna di queste zone. Anche se ogni replica è un insieme completo dei tuoi dati, i dati all'interno di una replica sono partizionati tra le risorse di calcolo in quella zona.
I dati all'interno di ogni replica Spanner sono organizzati in due livelli di gerarchia fisica: divisioni del database e poi blocchi. Suddividono intervalli contigui di righe e sono l'unità in base alla quale Spanner distribuisce il database tra le risorse di calcolo. Nel tempo, le suddivisioni possono essere suddivise in parti più piccole, unite o spostate in altri nodi dell'istanza per aumentare il parallelismo e consentire il ridimensionamento dell'applicazione. Le operazioni che si estendono su più suddivisioni sono più costose delle operazioni equivalenti che non lo fanno, a causa dell'aumento della comunicazione. Ciò vale anche se queste suddivisioni vengono pubblicate dallo stesso nodo.
In Spanner esistono due tipi di tabelle: tabelle radice (a volte chiamate tabelle di primo livello) e tabelle interleaved. Le tabelle interfoliate vengono definite specificando un'altra tabella come padre, in modo che le righe della tabella interfoliata vengano raggruppate con la riga padre. Le tabelle radice non hanno un elemento principale e ogni riga di una tabella radice definisce una nuova riga di primo livello o riga radice. Le righe intercalate con questa riga radice sono chiamate righe secondarie e la raccolta di una riga radice più tutti i suoi discendenti è chiamata albero di righe. La riga principale deve esistere prima di poter inserire le righe secondarie. La riga principale può già esistere nel database o può essere inserita prima dell'inserimento delle righe secondarie nella stessa transazione.
Spanner divide automaticamente le partizioni quando lo ritiene necessario a causa delle dimensioni o del carico. Per preservare la località dei dati, Spanner preferisce aggiungereconfini della suddivisionee il più vicino possibile alle tabelle radice, in modo che qualsiasi albero di righe possa essere mantenuto in una singola suddivisione. Ciò significa che le operazioni all'interno di un albero di righe tendono a essere più efficienti perché è improbabile che richiedano la comunicazione con altre suddivisioni.
Tuttavia, se è presente un hotspot in una riga secondaria, Spanner tenterà di aggiungereconfini della suddivisionee alle tabelle interleaved per isolare la riga dell'hotspot, insieme a tutte le righe secondarie sottostanti.
La scelta delle tabelle che devono essere radici è una decisione importante per progettare la tua applicazione in modo che sia scalabile. Le radici sono in genere elementi come Utenti, Account, Progetti e simili, mentre le tabelle secondarie contengono la maggior parte degli altri dati relativi all'entità in questione.
Consigli:
- Utilizza un prefisso di chiave comune per le righe correlate nella stessa tabella per migliorare la località.
- Inserisci i dati correlati in un'altra tabella quando ha senso.
Compromessi della località
Se i dati vengono scritti o letti spesso insieme, è possibile migliorare sia la latenza che il throughput raggruppandoli selezionando con attenzione le chiavi primarie e utilizzando l'interleaving. Questo perché la comunicazione con qualsiasi server o blocco del disco ha un costo fisso, quindi tanto vale ottenere il massimo possibile. Inoltre, più server contatti, maggiori sono le probabilità di incontrare un server temporaneamente occupato, aumentando le latenze di coda. Infine, le transazioni che si estendono su più suddivisioni, sebbene automatiche e trasparenti in Spanner, hanno un costo della CPU e una latenza leggermente superiori a causa della natura distribuita del commit in due fasi.
Al contrario, se i dati sono correlati ma non vengono consultati spesso insieme, valuta la possibilità di separarli. Questo approccio è più vantaggioso quando i dati ad accesso non frequente sono di grandi dimensioni. Ad esempio, molti database archiviano grandi dati binari fuori banda rispetto ai dati di riga principali, con solo riferimenti ai grandi dati intercalati.
Tieni presente che un certo livello di commit in due fasi e operazioni sui dati non locali sono inevitabili in un database distribuito. Non preoccuparti troppo di ottenere una storia di località perfetta per ogni operazione. Concentrati sull'ottenimento della località desiderata per le entità radice più importanti e i pattern di accesso più comuni e lascia che le operazioni distribuite meno frequenti o meno sensibili al rendimento vengano eseguite quando necessario. Il commit in due fasi e le letture distribuite sono pensati per semplificare gli schemi e ridurre il lavoro dei programmatori: in tutti i casi d'uso tranne quelli più critici per le prestazioni, è meglio lasciarli.
Consigli:
- Organizza i dati in gerarchie in modo che i dati letti o scritti insieme tendano a trovarsi nelle vicinanze.
- Valuta la possibilità di archiviare colonne di grandi dimensioni in tabelle non interleaved se vi si accede meno frequentemente.
Opzioni di indice
Gli indici secondari consentono di trovare rapidamente le righe in base a valori diversi dalla chiave primaria. Spanner supporta indici non intercalati e intercalati. Gli indici non interleaved sono il tipo predefinito e il tipo più analogo a quello supportato in un RDBMS tradizionale. Non impongono restrizioni alle colonne da indicizzare e, sebbene siano potenti, non sono sempre la scelta migliore. Gli indici interleaved devono essere definiti su colonne che condividono un prefisso con la tabella padre e consentono un maggiore controllo della località.
Spanner archivia i dati dell'indice nello stesso modo delle tabelle, con una riga per ogni voce dell'indice. Molte delle considerazioni di progettazione per le tabelle si applicano anche agli indici. Gli indici non intercalati archiviano i dati nelle tabelle radice. Poiché le tabelle radice possono essere suddivise tra qualsiasi riga radice, ciò garantisce che gli indici non interleaved possano essere scalati a dimensioni arbitrarie e, ignorando gli hotspot, a quasi qualsiasi carico di lavoro. Purtroppo, significa anche che le voci dell'indice di solito non si trovano negli stessi split dei dati principali. Questo crea lavoro e latenza aggiuntivi per qualsiasi processo di scrittura e aggiunge ulteriori suddivisioni da consultare al momento della lettura.
Gli indici con interfoliazione, al contrario, archiviano i dati in tabelle con interfoliazione. Sono adatti quando esegui ricerche all'interno del dominio di una singola entità. Gli indici intercalati forzano i dati e le voci di indice a rimanere nello stesso albero di righe, rendendo i join tra loro molto più efficienti. Esempi di utilizzo di un indice interlacciato:
- Accedere alle foto in base a vari ordini di ordinamento, ad esempio data dello scatto, data dell'ultima modifica, titolo, album e così via.
- Trovare tutti i tuoi post con un determinato insieme di tag.
- Trovare i miei ordini di acquisto precedenti che contenevano un articolo specifico.
Consigli:
- Utilizza indici non interleaved quando devi trovare righe da qualsiasi punto del database.
- Preferisci gli indici intercalati ogni volta che le ricerche sono limitate a una singola entità.
Clausola dell'indice STORING
Gli indici secondari consentono di trovare righe in base ad attributi diversi dalla chiave primaria. Se tutti i dati richiesti si trovano nell'indice stesso, possono essere consultati senza leggere il record principale. In questo modo puoi risparmiare risorse significative in quanto non è necessario alcun join.
Purtroppo, le chiavi di indice sono limitate a 16 e a 8 KiB di dimensioni aggregate, il che limita ciò che può essere inserito. Per compensare queste limitazioni,
Spanner è in grado di archiviare dati aggiuntivi in qualsiasi indice utilizzando
la clausola STORING. STORING una colonna in un indice comporta la duplicazione dei relativi valori, con una copia memorizzata nell'indice. Puoi considerare un indice con
STORING come una semplice vista materializzata a tabella singola (le viste non sono supportate
in modo nativo in Spanner al momento).
Un'altra applicazione utile di STORING è come parte di un indice NULL_FILTERED.
In questo modo puoi definire quella che è effettivamente una vista materializzata di un sottoinsieme sparso di una tabella che puoi scansionare in modo efficiente. Ad esempio, potresti creare
un indice di questo tipo nella colonna is_unread di una casella postale per poter visualizzare i
messaggi non letti in una singola scansione della tabella, ma senza pagare per una copia
completa di ogni casella postale.
Consigli:
- Utilizza
STORINGcon prudenza per bilanciare le prestazioni del tempo di lettura rispetto alle dimensioni di archiviazione e alle prestazioni del tempo di scrittura. - Utilizza
NULL_FILTEREDper controllare i costi di archiviazione degli indici sparsi.
Anti-pattern
Anti-pattern: ordinamento dei timestamp
Molti progettisti di schemi tendono a definire una tabella radice ordinata in base al timestamp e aggiornata a ogni scrittura. Purtroppo, questo è uno dei modi meno scalabili. Il motivo è che questo design genera un enorme hotspot alla fine della tabella che non può essere facilmente mitigato. Man mano che aumentano i tassi di scrittura, aumentano anche le RPC a una singola suddivisione, così come gli eventi di contesa dei blocchi e altri problemi. Spesso questo tipo di problemi non si manifesta nei test di carico di piccole dimensioni, ma dopo che l'applicazione è stata in produzione per un po' di tempo. A quel punto, è troppo tardi.
Se la tua applicazione deve assolutamente includere un log ordinato in base al timestamp, valuta se puoi renderlo locale inserendolo in una delle altre tabelle radice. Ciò ha il vantaggio di distribuire l'hotspot su molte radici. Tuttavia, devi comunque fare attenzione che ogni radice distinta abbia una frequenza di scrittura sufficientemente bassa.
Se hai bisogno di una tabella ordinata in base al timestamp globale (cross-root) e devi supportare tassi di scrittura più elevati in quella tabella rispetto a quelli di cui è capace un singolo nodo, utilizza lo sharding a livello di applicazione. Lo sharding di una tabella consiste nel partizionarla in un numero N di divisioni approssimativamente uguali chiamate shard. In genere, questa operazione viene eseguita
anteponendo alla chiave primaria originale una colonna ShardId aggiuntiva contenente
valori interi compresi tra [0, N). Il ShardId per una determinata scrittura viene in genere
selezionato in modo casuale o tramite hashing di una parte della chiave di base. L'hashing è
spesso preferito perché può essere utilizzato per garantire che tutti i record di un determinato tipo vengano inseriti
nello stesso shard, migliorando le prestazioni di recupero. In entrambi i casi, l'obiettivo è
garantire che, nel tempo, le scritture vengano distribuite equamente tra tutti gli shard.
Questo approccio a volte significa che le letture devono scansionare tutti gli shard per ricostruire
l'ordinamento totale originale delle scritture.
Consigli:
- Evita tabelle e indici ordinati in base al timestamp con un'elevata velocità di scrittura a tutti i costi.
- Utilizza una tecnica per distribuire gli hot spot, ad esempio l'interleaving in un'altra tabella o lo sharding.
Anti-pattern: sequenze
Gli sviluppatori di applicazioni adorano utilizzare le sequenze di database (o l'incremento automatico) per generare chiavi primarie. Purtroppo, questa abitudine dei tempi di RDBMS (chiamata chiavi surrogate) è quasi dannosa quanto l'antipattern di ordinamento dei timestamp descritto sopra. Il motivo è che le sequenze di database tendono a emettere valori in modo quasi monotono nel tempo, producendo valori raggruppati vicini tra loro. In genere, quando vengono utilizzate come chiavi primarie, si creano hot spot, soprattutto per le righe radice.
Contrariamente alla saggezza convenzionale degli RDBMS, ti consigliamo di utilizzare attributi reali per le chiavi primarie ogni volta che ha senso. Questo è particolarmente vero se l'attributo non cambierà mai.
Se vuoi generare chiavi primarie univoche numeriche, fai in modo che i bit di ordine superiore dei numeri successivi siano distribuiti in modo più o meno uniforme sull'intero spazio numerico. Un trucco consiste nel generare numeri sequenziali con mezzi convenzionali e poi invertire i bit per ottenere un valore finale. In alternativa, puoi utilizzare un generatore di UUID, ma fai attenzione: non tutte le funzioni UUID sono uguali e alcune memorizzano il timestamp nei bit di ordine superiore, annullando di fatto il vantaggio. Assicurati che il generatore di UUID scelga in modo pseudo-casuale i bit di ordine superiore.
Consigli:
- Evita di utilizzare valori di sequenza incrementali come chiavi primarie. In alternativa, inverti il bit di un valore di sequenza o utilizza un UUID scelto con cura.
- Utilizza valori reali per le chiavi principali anziché chiavi surrogate.
