Cinque studenti e tre negozi di valore-chiave distribuiti

O come abbiamo scritto una libreria C++ client per ZooKeeper, etcd e Consul KV

Nel mondo dei sistemi distribuiti, ci sono una serie di compiti tipici: memorizzare informazioni sulla composizione del cluster, gestire la configurazione dei nodi, rilevare nodi difettosi, scegliere un leader e altri. Per risolvere questi problemi sono stati creati speciali sistemi distribuiti: servizi di coordinamento. Ora saremo interessati a tre di essi: ZooKeeper, etcd e Consul. Tra tutte le ricche funzionalità di Consul, ci concentreremo su Consul KV.

Cinque studenti e tre negozi di valore-chiave distribuiti

In sostanza, tutti questi sistemi sono archivi di valori-chiave linearizzabili e tolleranti agli errori. Sebbene i loro modelli di dati presentino differenze significative, di cui parleremo più avanti, risolvono gli stessi problemi pratici. Ovviamente, ogni applicazione che utilizza il servizio di coordinamento è legata a una di esse, il che può portare alla necessità di supportare più sistemi in un data center che risolvono gli stessi problemi per applicazioni diverse.

L’idea di risolvere questo problema è nata in un’agenzia di consulenza australiana, ed è toccato a noi, un piccolo team di studenti, implementarla, di cui parlerò.

Siamo riusciti a creare una libreria che fornisce un'interfaccia comune per lavorare con ZooKeeper, etcd e Consul KV. La libreria è scritta in C++, ma si prevede di portarla su altri linguaggi.

Modelli di dati

Per sviluppare un'interfaccia comune per tre sistemi diversi, è necessario capire cosa hanno in comune e in cosa differiscono. Scopriamolo.

Custode dello zoo

Cinque studenti e tre negozi di valore-chiave distribuiti

Le chiavi sono organizzate in un albero e sono chiamate nodi. Di conseguenza, per un nodo puoi ottenere un elenco dei suoi figli. Le operazioni di creazione di uno znode (create) e di modifica di un valore (setData) sono separate: solo le chiavi esistenti possono essere lette e modificate. Gli orologi possono essere collegati alle operazioni di verifica dell'esistenza di un nodo, lettura di un valore e acquisizione di figli. Watch è un trigger monouso che si attiva quando cambia la versione dei dati corrispondenti sul server. I nodi temporanei vengono utilizzati per rilevare gli errori. Sono legati alla sessione del client che li ha creati. Quando un client chiude una sessione o smette di notificare a ZooKeeper la sua esistenza, questi nodi vengono automaticamente eliminati. Sono supportate transazioni semplici: un insieme di operazioni che riescono tutte o falliscono se ciò non è possibile per almeno una di esse.

etcd

Cinque studenti e tre negozi di valore-chiave distribuiti

Gli sviluppatori di questo sistema si sono chiaramente ispirati a ZooKeeper e quindi hanno fatto tutto diversamente. Non esiste una gerarchia delle chiavi, ma esse formano un insieme ordinato lessicograficamente. È possibile ottenere o eliminare tutte le chiavi appartenenti a un determinato intervallo. Questa struttura può sembrare strana, ma in realtà è molto espressiva e attraverso di essa è possibile emulare facilmente una visione gerarchica.

etcd non ha un'operazione standard di confronto e impostazione, ma ha qualcosa di meglio: transazioni. Naturalmente esistono in tutti e tre i sistemi, ma le transazioni etcd sono particolarmente buone. Sono costituiti da tre blocchi: verifica, successo, fallimento. Il primo blocco contiene una serie di condizioni, il secondo e il terzo le operazioni. La transazione viene eseguita atomicamente. Se tutte le condizioni sono vere, viene eseguito il blocco di successo, altrimenti viene eseguito il blocco di fallimento. Nell'API 3.3, i blocchi di successo e di errore possono contenere transazioni nidificate. Cioè, è possibile eseguire atomicamente costrutti condizionali di livello di annidamento quasi arbitrario. Puoi saperne di più su quali controlli e operazioni esistono da documentazione.

Anche qui esistono gli orologi, anche se sono un po’ più complicati e riutilizzabili. Cioè, dopo aver installato l'orologio su un intervallo chiave, riceverai tutti gli aggiornamenti in questo intervallo finché non annulli l'orologio, e non solo il primo. In etcd, l'analogo delle sessioni client di ZooKeeper sono i lease.

Il console K.V.

Anche qui non esiste una struttura gerarchica rigorosa, ma Console può creare l'apparenza che esista: puoi ottenere ed eliminare tutte le chiavi con il prefisso specificato, ovvero lavorare con il "sottoalbero" della chiave. Tali query sono chiamate ricorsive. Inoltre, Console può selezionare solo le chiavi che non contengono il carattere specificato dopo il prefisso, che corrisponde all'ottenimento immediato dei “figli”. Ma vale la pena ricordare che questa è proprio l'apparenza di una struttura gerarchica: è del tutto possibile creare una chiave se il suo genitore non esiste o eliminare una chiave che ha figli, mentre i figli continueranno a essere archiviati nel sistema.

Cinque studenti e tre negozi di valore-chiave distribuiti
Invece di vigilare, Consul ha bloccato le richieste HTTP. Si tratta in sostanza di normali richiami al metodo di lettura dei dati, per il quale, insieme ad altri parametri, viene indicata l'ultima versione conosciuta dei dati. Se la versione corrente dei dati corrispondenti sul server è maggiore di quella specificata, la risposta viene restituita immediatamente, altrimenti quando il valore cambia. Esistono anche sessioni che possono essere allegate alle chiavi in ​​qualsiasi momento. Vale la pena notare che, a differenza di etcd e ZooKeeper, dove l'eliminazione delle sessioni comporta l'eliminazione delle chiavi associate, esiste una modalità in cui la sessione viene semplicemente scollegata da esse. Disponibile transazioni, senza filiali, ma con controlli di ogni genere.

Mettere tutto insieme

ZooKeeper ha il modello di dati più rigoroso. Le query sull'intervallo espressivo disponibili in etcd non possono essere emulate in modo efficace né in ZooKeeper né in Consul. Cercando di incorporare il meglio di tutti i servizi, abbiamo ottenuto un'interfaccia quasi equivalente all'interfaccia di ZooKeeper con le seguenti significative eccezioni:

  • sequenza, contenitore e nodi TTL non supportato
  • Gli ACL non sono supportati
  • il metodo set crea una chiave se non esiste (in ZK setData restituisce un errore in questo caso)
  • i metodi set e cas sono separati (in ZK sono essenzialmente la stessa cosa)
  • il metodo cancella cancella un nodo insieme al suo sottoalbero (in ZK delete restituisce un errore se il nodo ha figli)
  • Per ogni chiave esiste solo una versione: la versione del valore (in ZK ce ne sono tre)

Il rifiuto dei nodi sequenziali è dovuto al fatto che etcd e Consul non hanno un supporto integrato per essi e possono essere facilmente implementati dall'utente sull'interfaccia della libreria risultante.

L'implementazione di un comportamento simile a ZooKeeper durante l'eliminazione di un vertice richiederebbe il mantenimento di un contatore figlio separato per ogni chiave in etcd e Consul. Poiché abbiamo cercato di evitare di memorizzare metainformazioni, si è deciso di eliminare l'intero sottoalbero.

Sottigliezze di implementazione

Diamo uno sguardo più da vicino ad alcuni aspetti dell'implementazione dell'interfaccia della libreria in diversi sistemi.

Gerarchia in ecc

Mantenere una vista gerarchica in etcd si è rivelato uno dei compiti più interessanti. Le query di intervallo semplificano il recupero di un elenco di chiavi con un prefisso specificato. Ad esempio, se hai bisogno di tutto ciò che inizia con "/foo", stai chiedendo un intervallo ["/foo", "/fop"). Ma ciò restituirebbe l'intero sottoalbero della chiave, il che potrebbe non essere accettabile se il sottoalbero è grande. Inizialmente avevamo pianificato di utilizzare un meccanismo di traduzione chiave, implementato in zeetcd. Si tratta di aggiungere un byte all'inizio della chiave, pari alla profondità del nodo nell'albero. Lasciate che vi faccia un esempio.

"/foo" -> "u01/foo"
"/foo/bar" -> "u02/foo/bar"

Quindi prendi tutti i figli immediati della chiave "/foo" possibile richiedendo un intervallo ["u02/foo/", "u02/foo0"). Sì, in ASCII "0" sta subito dopo "/".

Ma come implementare la rimozione di un vertice in questo caso? Risulta che è necessario eliminare tutti gli intervalli del tipo ["uXX/foo/", "uXX/foo0") per XX dal 01 alle FF. E poi ci siamo imbattuti limite del numero di operazioni all'interno di una transazione.

Di conseguenza, è stato inventato un semplice sistema di conversione delle chiavi, che ha permesso di implementare in modo efficace sia l'eliminazione della chiave sia l'ottenimento di un elenco di bambini. È sufficiente aggiungere un carattere speciale prima dell'ultimo token. Per esempio:

"/very" -> "/u00very"
"/very/long" -> "/very/u00long"
"/very/long/path" -> "/very/long/u00path"

Quindi eliminando la chiave "/very" si trasforma in cancellazione "/u00very" e portata ["/very/", "/very0")e ottenere tutti i bambini - in una richiesta di chiavi dalla gamma ["/very/u00", "/very/u01").

Rimozione di una chiave in ZooKeeper

Come ho già detto, in ZooKeeper non puoi eliminare un nodo se ha dei figli. Vogliamo eliminare la chiave insieme al sottoalbero. Cosa dovrei fare? Lo facciamo con ottimismo. Innanzitutto, attraversiamo ricorsivamente il sottoalbero, ottenendo i figli di ciascun vertice con una query separata. Quindi creiamo una transazione che tenta di eliminare tutti i nodi del sottoalbero nell'ordine corretto. Naturalmente, possono verificarsi cambiamenti tra la lettura di un sottoalbero e la sua eliminazione. In questo caso la transazione fallirà. Inoltre, il sottoalbero può cambiare durante il processo di lettura. Una richiesta per i figli del nodo successivo può restituire un errore se, ad esempio, questo nodo è già stato cancellato. In entrambi i casi, ripetiamo nuovamente l'intero processo.

Questo approccio rende l'eliminazione di una chiave molto inefficace se ha figli, e ancora di più se l'applicazione continua a funzionare con il sottoalbero, eliminando e creando chiavi. Tuttavia, questo ci ha permesso di evitare di complicare l'implementazione di altri metodi in etcd e Consul.

impostato in ZooKeeper

In ZooKeeper ci sono metodi separati che funzionano con la struttura ad albero (create, delete, getChildren) e che funzionano con i dati nei nodi (setData, getData). Inoltre, tutti i metodi hanno precondizioni rigorose: create restituirà un errore se il nodo ha già stato creato, eliminare o setData – se non esiste già. Avevamo bisogno di un metodo impostato che possa essere chiamato senza pensare alla presenza di una chiave.

Un’opzione è adottare un approccio ottimistico, come nel caso della cancellazione. Controlla se esiste un nodo. Se esiste, chiama setData, altrimenti crea. Se l'ultimo metodo ha restituito un errore, ripetere tutto da capo. La prima cosa da notare è che il test di esistenza è inutile. Puoi chiamare immediatamente create. Il completamento con successo significherà che il nodo non esisteva ed è stato creato. In caso contrario, create restituirà l'errore appropriato, dopodiché sarà necessario chiamare setData. Naturalmente, tra una chiamata e l'altra, un vertice potrebbe essere cancellato da una chiamata concorrente e anche setData restituirebbe un errore. In questo caso puoi rifare tutto da capo, ma ne vale la pena?

Se entrambi i metodi restituiscono un errore, allora sappiamo per certo che è avvenuta una cancellazione concorrente. Immaginiamo che questa cancellazione sia avvenuta dopo aver chiamato set. Allora qualunque significato stiamo cercando di stabilire è già cancellato. Ciò significa che possiamo supporre che set sia stato eseguito con successo, anche se in realtà non è stato scritto nulla.

Maggiori dettagli tecnici

In questa sezione faremo una pausa dai sistemi distribuiti e parleremo di codifica.
Uno dei requisiti principali del cliente era la multipiattaforma: almeno uno dei servizi doveva essere supportato su Linux, MacOS e Windows. Inizialmente, abbiamo sviluppato solo per Linux e in seguito abbiamo iniziato a testarlo su altri sistemi. Ciò ha causato molti problemi, ai quali per qualche tempo non era del tutto chiaro come affrontare. Di conseguenza, tutti e tre i servizi di coordinamento sono ora supportati su Linux e MacOS, mentre solo Consul KV è supportato su Windows.

Fin dall'inizio, abbiamo cercato di utilizzare librerie già pronte per accedere ai servizi. Nel caso di ZooKeeper la scelta è ricaduta ZooKeeper C++, che alla fine non è riuscito a compilare su Windows. Ciò, tuttavia, non sorprende: la libreria è posizionata solo per Linux. Per Console l'unica opzione era ppconsul. A ciò bisognava aggiungere il supporto sessioni и transazioni. Per etcd non è stata trovata una libreria completa che supporti l'ultima versione del protocollo, quindi abbiamo semplicemente client grpc generato.

Ispirati dall'interfaccia asincrona della libreria ZooKeeper C++, abbiamo deciso di implementare anche un'interfaccia asincrona. ZooKeeper C++ utilizza primitive future/promesse per questo. In STL, sfortunatamente, sono implementati in modo molto modesto. Ad esempio, no quindi metodo, che applica la funzione passata al risultato del futuro quando diventa disponibile. Nel nostro caso, tale metodo è necessario per convertire il risultato nel formato della nostra libreria. Per aggirare questo problema, abbiamo dovuto implementare il nostro semplice pool di thread, poiché su richiesta del cliente non potevamo utilizzare pesanti librerie di terze parti come Boost.

La nostra implementazione funziona in questo modo. Quando viene chiamato, viene creata un'ulteriore coppia promessa/futuro. Viene restituito il nuovo futuro e quello passato viene messo in coda insieme alla funzione corrispondente e ad una promessa aggiuntiva. Un thread dal pool seleziona diversi futures dalla coda e li interroga utilizzando wait_for. Quando un risultato diventa disponibile, viene chiamata la funzione corrispondente e il suo valore restituito viene passato alla promessa.

Abbiamo utilizzato lo stesso pool di thread per eseguire query su etcd e Consul. Ciò significa che è possibile accedere alle librerie sottostanti da più thread diversi. ppconsul non è thread-safe, quindi le chiamate ad esso sono protette da blocchi.
Puoi lavorare con grpc da più thread, ma ci sono delle sottigliezze. In etcd gli orologi sono implementati tramite flussi grpc. Si tratta di canali bidirezionali per messaggi di un certo tipo. La libreria crea un singolo thread per tutti gli orologi e un singolo thread che elabora i messaggi in arrivo. Quindi grpc proibisce le scritture parallele nello streaming. Ciò significa che quando si inizializza o si elimina un orologio, è necessario attendere il completamento dell'invio della richiesta precedente prima di inviare quella successiva. Usiamo per la sincronizzazione variabili condizionali.

risultato

Vedi di persona: liboffkv.

La nostra squadra: Raed Romanov, Ivan Glushenkov, Dmitrij Kamaldinov, Victor Krapivensky, Vitaly Ivanin.

Fonte: habr.com

Aggiungi un commento