Bilanciamento di coerenza elevata ed finale con Datastore

Offrire un'esperienza utente coerente e sfruttare il modello di coerenza finale per scalare su set di dati di grandi dimensioni

Questo documento illustra come ottenere una coerenza forte per un'esperienza utente positiva, adottando al contempo il modello di coerenza finale di Datastore per la gestione di grandi quantità di dati e utenti.

Questo documento è destinato agli ingegneri e agli architetti software che desiderano creare soluzioni su Datastore. Per aiutare i lettori che hanno più familiarità con i database relazionali rispetto ai sistemi non relazionali come Datastore, questo documento evidenzia concetti analoghi nei database relazionali. Il documento presuppone che tu abbia una familiarità di base con Datastore. Il modo più semplice per iniziare a utilizzare Datastore è in Google App Engine utilizzando una delle lingue supportate. Se non hai ancora utilizzato App Engine, ti consigliamo di leggere prima la Guida introduttiva e la sezione Archiviazione dei dati per una di queste lingue. Anche se per i frammenti di codice di esempio viene utilizzato Python, non è richiesta alcuna esperienza in Python per seguire questo documento.

Nota: Gli snippet di codice in questo articolo utilizzano la libreria client del database Python per Datastore, che non è più consigliata. Gli sviluppatori che creano nuove applicazioni sono vivamente incoraggiati a utilizzare il Libreria client NDB, che offre numerosi vantaggi rispetto a questa libreria client, come la memorizzazione automatica nella cache delle entità tramite tramite Google Cloud CLI o tramite l'API Compute Engine. Se al momento utilizzi la libreria client DB precedente, leggi il Guida alla migrazione da DB a NDB

Sommario

NoSQL e coerenza finale
Coerenza finale in Datastore
Query predecessore e gruppo di entità
Limiti del gruppo di entità e della query predecessore
Alternative alle query predecessori
Ridurre al minimo il tempo per raggiungere la piena coerenza
Conclusione
Risorse aggiuntive

NoSQL e coerenza finale

I database non relazionali, noti anche come database NoSQL, sono emersi negli ultimi anni come alternativa ai database relazionali. Datastore è uno dei database non relazionali più utilizzati nel settore. Nel 2013 Datastore ha elaborato 4,5 trilioni di transazioni al mese (post del blog della piattaforma Google Cloud). Offre agli sviluppatori un modo più semplice per archiviare i dati e accedervi. Lo schema flessibile si mappa naturalmente con i linguaggi di scripting e orientati agli oggetti. Datastore fornisce inoltre una serie di funzionalità che i database relazionali non sono adatti a fornire in modo ottimale, tra cui prestazioni elevate su larga scala e elevata affidabilità.

Per gli sviluppatori più abituati ai database relazionali, può essere difficile progettare un sistema che utilizza database non relazionali, poiché potrebbero avere relativamente poca familiarità con alcune caratteristiche e pratiche di questi database. Sebbene il modello di programmazione Datastore sia semplice, è importante conoscere queste caratteristiche. La coerenza finale è una di queste caratteristiche e la programmazione per la coerenza finale è l'argomento principale di questo documento.

Cos'è la coerenza finale?

La coerenza finale è una garanzia teorica che, a condizione che non vengano effettuati nuovi aggiornamenti a un'entità, tutte le letture dell'entità alla fine restituiranno l'ultimo valore aggiornato. Il DNS (Domain Name System) di internet è un noto esempio di sistema con un modello a coerenza finale. I server DNS non riflettono necessariamente i valori più recenti, ma i valori vengono memorizzati nella cache e replicati su molte directory su Internet. È necessario un certo periodo di tempo per replicare i valori modificati in tutti i client e i server DNS. Tuttavia, il sistema DNS è un sistema di grande successo che è diventato una delle basi di internet. È ad alta disponibilità e ha dimostrato di essere estremamente scalabile, consentendo ricerche di nomi su oltre un centinaio di milioni di dispositivi nell'intera rete internet.

La Figura 1 illustra il concetto di replica con coerenza finale. Il diagramma mostra che, sebbene le repliche siano sempre disponibili per la lettura, alcune repliche potrebbero non essere coerenti con l'ultima scrittura sul nodo di origine, in un determinato momento. Nel diagramma, il nodo A è il nodo di origine, mentre i nodi B e C sono le repliche.

Figura 1: rappresentazione concettuale della replica con coerenza finale

Al contrario, i tradizionali database relazionali sono stati progettati in base al concetto dell'elevata coerenza, chiamata anche coerenza immediata. Ciò vuol dire che i dati visualizzati subito dopo un aggiornamento saranno coerenti per tutti coloro che osservano l'entità. Questa caratteristica è un presupposto fondamentale per molti sviluppatori che usano i database relazionali. Tuttavia, per ottenere elevata coerenza, gli sviluppatori devono trovare un compromesso sulla scalabilità e sulle prestazioni dell'applicazione. Detto in maniera più semplice, i dati devono essere bloccati durante il periodo di aggiornamento o il processo di replica per garantire che nessun altro processo stia aggiornando gli stessi dati.

La Figura 2 mostra una vista concettuale della topologia di deployment e del processo di replica con elevata coerenza. In questo diagramma, puoi vedere come le repliche hanno sempre valori coerenti con il nodo di origine, ma non sono accessibili fino al termine dell'aggiornamento.

Figura 2: rappresentazione concettuale della replica con coerenza forte

Bilanciamento per coerenza elevata e finale

I database non relazionali sono diventati popolari di recente, in particolare per le applicazioni web che richiedono elevata scalabilità e prestazioni con alta disponibilità. I database non relazionali consentono agli sviluppatori di scegliere un equilibrio ottimale tra elevata coerenza e coerenza finale per ogni applicazione. In questo modo gli sviluppatori possono combinare i vantaggi di entrambe le piattaforme. Ad esempio, informazioni quali "sapere chi è online nella tua lista di contatti in un determinato momento" o "sapere quanti utenti hanno aggiunto un Mi piace al tuo post" sono casi d'uso in cui non è richiesta una forte coerenza. La scalabilità e le prestazioni possono essere fornite per questi casi d'uso sfruttando la coerenza eventuale. I casi d'uso che richiedono un'elevata coerenza includono informazioni come "se un utente ha terminato o meno il processo di fatturazione" o "il numero di punti guadagnati da un giocatore durante una sessione di battaglia".

Per generalizzare gli esempi appena forniti, i casi d'uso con un numero molto elevato di entità suggeriscono spesso che la coerenza finale è il modello migliore. Se una query contiene un numero molto elevato di risultati, l'inclusione o l'esclusione di entità specifiche potrebbe non influire sull'esperienza utente. D'altra parte, i casi d'uso con un numero ridotto di entità e un contesto ristretto suggeriscono che è necessaria un'elevata coerenza. L'esperienza utente sarà interessata perché il contesto indicherà agli utenti quali entità devono essere incluse o escluse.

Per questi motivi, è importante che gli sviluppatori comprendano le caratteristiche non relazionali di Datastore. Le sezioni seguenti illustrano come combinare modelli a coerenza finale ed elevata coerenza per creare un'applicazione scalabile, a disponibilità elevata e con prestazioni elevate. In questo modo, saranno comunque soddisfatti i requisiti di coerenza per un'esperienza utente positiva.

Coerenza finale in Datastore

È necessario selezionare l'API corretta quando è richiesta una visualizzazione dei dati a elevata coerenza. Le diverse varietà di API di query Datastore e i relativi modelli di coerenza sono mostrate nella tabella 1.

API Datastore

Lettura del valore dell'entità

Lettura dell'indice

Query globale

Coerenza finale

Coerenza finale

Query globale solo per chiavi

N/D

Coerenza finale

Query predecessore

Elevata coerenza

Elevata coerenza

Ricerca per chiave (get())

Elevata coerenza

N/D

Tabella 1: query Datastore/chiamate Get e possibili comportamenti di coerenza

Le query del datastore senza un elemento antecedente sono chiamate query globali e sono progettate per funzionare con un modello di coerenza finale. Ciò non garantisce un'elevata coerenza. Una query globale solo chiavi è una query globale che restituisce solo le chiavi delle entità corrispondenti alla query, non i valori degli attributi delle entità. Una query predecessore definisce l'ambito della query in base a un'entità predecessore. Le sezioni seguenti descrivono in modo più dettagliato ogni comportamento di coerenza.

Coerenza finale nella lettura dei valori delle entità

Ad eccezione delle query sugli antenati, un valore dell'entità aggiornato potrebbe non essere immediatamente visibile durante l'esecuzione di una query. Per comprendere l'impatto della coerenza finale durante la lettura dei valori delle entità, considera uno scenario in cui un'entità, il player, ha una proprietà, ovvero Punteggio. Considera, ad esempio, che il punteggio iniziale abbia un valore pari a 100. Dopo un po' di tempo, il valore del punteggio viene aggiornato a 200. Se viene eseguita una query globale e nel risultato viene inclusa la stessa entità player, è possibile che il valore della proprietà Punteggio dell'entità restituita appaia invariato, pari a 100.

Questo comportamento è causato dalla replica tra i server Datastore. La replica è gestita da Bigtable e Megastore, le tecnologie sottostanti per Datastore (consulta Risorse aggiuntive per maggiori dettagli su Bigtable e Megastore). La replica viene eseguita con l'algoritmo Paxos, che attende in modo sincrono fino a quando la maggior parte delle repliche non accetta la richiesta di aggiornamento. La replica viene aggiornata con i dati della richiesta dopo un determinato periodo di tempo. Questo periodo di tempo è in genere breve, ma non c'è alcuna garanzia sulla sua durata effettiva. Una query potrebbe leggere i dati inattivi se viene eseguita prima del completamento dell'aggiornamento.

In molti casi, l’aggiornamento avrà raggiunto tutte le repliche molto rapidamente. Tuttavia, esistono diversi fattori che, se combinati insieme, possono aumentare il tempo necessario per ottenere la coerenza. Questi fattori includono eventuali incidenti a livello di data center che comportano il trasferimento di un numero elevato di server tra data center. Data la variabilità di questi fattori, è impossibile fornire requisiti temporali definitivi per stabilire una coerenza completa.

Il tempo necessario a una query per restituire l'ultimo valore in genere è molto breve. Tuttavia, in rari casi in cui la latenza di replica aumenta, il tempo può essere molto più lungo. Le applicazioni che utilizzano query globali di Datastore devono essere progettate con attenzione per gestire in modo elegante questi casi.

L'coerenza finale nella lettura dei valori delle entità può essere evitata utilizzando una query con solo chiavi, una query da predecessore o una ricerca per chiave (metodo get()). Parleremo di questi diversi tipi di query in modo più approfondito di seguito.

Coerenza finale nella lettura di un indice

Un indice potrebbe non essere ancora aggiornato quando viene eseguita una query globale. Ciò significa che, anche se puoi leggere i valori più recenti delle proprietà delle entità, l'"elenco di entità" incluso nel risultato della query potrebbe essere filtrato in base ai vecchi valori dell'indice.

Per comprendere l'impatto della coerenza finale sulla lettura di un indice, immagina uno scenario in cui una nuova entità, il player, viene inserita in Datastore. L'entità ha una proprietà, Punteggio, che ha un valore iniziale di 300. Subito dopo l'inserimento, esegui una query basata solo su chiavi per recuperare tutte le entità con un valore di punteggio maggiore di 0. Dovresti quindi aspettarti che l'entità Player, inserita di recente, venga visualizzata nei risultati della query. Forse inaspettatamente, invece, potresti notare che l'entità Player non compare nei risultati. Questa situazione può verificarsi quando la tabella di indice per la proprietà Score non viene aggiornata con il valore appena inserito al momento dell'esecuzione della query.

Ricorda che tutte le query in Datastore vengono eseguite sulle tabelle di indice, ma gli aggiornamenti alle tabelle di indice sono asincroni. Ogni aggiornamento di un'entità, essenzialmente, è costituito da due fasi. Nella prima fase, quella di commit, viene eseguita una scrittura nel log delle transazioni. Nella seconda fase, i dati vengono scritti e gli indici vengono aggiornati. Se la fase di commit ha esito positivo, viene garantita la riuscita della fase di scrittura, anche se potrebbe non verificarsi immediatamente. Se esegui una query su un'entità prima che gli indici vengano aggiornati, potresti finire per visualizzare dati non ancora coerenti.

A causa di questa procedura in due fasi, esiste un ritardo prima che gli aggiornamenti più recenti delle entità siano visibili nelle query globali. Proprio come per la coerenza finale del valore dell'entità, il ritardo in genere è breve, ma può essere più lungo (anche minuti o più in circostanze eccezionali).

Lo stesso può succedere anche dopo gli aggiornamenti. Ad esempio, supponiamo che tu aggiorni un'entità esistente, Player, con un nuovo valore della proprietà Score pari a 0, ed eseguito la stessa query subito dopo. Ci aspetteresti che l'entità non venga visualizzata nei risultati della query perché il nuovo valore del punteggio pari a 0 la esclude. Tuttavia, a causa dello stesso comportamento di aggiornamento dell'indice asincrono, è ancora possibile che l'entità venga inclusa nel risultato.

La coerenza finale nella lettura di un indice può essere evitata solo utilizzando una query da predecessore o una ricerca per metodo della chiave. Una query basata solo su chiavi non può evitare questo comportamento.

Coerenza elevata nella lettura dei valori e degli indici delle entità

In Datastore, esistono solo due API che forniscono una visualizzazione a elevata coerenza per la lettura dei valori e degli indici delle entità: (1) la ricerca per metodo della chiave e (2) la query da predecessore. Se la logica dell'applicazione richiede elevata coerenza, lo sviluppatore deve utilizzare uno di questi metodi per leggere le entità da Datastore.

Datastore è progettato specificamente per fornire elevata coerenza su queste API. Quando chiami una delle due, Datastore eseguirà lo svuotamento di tutti gli aggiornamenti in attesa su una delle repliche e delle tabelle di indice, quindi eseguirà la query di ricerca o di antenato. Di conseguenza, l'ultimo valore dell'entità, basato sulla tabella di indice aggiornata, verrà sempre restituito con valori basati sugli ultimi aggiornamenti.

A differenza delle query, la ricerca per chiamata di chiave restituisce solo un'entità o un insieme di entità specificate da una chiave o da un insieme di chiavi. Ciò significa che una query sull'antenato è l'unico modo in Datastore per soddisfare il requisito di coerenza forte insieme a un requisito di filtro. Tuttavia, le query predecessore non funzionano senza specificare un gruppo di entità.

Query predecessore e gruppo di entità

Come discusso all'inizio di questo documento, uno dei vantaggi di Datastore è che gli sviluppatori possono trovare un equilibrio ottimale tra elevata coerenza e coerenza finale. In Datastore, un gruppo di entità è un'unità con elevata coerenza, transazioni e località. Utilizzando i gruppi di entità, gli sviluppatori possono definire l'ambito di elevata coerenza tra le entità di un'applicazione. In questo modo, l'applicazione può mantenere la coerenza all'interno del gruppo di entità e, allo stesso tempo, raggiungere scalabilità, disponibilità e prestazioni elevate come un sistema completo.

Un gruppo di entità è una gerarchia formata da entità base e dai relativi elementi secondari o successori.[1] Per creare un gruppo di entità, uno sviluppatore specifica un percorso predecessore, ovvero una serie di chiavi padre che precede la chiave figlio. Il concetto di gruppo di entità è illustrato nella Figura 3. In questo caso, l'entità base con la chiave "ateam" ha due figli con le chiavi "ateam/098745" e "ateam/098746".

Figura 3: vista schematica del concetto di gruppo di entità

All'interno del gruppo di entità, sono garantite le seguenti caratteristiche:

  • Coerenza elevata
    • Una query da predecessore sul gruppo di entità restituirà un risultato a elevata coerenza. In questo modo, riflette gli ultimi valori dell'entità filtrati in base allo stato di indice più recente.
  • Transazionalità
    • Demarcando una transazione in modo programmatico, il gruppo di entità fornisce caratteristiche ACID (atomicità, coerenza, isolamento e durabilità) nella transazione.
  • Località
    • Le entità in un gruppo di entità verranno archiviate in posizioni fisicamente più vicine sui server Datastore, poiché tutte le entità sono ordinate e archiviate in base all'ordine grammaticale delle chiavi. In questo modo, una query sull'antenato può eseguire rapidamente la scansione del gruppo di entità con una quantità minima di I/O.

Una query da predecessore è una forma speciale di query che viene eseguita solo su un gruppo di entità specificato. Viene eseguito con elevata coerenza. Datastore assicura che tutte le repliche e gli aggiornamenti degli indici in attesa vengano applicati prima di eseguire la query.

Esempio di query predecessore

Questa sezione descrive come utilizzare i gruppi di entità e le query sugli antenati nella pratica. Nel seguente esempio viene esaminato il problema della gestione dei record di dati per le persone. Supponiamo di avere codice che aggiunge un'entità di un tipo specifico seguita immediatamente da una query su quel tipo. Questo concetto è dimostrato dal codice Python di esempio riportato di seguito.

# Define the Person entity
class Person(db.Model):
    given_name = db.StringProperty()
    surname = db.StringProperty()
    organization = db.StringProperty()
# Add a person and retrieve the list of all people
class MainPage(webapp2.RequestHandler):
    def post(self):
        person = Person(given_name='GI', surname='Joe', organization='ATeam')
        person.put()
        q = db.GqlQuery("SELECT * FROM Person")
        people = []
        for p in q.run():
            people.append({'given_name': p.given_name,
                        'surname': p.surname,
                        'organization': p.organization})

Il problema con questo codice è che, nella maggior parte dei casi, la query non restituisce l'entità aggiunta nell'istruzione precedente. Poiché la query segue la riga che segue subito dopo l'inserimento, l'indice non verrà aggiornato quando viene eseguita la query. Tuttavia, esiste anche un problema di validità di questo caso d'uso: è davvero necessario restituire un elenco di tutte le persone in una pagina senza contesto? E se ci fossero un milione di persone? Il ritorno della pagina impiegherebbe troppo tempo.

La natura del caso d'uso suggerisce che dovremmo fornire un po' di contesto per restringere la query. In questo esempio, il contesto che utilizzeremo sarà l'organizzazione. Se lo facciamo, è possibile utilizzare l'organizzazione come un gruppo di entità ed eseguire una query da predecessore, risolvendo così il nostro problema di coerenza. Ciò è dimostrato con il codice Python riportato di seguito.

class Organization(db.Model):
    name = db.StringProperty()
class Person(db.Model):
    given_name = db.StringProperty()
    surname = db.StringProperty()
class MainPage(webapp2.RequestHandler):
    def post(self):
        org = Organization.get_or_insert('ateam', name='ATeam')
        person = Person(parent=org)
        person.given_name='GI'
        person.surname='Joe'
        person.put()
        q = db.GqlQuery("SELECT * FROM Person WHERE ANCESTOR IS :1 ", org)
        people = []
        for p in q.run():
            people.append({'given_name': p.given_name,
                        'surname': p.surname})

Questa volta, con l'organizzazione predecessore specificata in GqlQuery, la query restituisce l'entità appena inserita. L'esempio potrebbe essere esteso per visualizzare in dettaglio una singola persona eseguendo una query sul suo nome con il predecessore come parte della query. In alternativa, questa operazione potrebbe essere eseguita salvando la chiave dell'entità e utilizzandola per eseguire una ricerca per chiave.

Mantenere la coerenza tra Memcache e Datastore

I gruppi di entità possono essere utilizzati anche come unità per mantenere la coerenza tra le voci Memcache e le entità Datastore. Ad esempio, considera uno scenario in cui conti il numero di persone in ogni team e le archivi in Memcache. Per assicurarti che i dati memorizzati nella cache siano coerenti con i valori più recenti in Datastore, puoi utilizzare i metadati del gruppo di entità. I metadati restituiscono il numero di versione più recente del gruppo di entità specificato. Puoi confrontare il numero di versione con quello archiviato in Memcache. Con questo metodo, puoi rilevare una modifica in qualsiasi entità dell'intero gruppo leggendo da un set di metadati, invece di analizzare tutte le singole entità del gruppo.

Limiti della query sui gruppi di entità e sui predecessori

L'approccio che utilizza i gruppi di entità e le query dei predecessori non è una soluzione miracolosa. Esistono due sfide nella pratica che rendono difficile applicare questa tecnica in generale, come elencato di seguito.

  1. Esiste un limite di un aggiornamento al secondo di scrittura per ogni gruppo di entità.
  2. La relazione del gruppo di entità non può essere modificata dopo la creazione dell'entità.

Limite di scrittura

Una sfida importante è che il sistema deve essere progettato per contenere il numero di aggiornamenti (o transazioni) in ciascun gruppo di entità. Il limite supportato è di un aggiornamento al secondo per gruppo di entità.[2] Se il numero di aggiornamenti deve superare questo limite, il gruppo di entità potrebbe rappresentare un collo di bottiglia delle prestazioni.

Nell'esempio precedente, ogni organizzazione potrebbe dover aggiornare il record di qualsiasi persona all'interno dell'organizzazione. Considera uno scenario in cui il team "ateam" è composto da 1000 persone e ogni persona può ricevere un aggiornamento al secondo su una delle proprietà. Di conseguenza, nel gruppo di entità potrebbero essere presenti fino a 1000 aggiornamenti al secondo, un risultato che non sarebbe possibile ottenere a causa del limite di aggiornamento. Ciò dimostra che è importante scegliere un progetto di gruppo di entità appropriato che tenga conto dei requisiti di prestazioni. Questa è una delle sfide nel trovare l'equilibrio ottimale tra coerenza finale ed elevata coerenza.

Immutabilità delle relazioni tra gruppi di entità

Una seconda sfida è l'immutabilità delle relazioni tra gruppi di entità. La relazione del gruppo di entità viene formata in modo statico in base alla denominazione delle chiavi. Non può essere modificato dopo la creazione dell'entità. L'unica opzione disponibile per modificare la relazione è eliminare le entità in un gruppo di entità e ricrearle di nuovo. Questa sfida ci impedisce di utilizzare i gruppi di entità per definire ambiti ad hoc per garantire coerenza o transazioni in modo dinamico. L'ambito di coerenza e transazionalità è invece strettamente collegato al gruppo di entità statico definito in fase di progettazione.

Ad esempio, supponiamo che tu intenda implementare un bonifico bancario tra due conti bancari. Questo scenario aziendale richiede elevata coerenza e transazionalità. Tuttavia, i due account non possono essere raggruppati in un gruppo di entità all'ultimo minuto o essere basati su un account principale globale. Questo gruppo di entità creerebbe un collo di bottiglia per l'intero sistema che impedirebbe l'esecuzione di altre richieste di bonifico bancario. Di conseguenza, i gruppi di entità non possono essere utilizzati in questo modo.

Esiste un modo alternativo per implementare un bonifico bancario in modo altamente scalabile e disponibile. Anziché inserire tutti gli account in un unico gruppo di entità, puoi creare un gruppo di entità per ogni account. In questo modo, puoi utilizzare le transazioni per garantire gli aggiornamenti ACID su entrambi i conti bancari. Le transazioni sono una funzionalità di Datastore che consente di creare insiemi di operazioni con caratteristiche ACID per un massimo di venticinque gruppi di entità. Tieni presente che, all'interno di una transazione, devi utilizzare query a elevata coerenza, come ricerche per query chiave e predecessore. Per saperne di più sulle limitazioni delle transazioni, vedi Transazioni e gruppi di entità.

Alternative alle query predecessore

Se hai già un'applicazione esistente con un numero elevato di entità archiviate in Datastore, potrebbe essere difficile incorporare successivamente i gruppi di entità in un esercizio di refactoring. Sarà necessario eliminare tutte le entità e aggiungerle all'interno di una relazione con un gruppo di entità. Pertanto, nella modellazione dei dati per Datastore, è importante prendere una decisione sulla progettazione dei gruppi di entità nella fase iniziale della progettazione dell'applicazione. In caso contrario, il refactoring potrebbe essere limitato ad altre alternative per raggiungere un determinato livello di coerenza, ad esempio una query solo per chiavi seguita da una ricerca per chiave o l'utilizzo di Memcache.

Query globale solo chiavi seguita da Ricerca per chiave

Una query globale basata solo su chiavi è un tipo speciale di query globale che restituisce solo chiavi senza i valori di proprietà delle entità. Poiché i valori restituiti sono solo chiavi, la query non coinvolge un valore dell'entità con un possibile problema di coerenza. Una combinazione della query globale solo chiavi con un metodo di ricerca consente di leggere gli ultimi valori delle entità. Tuttavia, è necessario tenere presente che una query globale basata solo su chiavi non può escludere la possibilità che un indice non sia ancora coerente al momento della query, il che potrebbe comportare il mancato recupero di un'entità. Il risultato della query potrebbe essere potenzialmente generato in base al filtraggio dei vecchi valori dell'indice. In sintesi, uno sviluppatore può utilizzare una query globale basata solo su chiavi seguita dalla ricerca per chiave solo se il requisito dell'applicazione consente che il valore dell'indice non sia ancora coerente al momento della query.

Utilizzare Memcache

Il servizio Memcache è volatile, ma a elevata coerenza. Quindi, combinando le ricerche Memcache e le query Datastore, è possibile creare un sistema che riduca al minimo i problemi di coerenza nella maggior parte dei casi.

Ad esempio, considera lo scenario di un'applicazione di gioco che mantiene un elenco di entità Giocatori, ciascuna con un punteggio maggiore di zero.

  • Per le richieste di inserimento o aggiornamento, applicale all'elenco di entità Player in Memcache e Datastore.
  • Per le richieste di query, leggi l'elenco delle entità player da Memcache ed esegui una query basata solo su chiavi in Datastore se l'elenco non è presente in Memcache.

L'elenco restituito sarà coerente ogni volta che l'elenco memorizzato nella cache è presente in Memcache. Se la voce è stata rimossa o se il servizio Memcache non è temporaneamente disponibile, il sistema potrebbe dover leggere il valore da una query Datastore che potrebbe restituire un risultato incoerente. Questa tecnica può essere applicata a qualsiasi applicazione che tollera una piccola quantità di incoerenza.

Esistono alcune best practice per l'utilizzo di Memcache come livello di memorizzazione nella cache per Datastore:

  • Rileva le eccezioni e gli errori di Memcache per mantenere la coerenza tra il valore Memcache e il valore Datastore. Se ricevi un'eccezione quando aggiorni la voce su Memcache, assicurati di invalidare la voce precedente in Memcache. In caso contrario, potrebbero esserci valori diversi per un'entità (un valore precedente in Memcache e un nuovo in Datastore).
  • Imposta un periodo di scadenza per le voci Memcache. È consigliabile impostare brevi periodi di tempo per la scadenza di ogni voce per ridurre al minimo la possibilità di incoerenze in caso di eccezioni Memcache.
  • Utilizza la funzionalità confronta e imposta quando aggiorni le voci per il controllo della concorrenza. In questo modo, gli aggiornamenti simultanei sulla stessa voce non interferiranno tra loro.

Migrazione graduale a gruppi di entità

I suggerimenti forniti nella sezione precedente riducono solo la possibilità di comportamenti incoerenti. Quando è richiesta un'elevata coerenza, è preferibile progettare l'applicazione in base a gruppi di entità e query dei predecessori. Tuttavia, potrebbe non essere possibile eseguire la migrazione di un'applicazione esistente, il che può includere la modifica di un modello dei dati e della logica dell'applicazione esistenti da query globali a query predecessore. Un modo per raggiungere questo obiettivo è seguire un processo di transizione graduale, come il seguente:

  1. Identificare e dare priorità alle funzioni nell'applicazione che richiedono elevata coerenza.
  2. Scrivi una nuova logica per le funzioni insert() o update() utilizzando gruppi di entità in aggiunta alla logica esistente (anziché sostituirla). In questo modo, tutti i nuovi inserimenti o aggiornamenti sia di nuovi gruppi di entità che di vecchie entità possono essere gestiti da una funzione appropriata.
  3. Modifica la logica esistente per le query predecessore delle funzioni di lettura o di query che vengono eseguite se esiste un nuovo gruppo di entità per la richiesta. Eseguire la vecchia query globale come logica di fallback se il gruppo di entità non esiste.

Questa strategia consente una migrazione graduale da un modello dei dati esistente a un nuovo modello dei dati in base ai gruppi di entità, in modo da ridurre al minimo il rischio di problemi causati dalla coerenza finale. In pratica, questo approccio dipende da casi d'uso e requisiti specifici per la sua applicazione a un sistema reale.

Riserva alla modalità ridotta

Al momento, è difficile rilevare una situazione in modo programmatico quando un'applicazione ha deteriorato la coerenza. Tuttavia, se si verifica tramite altri mezzi che un'applicazione ha deteriorato la coerenza, potrebbe essere possibile implementare una modalità con prestazioni ridotte che potrebbe essere attivata o disattivata per disabilitare alcune aree della logica dell'applicazione che richiedono elevata coerenza. Ad esempio, anziché mostrare un risultato di query incoerente nella schermata di un report sulla fatturazione, è possibile che venga visualizzato un messaggio di manutenzione per quella determinata schermata. In questo modo, gli altri servizi nell'applicazione possono continuare a gestire il servizio e, a loro volta, ridurre l'impatto sull'esperienza utente.

Ridurre al minimo il tempo per raggiungere la piena coerenza

In un'applicazione di grandi dimensioni con milioni di utenti o terabyte di entità Datastore, è possibile che l'utilizzo improprio di Datastore porti a una consistenza peggiorata. Queste pratiche includono:

  • Numerazione sequenziale nelle chiavi di entità
  • Troppi indici

Queste pratiche non riguardano le applicazioni di piccole dimensioni. Tuttavia, una volta che l'applicazione diventa molto grande, queste pratiche aumentano la possibilità di tempi più lunghi necessari per la coerenza. Pertanto, è meglio evitarli nelle fasi iniziali della progettazione dell'applicazione.

Anti-pattern 1: numerazione sequenziale delle chiavi di entità

Prima del rilascio dell'SDK di App Engine 1.8.1, Datastore utilizzava una sequenza di piccoli ID interi con pattern generalmente consecutivi come nomi delle chiavi predefiniti generati automaticamente. In alcuni documenti questo viene chiamato "criterio legacy" per la creazione di entità per cui non è specificato un nome chiave per l'applicazione. Questo criterio precedente generava nomi delle chiavi delle entità con numerazione sequenziale, ad esempio 1000, 1001, 1002. Tuttavia, come discusso in precedenza, Datastore archivia le entità in base all'ordine lessicografico dei nomi delle chiavi, in modo che molto probabilmente queste entità siano archiviate negli stessi server Datastore. Se un'applicazione attira traffico molto grande, questa numerazione sequenziale potrebbe causare una concentrazione di operazioni su un server specifico, con conseguente maggiore latenza per coerenza.

Nell'SDK App Engine 1.8.1, Datastore ha introdotto un nuovo metodo di numerazione degli ID con un criterio predefinito che utilizza ID sparsi (consulta la documentazione di riferimento). Questo criterio predefinito genera una sequenza casuale di ID lunghi fino a 16 cifre distribuiti approssimativamente in modo uniforme. Utilizzando questo criterio, è probabile che il traffico dell'applicazione di grandi dimensioni venga distribuito meglio tra un insieme di server Datastore con tempi ridotti per la coerenza. Il criterio predefinito è consigliato, a meno che la tua applicazione non richieda specificamente la compatibilità con il criterio precedente.

Se imposti esplicitamente i nomi delle chiavi sulle entità, lo schema di denominazione dovrebbe essere progettato in modo da accedere alle entità in modo uniforme nell'intero spazio dei nomi della chiave. In altre parole, non concentrare l'accesso in un determinato intervallo poiché questi sono ordinati in base all'ordine lessicografico dei nomi delle chiavi. In caso contrario, potrebbe verificarsi lo stesso problema della numerazione sequenziale.

Per comprendere la distribuzione non uniforme dell'accesso nello spazio delle chiavi, prendi in considerazione un esempio in cui le entità vengono create con i nomi delle chiavi sequenziali come mostrato nel seguente codice:

p1 = Person(key_name='0001')
p2 = Person(key_name='0002')
p3 = Person(key_name='0003')
...

Il pattern di accesso all'applicazione può creare un "hotspot" su un determinato intervallo di nomi delle chiavi, ad esempio avere un accesso concentrato su entità Persona create di recente. In questo caso, le chiavi a cui si accede di frequente avranno tutte ID più elevati. Il carico può quindi essere concentrato su un server Datastore specifico.

In alternativa, per comprendere la distribuzione uniforme nello spazio delle chiavi, valuta la possibilità di utilizzare lunghe stringhe casuali per i nomi delle chiavi. Questo è illustrato nel seguente esempio:

p1 = Person(key_name='t9P776g5kAecChuKW4JKCnh44uRvBDhU')
p2 = Person(key_name='hCdVjL2jCzLqRnPdNNcPCAN8Rinug9kq')
p3 = Person(key_name='PaV9fsXCdra7zCMkt7UX3THvFmu6xsUd')
...

Ora le entità Person create di recente saranno sparse nello spazio delle chiavi e su più server. Questo presuppone che ci sia un numero sufficientemente elevato di entità Person.

Pattern antico n. 2: troppi indici

In Datastore, un aggiornamento su un'entità comporterà l'aggiornamento di tutti gli indici definiti per quel tipo di entità. Se un'applicazione utilizza molti indici personalizzati, un aggiornamento potrebbe comportare decine, centinaia o addirittura migliaia di aggiornamenti nelle tabelle di indice. In un'applicazione di grandi dimensioni, un uso eccessivo di indici personalizzati può comportare un aumento del carico sul server e potrebbe aumentare la latenza per ottenere coerenza.

Nella maggior parte dei casi, gli indici personalizzati vengono aggiunti per soddisfare requisiti di assistenza come l'assistenza clienti, la risoluzione dei problemi o le attività di analisi dei dati. BigQuery è un motore di query ad altissima scalabilità in grado di eseguire query ad hoc su grandi set di dati senza indici predefiniti. È più adatto a casi d'uso come l'assistenza clienti, la risoluzione dei problemi o l'analisi dei dati che richiedono query complesse rispetto a Datastore.

Una pratica consiste nel combinare Datastore e BigQuery per soddisfare diversi requisiti aziendali. Utilizza Datastore per l'elaborazione transazionale online (OLTP) richiesta per la logica dell'applicazione principale e BigQuery per l'elaborazione analitica online (OLAP) per le operazioni di backend. Potrebbe essere necessario implementare un flusso di esportazione dati continuo da Datastore a BigQuery per spostare i dati necessari per queste query.

Oltre a un'implementazione alternativa per gli indici personalizzati, un altro consiglio è quello di specificare esplicitamente le proprietà non indicizzate (consulta Proprietà e tipi di valori). Per impostazione predefinita, Datastore crea una tabella di indice diversa per ogni proprietà indicizzabile di un tipo di entità. Se disponi di 100 proprietà su un tipo, ci saranno 100 tabelle di indice per quel tipo e ulteriori 100 aggiornamenti a ogni aggiornamento di un'entità. Una best practice, quindi, consiste nell'impostare le proprietà non indicizzate, ove possibile, se non sono necessarie per una condizione di query.

Oltre a ridurre la possibilità di aumentare i tempi per la coerenza, queste ottimizzazioni degli indici possono comportare una riduzione significativa dei costi di archiviazione di Datastore in un'applicazione di grandi dimensioni che utilizza molto gli indici.

Conclusione

La coerenza finale è un elemento essenziale dei database non relazionali che consente agli sviluppatori di trovare un equilibrio ottimale tra scalabilità, prestazioni e coerenza. È importante capire come gestire il bilanciamento tra coerenza finale e coerenza forte per progettare un modello di dati ottimale per la tua applicazione. In Datastore, l'utilizzo di gruppi di entità e query predecessore è il modo migliore per garantire un'elevata coerenza in un ambito di entità. Se la tua applicazione non può incorporare gruppi di entità a causa delle limitazioni descritte in precedenza, puoi prendere in considerazione altre opzioni come l'utilizzo di query basate solo su chiavi o di Memcache. Per le applicazioni di grandi dimensioni, applica best practice come l'utilizzo di ID sparsi e una riduzione dell'indicizzazione per ridurre il tempo necessario per la coerenza. Potrebbe inoltre essere importante combinare Datastore con BigQuery per soddisfare i requisiti aziendali per query complesse e ridurre il più possibile l'utilizzo degli indici Datastore.

Risorse aggiuntive

Le seguenti risorse forniscono ulteriori informazioni sugli argomenti trattati in questo documento:




[1] Un gruppo di entità può anche essere formato specificando solo una chiave dell'entità radice o principale, senza memorizzare le entità effettive per l'entità principale o principale, perché le funzioni di gruppo di entità sono tutte implementate in base alle relazioni tra le chiavi.

[2] Il limite supportato è di un aggiornamento al secondo per gruppo di entità esterno alle transazioni o di una transazione al secondo per gruppo di entità. Se aggreghi più aggiornamenti in un'unica transazione, allora hai un limite di dimensione massima della transazione di 10 MB e della velocità di scrittura massima del server Datastore.