Ottimizzazione della progettazione dello schema per Spanner

Le tecnologie di archiviazione di Google supportano alcune delle più grandi applicazioni al mondo. Tuttavia, la scalabilità non è sempre un risultato automatico dell'uso di questi sistemi. I designer devono pensare attentamente a come modellare i dati per garantire che l'applicazione possa scalare ed eseguire le attività in base alla crescita in varie dimensioni.

Spanner è un database distribuito e per utilizzarlo in modo efficace è necessario pensare in modo diverso alla progettazione dello schema e ai pattern di accesso rispetto ai database tradizionali. I sistemi distribuiti, per loro natura, costringono i progettisti a pensare alla località dei dati e dell'elaborazione.

Spanner supporta query e transazioni SQL con la possibilità di fare lo scale out orizzontalmente. Spesso è necessaria una progettazione accurata per sfruttare tutti i vantaggi di Spanner. Questo documento illustra alcune delle idee chiave che ti aiuteranno a garantire la scalabilità dell'applicazione a livelli arbitrari e a massimizzarne le prestazioni. Due strumenti in particolare hanno un grande impatto sulla scalabilità: la definizione della chiave e l'interleaving.

Layout tabella

Le righe in 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. che mostra tutte le proprietà standard della località:

  • Analizzare la tabella in ordine lessicografico è efficace.
  • Le righe abbastanza chiuse verranno archiviate negli stessi blocchi del disco e verranno lette e memorizzate nella cache insieme.

Spanner replica i dati su più zone per garantire disponibilità e scalabilità, in cui ogni zona contiene una replica completa dei tuoi dati. Quando esegui il provisioning di un nodo di istanza Spanner, ottieni questa quantità di risorse di calcolo in ciascuna di queste zone. Sebbene ogni replica sia un set completo dei tuoi dati, i dati all'interno di una replica sono partizionati tra le risorse di calcolo della zona.

I dati all'interno di ogni replica di Spanner sono organizzati in due livelli di gerarchia fisica: divisioni del database e poi blocchi. Le suddivisioni contengono intervalli contigui di righe e sono l'unità con cui Spanner distribuisce il database tra le risorse di calcolo. Nel tempo, le divisioni possono essere suddivise in parti più piccole, unite o spostate in altri nodi nell'istanza per aumentare il parallelismo e consentire la scalabilità dell'applicazione. Le operazioni che coprono le suddivisioni sono più costose rispetto a quelle equivalenti che non lo fanno, a causa dell'aumento della comunicazione. anche se le suddivisioni vengono servite dallo stesso nodo.

Esistono due tipi di tabelle in Spanner: tabelle root (a volte chiamate tabelle di primo livello) e tabelle con interleaving. Le tabelle con interleaving vengono definite specificando un'altra tabella come parent, causando il clustering delle righe nella tabella con interleaving con la riga padre. Le tabelle radice non hanno un elemento padre e ogni riga in una tabella radice definisce una nuova riga di primo livello, o riga principale. Le righe interleaving con questa riga radice sono chiamate righe figlio, mentre la raccolta di una riga radice più tutti i suoi discendenti è chiamata albero delle righe. La riga padre deve esistere prima di poter inserire righe figlio. La riga padre può già esistere nel database o può essere inserita prima dell'inserimento delle righe figlio nella stessa transazione.

Spanner esegue automaticamente il partizionamento delle suddivisioni quando lo ritiene necessario a causa delle dimensioni o del carico. Per preservare la località dei dati, Spanner preferisce aggiungere confini divisi il più vicino possibile alle tabelle principali, in modo che ogni albero di righe specifico possa essere mantenuto in un'unica suddivisione. Ciò significa che le operazioni all'interno di un albero di riga tendono a essere più efficienti perché è improbabile che richiedano la comunicazione con altri segmenti.

Tuttavia, se è presente un hotspot in una riga figlio, Spanner tenterà di aggiungere confini della suddivisione alle tabelle con interleaving per isolare la riga di hotspot, insieme a tutte le righe figlio sottostanti.

Scegliere quali tabelle devono essere root è una decisione importante nella progettazione della tua applicazione per la scalabilità. I roots sono in genere elementi come utenti, account, progetti e simili e le loro tabelle figlio contengono la maggior parte degli altri dati sull'entità in questione.

Consigli:

  • Utilizza un prefisso di chiave comune per le righe correlate nella stessa tabella per migliorare la località.
  • Interlea i dati correlati in un'altra tabella quando opportuno.

Svantaggi della località

Se i dati vengono scritti o letti di frequente insieme, la latenza e la velocità effettiva possono essere associate al clustering dei dati, selezionando con attenzione le chiavi primarie e utilizzando l'interleaving. Questo perché la comunicazione con qualsiasi blocco del server o del disco ha un costo fisso. Quindi, perché non ottenere il maggior numero possibile di messaggi? Inoltre, maggiore è il numero di server con cui comunichi, maggiori sono le probabilità di incontrare un server temporaneamente occupato, con conseguente aumento delle latenze di coda. Infine, le transazioni che coprono le suddivisioni, sebbene automatiche e trasparenti in Spanner, hanno un costo della CPU e una latenza leggermente più elevati a causa della natura distribuita del commit in due fasi.

D'altra parte, se i dati sono correlati, ma non si accede di frequente insieme, valuta la possibilità di separarli. Questo offre il vantaggio maggiore quando i dati a cui si accede raramente sono grandi. Ad esempio, molti database archiviano dati binari di grandi dimensioni fuori banda provenienti dai dati della riga primaria, con riferimenti solo ai dati di grandi dimensioni con interleaving.

Tieni presente che alcuni livelli di commit in due fasi e operazioni sui dati non locali sono inevitabili in un database distribuito. Non preoccuparti troppo di avere una storia perfetta sulla località per ogni operazione. Concentrati sulla località desiderata per le entità root più importanti e i pattern di accesso più comuni, e lascia che vengano eseguite operazioni distribuite meno frequenti o meno sensibili alle prestazioni quando necessario. Il commit in due fasi e le letture distribuite aiutano a semplificare gli schemi e ad allentare il lavoro del programmatore: in tutti i casi d'uso, tranne in quelli più critici per le prestazioni, è meglio lasciarli.

Consigli:

  • Organizzare i dati in gerarchie in modo che i dati letti o scritti insieme tendano a essere nelle vicinanze.
  • Valuta la possibilità di archiviare colonne di grandi dimensioni in tabelle con interleaving se accedi con meno frequenza.

Opzioni di indice

Gli indici secondari consentono di trovare rapidamente le righe in base a valori diversi dalla chiave primaria. Spanner supporta indici sia con interleaving che con interleaving. Gli indici senza interleaving sono l'impostazione predefinita e il tipo più simile a quanto supportato in un RDBMS tradizionale. Non pongono restrizioni sulle colonne indicizzate e, nonostante siano efficaci, non sono sempre la scelta migliore. Gli indici con interleaving devono essere definiti sopra le colonne che condividono un prefisso con la tabella padre e consentono un maggiore controllo della località.

Spanner archivia i dati degli indici come le tabelle, con una riga per voce di indice. Molte delle considerazioni sulla progettazione delle tabelle si applicano anche agli indici. Gli indici senza interleaving archiviano i dati nelle tabelle radice. Poiché le tabelle root possono essere suddivise tra qualsiasi riga radice, ciò garantisce che gli indici senza interleaving possano scalare a dimensioni arbitrarie e, ignorando gli hotspot, a quasi tutti i carichi di lavoro. Purtroppo ciò significa anche che le voci di indice di solito non si trovano nelle stesse suddivisioni dei dati principali. Questo genera lavoro e latenza aggiuntivi per qualsiasi processo di scrittura e aggiunge ulteriori suddivisioni per la consulenza in fase di lettura.

Gli indici con interleaving, invece, memorizzano i dati in tabelle con interleaving. Sono adatti quando esegui ricerche all'interno del dominio di una singola entità. Gli indici con interleaving costringono i dati e le voci di indice a rimanere nello stesso albero di righe, rendendo i join tra di loro molto più efficienti. Esempi di utilizzo di un indice con interleaving:

  • Accesso alle foto in vari ordinamento, come data di scatto, data dell'ultima modifica, titolo, album ecc.
  • Trovare tutti i tuoi post che hanno un determinato insieme di tag.
  • Ricerca dei miei ordini di acquisto precedenti che contenevano un articolo specifico.

Consigli:

  • Utilizza indici senza interleaving quando devi trovare righe da qualsiasi punto del database.
  • Vengono preferiti gli indici con interleaving ogni volta che le ricerche hanno come ambito una singola entità.

Clausola indice STORING

Gli indici secondari consentono di trovare le righe in base ad attributi diversi dalla chiave primaria. Se tutti i dati richiesti sono presenti nell'indice stesso, è possibile consultarli autonomamente senza leggere il record principale. In questo modo puoi risparmiare risorse significative, perché non è richiesta l'unione.

Sfortunatamente, le chiavi di indice hanno un limite di 16 numeri e 8 KiB di dimensione aggregata, il che limita gli elementi che possono essere inseriti al loro interno. Per compensare queste limitazioni, Spanner ha la possibilità di archiviare dati aggiuntivi in qualsiasi indice, tramite la clausola STORING. STORING una colonna in un indice ne comporta la duplicazione, con una copia archiviata nell'indice. Un indice con STORING può essere paragonato a una semplice vista materializzata di una singola tabella (al momento le visualizzazioni non sono supportate in modo nativo in Spanner).

Un'altra utile applicazione di STORING è l'elemento di un indice NULL_FILTERED. Ciò ti consente di definire quale sia effettivamente una vista materializzata di un sottoinsieme sparso di una tabella che puoi analizzare in modo efficiente. Ad esempio, puoi creare un indice di questo tipo nella colonna is_unread di una casella di posta per poter pubblicare la visualizzazione dei messaggi da leggere in un'unica scansione della tabella, ma senza pagare una copia completa di ogni casella di posta.

Consigli:

  • Fai un uso prudente di STORING per confrontare le prestazioni del tempo di lettura con le prestazioni del tempo di scrittura e delle dimensioni dello spazio di archiviazione.
  • Usa NULL_FILTERED per controllare i costi di archiviazione degli indici sparsi.

Anti-pattern

Anti-pattern: ordinazione con timestamp

Molti progettisti di schemi sono inclini a definire una tabella radice con ordinazioni di timestamp e aggiornate a ogni scrittura. Sfortunatamente, questa è una delle cose meno scalabili che puoi fare. Il motivo è che questo design genera un hot spot enorme alla fine della tabella, che non può essere facilmente attenuato. Con l'aumento delle percentuali di scrittura, aumentano anche le RPC su una singola suddivisione, così come gli eventi di contesa del blocco e altri problemi. Spesso questi tipi di problemi non si verificano nei piccoli test di carico, ma compaiono dopo che l'applicazione è in produzione per un po' di tempo. A quel punto, è troppo tardi!

Se la tua applicazione deve necessariamente includere un log con timestamp ordinato, valuta se puoi rendere locale il log interfolando in una delle altre tabelle principali. Questo ha il vantaggio di distribuire l'hot spot su molte radici. Ma devi comunque fare attenzione che ogni radice distinta abbia una velocità di scrittura sufficientemente bassa.

Se hai bisogno di una tabella ordinata con timestamp globale (cross root) e devi supportare frequenze di scrittura per questa tabella superiori rispetto a quelle supportate da un singolo nodo, utilizza lo sharding a livello di applicazione. Eseguire il partizionamento di una tabella significa partizionarla in numero N di divisioni approssimativamente uguali, chiamate shard. In genere, questo viene fatto inserendo il prefisso della chiave primaria originale con un'ulteriore colonna ShardId contenente valori interi compresi tra [0, N). Il ShardId per una determinata scrittura viene generalmente selezionato in modo casuale o eseguendo l'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 ogni caso, l'obiettivo è garantire che, nel tempo, le scritture vengano distribuite in modo uguale tra tutti gli shard. A volte questo approccio significa che le letture devono analizzare tutti gli shard per ricostruire l'ordine totale originale delle scritture.

Illustrazione di shard per parallelismo
e righe in ordine temporale per shard

Consigli:

  • Evita tabelle e indici ordinati con timestamp con frequenza di scrittura elevata a tutti i costi.
  • Utilizza alcune tecniche per distribuire gli hotspot, ad esempio l'interfoliazione in un'altra tabella o la suddivisione in segmenti.

Anti-pattern: sequenze

Gli sviluppatori di applicazioni amano utilizzare sequenze di database (o incremento automatico) per generare chiavi primarie. Purtroppo, questa abitudine dei giorni RDBMS (chiamate chiavi surrogate) è dannosa quasi quanto l'anti-pattern di ordinamento dei timestamp descritto sopra. Il motivo è che le sequenze di database tendono a emettere valori in modo quasi monotonico, nel tempo, a produrre valori raggruppati vicino all'altro. In questo modo vengono generati degli hotspot quando vengono utilizzati come chiavi primarie, soprattutto per le righe root.

Contrariamente all'uso convenzionale di RDBMS, ti consigliamo di utilizzare attributi reali per le chiavi primarie quando opportuno. in particolare se l'attributo non verrà mai modificato.

Se vuoi generare chiavi primarie numeriche univoche, cerca di far sì che i bit di ordine elevato dei numeri successivi vengano distribuiti approssimativamente equamente nell'intero spazio numerico. Un trucco è generare numeri sequenziali con metodi convenzionali e quindi l'inversione di bit per ottenere il valore finale. In alternativa, potresti utilizzare un generatore UUID, ma fai attenzione: non tutte le funzioni UUID vengono create in modo uguale e alcune memorizzano il timestamp nei bit di ordine elevato, annullando efficacemente il vantaggio. Assicurati che il generatore UUID scelga in modo pseudo-casuale i bit di alto ordine.

Consigli:

  • Evita di utilizzare valori di sequenza incrementali come chiavi primarie. Puoi invece eseguire l'inversione dei bit di un valore di sequenza o usare un UUID scelto con cura.
  • Utilizza valori reali per le chiavi primarie anziché surrogate.