Ottimizzazione della progettazione dello schema per Spanner

Le tecnologie di archiviazione di Google sono alla base di alcune delle più grandi applicazioni al mondo. Tuttavia, la scalabilità non è sempre un risultato automatico dell'utilizzo di questi sistemi. I designer devono pensare attentamente a come modellare i dati per garantire è in grado di scalare e funzionare man mano che cresce in varie dimensioni.

Spanner è un database distribuito e il suo utilizzo efficace richiede la progettazione dello schema e i pattern di accesso in modo diverso rispetto a quanto tradizionali. I sistemi distribuiti, per loro natura, costringono i progettisti a pensare ai dati e alla località di elaborazione.

Spanner supporta le query e le transazioni SQL con la capacità di scalare in orizzontale. Spesso è necessaria un'attenta progettazione per realizzare vantaggi completi. Questo documento illustra alcune delle idee chiave che ti aiuteranno a garantire che la tua applicazione possa scalare a livelli arbitrari e a massimizzare il suo rendimento. Due strumenti, in particolare, hanno un grande impatto sulla scalabilità: definizione e interfoliazione.

Layout della tabella

Le righe di una tabella Spanner sono organizzate grammaticalmente in base a PRIMARY KEY. Concettualmente, le chiavi sono ordinate dalla concatenazione delle colonne nel nell'ordine in cui vengono dichiarate nella clausola PRIMARY KEY. che mostra tutte le proprietà standard della località:

  • La scansione della tabella in ordine alfabetico è efficiente.
  • Le righe sufficientemente chiuse verranno archiviate negli stessi blocchi di disco e verranno letti e memorizzati nella cache insieme.

Spanner replica i dati in più zone per garantire disponibilità e scalabilità, con ogni zona che contiene una replica completa dei dati. Quando eseguire il provisioning di un nodo di istanza Spanner, si ottiene quella quantità di computing in ciascuna di queste zone. Sebbene ogni replica sia un insieme completo dei tuoi dati, i dati all'interno di una replica vengono suddivisi tra le risorse di calcolo della zona.

I dati all'interno di ogni replica di Spanner sono organizzati in due livelli di gerarchia fisica: suddivisioni del database e poi blocchi. Le suddivisioni contengono intervalli di righe contigue e sono l'unità con cui 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 alla tua applicazione di scalare. Le operazioni che comprendono le suddivisioni costose di operazioni equivalenti che non lo fanno, a causa dell'aumento la comunicazione. Ciò vale anche se tali suddivisioni sono gestite dalla stessa 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 sono definendo un'altra tabella come principale, in modo da generare righe nei con interleaving da raggruppare con la riga padre. Le tabelle radice non hanno principale e ogni riga in una tabella radice definisce una nuova riga di primo livello o riga principale. Le righe interlacciate con questa riga principale sono chiamate righe figlio e la raccolta di una riga radice più tutti i relativi discendenti è chiamato albero di riga. La riga principale deve essere esistente prima di poter inserire le righe secondarie. La riga principale può già esistono nel database o possono essere inserite prima dell'inserimento delle righe figlio nella stessa transazione.

Spanner esegue automaticamente il partizionamento delle suddivisioni quando lo ritiene necessario a causa dimensioni o al caricamento. Per preservare la localizzazione dei dati, Spanner preferisce aggiungere confini di suddivisione il più vicino possibile alle tabelle principali, in modo che qualsiasi albero di righe possa essere mantenuto in un'unica suddivisione. Ciò significa che le operazioni all'interno di una struttura di righe tendono più efficienti perché difficilmente richiedono la comunicazione con altri suddivisioni.

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

Scegliere quali tabelle devono essere le tabelle principali è una decisione importante per progettare un'applicazione scalabile. Le tabelle principali sono in genere elementi come Utenti, Account, Progetti e simili e le relative tabelle secondarie contengono la maggior parte degli altri dati sull'entità in questione.

Consigli:

  • Utilizza un prefisso della chiave comune per le righe correlate nella stessa tabella per migliorare la località.
  • Intercala i dati correlati in un'altra tabella, se opportuno.

Svantaggi della località

Se i dati vengono scritti o letti di frequente insieme, raggrupparli selezionando attentamente le chiavi principali e utilizzando l'interlacciamento può migliorare sia la latenza sia il throughput. Questo perché la comunicazione con qualsiasi server o blocco di disco ha un costo fisso, quindi perché non ottenere il massimo possibile? Inoltre, maggiore è il numero di server con cui comunichi, maggiori sono le possibilità che imbatterai in un server temporaneamente occupato, con conseguente aumento e la latenza minima. Infine, le transazioni che coprono più suddivisioni, sebbene automatiche e trasparenti in Spanner, hanno un costo e una latenza della CPU leggermente superiori a causa della natura distribuita del commit a due fasi.

Al contrario, se i dati sono correlati, ma non vengono spesso utilizzati insieme, valuta la possibilità di separarli. Ciò offre il massimo vantaggio i dati a cui si accede raramente sono grandi. Ad esempio, molti database archiviano i dati binari di grandi dimensioni out-of-band rispetto ai dati di riga principali, con solo riferimenti ai dati di grandi dimensioni interlacciati.

Tieni presente che a un certo livello delle operazioni di commit in due fasi e sui dati non locali vengono inevitabile in un database distribuito. Non preoccuparti troppo di ottenere una storia della località perfetta per ogni operazione. Concentrati sul recupero località desiderata per le entità principali più importanti e l'accesso più comune pattern e consenti di distribuire meno frequenza o meno le operazioni avvengono quando serve. Il commit a due fasi e le letture distribuite sono lì per semplificare gli schemi e sgravare il lavoro dei programmatori: in tutti i casi d'uso, tranne quelli più critici per le prestazioni, è meglio utilizzarli.

Consigli:

  • organizzare i dati in gerarchie in modo che vengano letti o scritti insieme è tendenzialmente nelle vicinanze.
  • Valuta la possibilità di archiviare colonne di grandi dimensioni in tabelle senza interleaving se meno frequentemente o rifiutano le richieste in base all'organizzazione a cui si accede.

Opzioni di indice

Gli indici secondari consentono di trovare rapidamente le righe in base ai valori che non sia la chiave primaria. Spanner supporta sia i nodi senza interleaving sia con interleaving. Gli indici senza interleaving sono quelli predefiniti e il tipo più in modo analogo a quanto supportato nei sistemi RDBMS tradizionali. Non impongono limitazioni alle colonne da indicizzare e, sebbene siano molto efficaci, non sono sempre la scelta migliore. Gli indici con interleaving devono essere definiti su colonne che condividono un prefisso con principale e consentono un maggiore controllo della località.

Spanner archivia i dati di indice come le tabelle, con una riga per voce di indice. Molte delle considerazioni di progettazione per le tabelle si applicano anche agli indici. Gli indici non interlacciati archiviano i dati nelle tabelle principali. Poiché le tabelle root possono essere suddivisi tra qualsiasi riga radice, assicura che gli indici senza interleaving possano scalare a una dimensione arbitraria e, ignorando gli hotspot, a quasi qualsiasi carico di lavoro. Purtroppo, significa anche che le voci dell'indice in genere non si trovano nelle stesse suddivisioni dei dati principali. Questo crea lavoro aggiuntivo e latenza per qualsiasi processo di scrittura aggiunge ulteriori suddivisioni da consultare al momento della lettura.

Gli indici con interfoliazione, invece, archiviano i dati in tabelle con interfoliazione. Sono adatti quando esegui ricerche all'interno del dominio di una singola entità. Gli indici interlacciati forzano le voci di dati e di indice a rimanere nella stessa struttura ad albero di righe, rendendo le unioni tra di loro molto più efficienti. Esempi di utilizzo di un indice con interleaving:

  • Accesso alle foto in base a diversi ordini, ad esempio data dello scatto e ultima modifica data, titolo, album, ecc.
  • Trovare tutti i post che hanno un determinato insieme di tag.
  • Trovare i miei ordini precedenti che contenevano un articolo specifico.

Consigli:

  • Utilizza gli indici non interlacciati quando devi trovare righe da qualsiasi punto del database.
  • Preferisci gli indici con interleaving ogni volta che le tue ricerche hanno come ambito un singolo dell'oggetto.

Clausola di indice STORING

Gli indici secondari consentono di trovare le righe in base ad attributi diversi da quello primario chiave. Se tutti i dati richiesti si trovano nell'indice stesso, questi possono essere consultati su senza leggere il record principale. In questo modo puoi risparmiare risorse significative poiché non è richiesta alcuna unione.

Purtroppo, le chiavi di indice sono limitate a 16 in numero e a 8 KiB in forma aggregata di dimensioni, limitando ciò che può essere inserito. Per compensare queste limitazioni, Spanner ha la possibilità di archiviare dati aggiuntivi in qualsiasi indice, tramite STORING. STORING una colonna in un indice comporta la duplicazione dei relativi valori, con una copia memorizzata nell'indice. Puoi considerare un indice conSTORING come una semplice vista materializzata a tabella singola (al momento le viste non sono supportate in modo nativo in Spanner).

Un'altra applicazione utile di STORING è l'inserimento di un indice NULL_FILTERED. In questo modo puoi definire quella che è in pratica una vista materializzata di un sottoinsieme sparso di una tabella che puoi eseguire la scansione in modo efficiente. Ad esempio, potresti creare un tale indice nella colonna is_unread di una casella di posta in modo da poter gestire visualizzazione dei messaggi da leggere in un'unica scansione della tabella, ma senza pagare per un copia di ogni casella di posta.

Consigli:

  • Utilizza STORING con cautela per trovare il giusto equilibrio tra il rendimento in termini di tempo di lettura e le dimensioni dello spazio di archiviazione e il rendimento in termini di tempo di scrittura.
  • Utilizza NULL_FILTERED per controllare i costi di archiviazione degli indici sparsi.

Anti-pattern

Anti-pattern: ordine con timestamp

Molti progettisti di schemi sono inclini a definire una tabella radice che è un timestamp vengono ordinati e aggiornati a ogni scrittura. Purtroppo, questa è una delle opzioni meno scalabili che puoi adottare. Il motivo è che questo design genera un enorme hotspot alla fine della tabella che non può essere facilmente mitigato. Come le velocità di scrittura aumentano, così come le RPC su una singola suddivisione, così come gli eventi di contesa dei blocchi e altri problemi. Spesso questi tipi di problemi non si verificano nei test di carico ridotto, ma si manifestano dopo che l'applicazione è in produzione da un po' di tempo. A quel punto, è troppo tardi.

Se la tua applicazione deve assolutamente includere un log ordinato in base al timestamp, valuta la possibilità di renderlo locale intercalandolo 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 un livello sufficientemente basso e la velocità di scrittura.

Se hai bisogno di una tabella globale (con radice incrociata) ordinata in base al timestamp e devi supportare velocità di scrittura più elevate rispetto a quelle supportate da un singolo nodo, utilizza lo sharding a livello di applicazione. La suddivisione in parti di una tabella significa suddividerla in un certo numero N di suddivisioni approssimativamente uguali chiamate shard. Questa operazione viene in genere eseguita far precedere la chiave primaria originale con una colonna ShardId aggiuntiva contenente valori interi compresi tra [0, N). Il valore ShardId per una determinata scrittura è in genere 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 nello stesso shard, migliorando le prestazioni di recupero. In ogni caso, l'obiettivo è per garantire che, nel tempo, le scritture siano distribuite equamente tra tutti gli shard. A volte, questo approccio comporta che le letture debbano eseguire la scansione di tutti i frammenti per ricostruire l'ordinamento totale originale delle scritture.

Illustrazione degli shard per il parallelismo e le righe in ordine cronologico per shard

Consigli:

  • Evita a tutti i costi tabelle e indici ordinati con timestamp con frequenza di scrittura elevata.
  • Utilizza una tecnica per distribuire gli hot spot, ad esempio l'interlacciamento in un'altra tabella o la suddivisione in parti.

Antipattern: sequenze

Gli sviluppatori di applicazioni amano utilizzare le sequenze del database (o l'incremento automatico) per generare chiavi primarie. Purtroppo, questa abitudine dei giorni RDBMS (chiamata chiavi surrogate) è dannoso quasi quanto il timestamp che ordina l'anti-pattern descritti sopra. Il motivo è che le sequenze dei database tendono a emettere valori in un modo quasi monotonico, nel tempo, di produrre valori che sono raggruppati vicino a e l'altro. In genere, questo produce hot spot se vengono utilizzati come chiavi principali, in particolare per le righe principali.

Contrariamente alla saggezza convenzionale dei sistemi RDBMS, ti consigliamo di utilizzare attributi reali per le chiavi principali, se opportuno. Questo accade soprattutto se l'attributo non cambierà mai.

Se desideri generare chiavi primarie numeriche univoche, punta a ottenere bit di numeri successivi distribuiti più o meno equamente sull'intera uno spazio numerico. Un trucco è 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 create allo stesso modo e alcune memorizzano il timestamp nei bit di ordine superiore, vanificando 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. Esegui invece l'inversione dei bit di un valore di sequenza o utilizza un UUID scelto con cura.
  • Utilizza valori reali per le chiavi primarie anziché le chiavi surrogate.