Ciao, mi occupo della creazione di applicazioni per DBMS — è una piattaforma sviluppata da Mail.ru Group, che unisce un DBMS altamente performante a un server di applicazioni in linguaggio Lua. L'alta velocità delle soluzioni basate su Tarantool è raggiunta anche grazie al supporto della modalità in-memory del DBMS e alla possibilità di eseguire la logica di business dell'applicazione nello stesso spazio indirizzabile dei dati. In questo modo si garantisce la persistenza dei dati attraverso l'uso di transazioni ACID (viene mantenuto un registro WAL su disco). Tarantool dispone di un supporto integrato per la replica e lo sharding. A partire dalla versione 2.1, supporta le query in linguaggio SQL. Tarantool ha codice sorgente aperto ed è distribuito sotto licenza Simplified BSD. È disponibile anche una versione commerciale Enterprise.

Senti la potenza! (…ossia goditi le prestazioni)
Tutto ciò rende Tarantool una piattaforma attraente per lo sviluppo di applicazioni ad alto carico che lavorano con i database. In tali applicazioni sorge spesso la necessità di replicare i dati.
Come già accennato, Tarantool ha la replica dei dati integrata. Il principio di funzionamento consiste nell'esecuzione sequenziale su tutte le repliche di tutte le transazioni contenute nel registro master (WAL). In genere, tale replica (che d'ora in poi chiameremo a basso livello) è utilizzata per garantire la resilienza dell'applicazione e/o per distribuire il carico di lettura tra i nodi del cluster.

Fig. 1. Replica all'interno del cluster
Un esempio di scenario alternativo può essere la trasmissione di dati generati in un database a un altro database per elaborazione/monitoraggio. In quest'ultimo caso, potrebbe rivelarsi più comoda l'utilizzazione di replica a livello alto — replica dei dati a livello di logica di business dell'applicazione. Cioè, non utilizziamo una soluzione pronta, integrata nel DBMS, ma implementiamo noi stessi la replica all'interno dell'applicazione che stiamo sviluppando. Questo approccio presenta sia vantaggi che svantaggi. Elenchiamo i punti a favore.
1. Risparmio di traffico:
- È possibile trasmettere solo una parte dei dati (ad esempio, è possibile trasmettere solo alcune tabelle, alcuni loro colonne o record che soddisfano determinati criteri);
- A differenza della replica a basso livello, che viene eseguita continuamente in modalità asincrona (implementata nella versione attuale di Tarantool — 1.10) o sincrona (che sarà implementata nelle versioni future di Tarantool), la replica ad alto livello può essere eseguita in sessioni (cioè, l'applicazione prima esegue la sincronizzazione dei dati — una sessione di scambio dati, quindi si verifica una pausa nella replica, dopo di che avviene la successiva sessione di scambio, e così via);
- Se un record è cambiato più volte, è possibile trasmettere solo la sua ultima versione (a differenza della replica a basso livello, in cui tutte le modifiche effettuate sul master verranno riprodotte consecutivamente sui replica).
2. Non ci sono complessità nell'implementazione dello scambio via HTTP, il che consente di sincronizzare database remoti.

Fig. 2. Replica tramite HTTP
3. Le strutture di database, attraverso le quali vengono trasmessi i dati, non devono essere identiche (infatti, in generale è possibile utilizzare diversi DBMS, linguaggi di programmazione, piattaforme, ecc.).

Fig. 3. Replica in sistemi eterogenei
Il punto negativo è che, in media, la programmazione è più complessa/costosa rispetto alla configurazione, e invece di impostare funzionalità integrate si dovrà realizzare una propria soluzione.
Se nella vostra situazione i vantaggi elencati sono decisivi (o sono una condizione necessaria), ha senso utilizzare la replica di alto livello. Esamineremo diversi modi per implementare la replica di alto livello dei dati nel DBMS Tarantool.
Minimizzazione del traffico
Uno dei vantaggi della replica di alto livello è il risparmio di traffico. Per sfruttare appieno questo vantaggio, è necessario ridurre al minimo la quantità di dati trasferiti durante ciascuna sessione di scambio. Naturalmente, non bisogna dimenticare che alla fine della sessione il destinatario dei dati deve essere sincronizzato con la fonte (almeno per la parte dei dati coinvolta nella replica).
Come possiamo ridurre al minimo la quantità di dati trasferiti durante la replica di alto livello? Una soluzione diretta può essere la selezione dei dati in base alla data e all'ora. A tal fine, si può utilizzare un campo data-ora già presente nella tabella (se esiste). Ad esempio, un documento 'ordine' potrebbe avere un campo 'tempo di consegna richiesto' — delivery_time. Il problema di questa soluzione è che i valori in questo campo non devono necessariamente seguire una sequenza corrispondente alla creazione degli ordini. Pertanto, non possiamo memorizzare il valore massimo del campo delivery_time, trasmesso durante la sessione di scambio precedente, e nella successiva sessione di scambio selezionare tutte le registrazioni con un valore del campo superiore. delivery_time. Tra le sessioni di scambio, potrebbero essere aggiunti record con un valore di campo inferiore. delivery_time. Inoltre, l'ordine potrebbe aver subito modifiche che tuttavia non hanno interessato il campo. delivery_time. In entrambi i casi, le modifiche non saranno trasferite dalla sorgente al destinatario. Per risolvere questi problemi, sarà necessario trasferire dati in sovrapposizione. Cioè, ad ogni sessione di scambio trasferiremo tutti i dati con un valore di campo delivery_time, superiore a un certo momento nel passato (ad esempio, N ore dal momento attuale). Tuttavia, è evidente che questo approccio è fortemente ridondante per grandi sistemi e può annullare il risparmio di traffico che cerchiamo di ottenere. Inoltre, nella tabella trasferita potrebbe non essere presente un campo associato alla data e all'ora.
Un'altra soluzione, più complessa dal punto di vista dell'implementazione, consiste nella conferma della ricezione dei dati. In questo caso, ad ogni sessione di scambio vengono trasferiti tutti i dati la cui ricezione non è stata confermata dal destinatario. Per implementare questa soluzione, sarà necessario aggiungere nella tabella sorgente una colonna booleana (ad esempio, is_transferred). Se il destinatario conferma la ricezione del record, il campo corrispondente assume il valore. true, dopodiché la registrazione non partecipa più alle comunicazioni. Questa soluzione presenta i seguenti svantaggi. In primo luogo, è necessario generare e inviare una conferma per ogni registrazione trasferita. In termini semplici, ciò può essere paragonabile a raddoppiare la quantità di dati trasferiti e portare a un raddoppio del numero di round trip. In secondo luogo, non è possibile inviare la stessa registrazione a più destinatari (il primo destinatario che riceve confermerà il ricevimento per se stesso e per tutti gli altri).
Un metodo privo dei difetti sopra citati consiste nell'aggiungere alla tabella trasferita una colonna per il tracciamento delle modifiche delle sue righe. Questa colonna potrebbe avere il tipo data-ora e dovrebbe essere impostata/aggiornata dall'applicazione all'ora corrente ogni volta che vengono aggiunte/modificate registrazioni (in modo atomico con l'aggiunta/modifica). Come esempio, chiamiamo questa colonna update_time. Salvando il valore massimo di questo campo per le registrazioni trasferite, possiamo iniziare la prossima sessione di trasferimento da questo valore (selezionando le registrazioni con un valore del campo update_time, superando il valore memorizzato in precedenza). Il problema con l'approccio recente è che le modifiche ai dati possono avvenire in modalità batch. Di conseguenza, i valori dei campi nella colonna update_time potrebbero non essere unici. Pertanto, questa colonna non può essere utilizzata per la restituzione parziale dei dati (pagina per pagina). Per la restituzione dei dati per pagina sarà necessario inventare meccanismi aggiuntivi che, probabilmente, avranno un'efficienza molto bassa (ad esempio, estraendo dal DB tutte le registrazioni con un valore update_time superiore a quello specificato e restituendo un certo numero di registrazioni, a partire da un certo offset dall'inizio del campione).
È possibile aumentare l'efficienza della trasmissione dei dati perfezionando leggermente l'approccio precedente. Per fare ciò, utilizziamo un tipo intero come valori dei campi della colonna per il monitoraggio delle modifiche (intero lungo). Chiamiamo la colonna row_ver. Il valore del campo di questa colonna deve continuare a essere impostato/aggiornato ogni volta che viene creata/modificata una registrazione. Ma in questo caso, al campo verrà assegnato non l'attuale data-ora, ma il valore di un certo contatore, incrementato di uno. Di conseguenza, la colonna row_ver conterrà valori unici e potrà essere utilizzata non solo per fornire il «delta» dei dati (dati aggiunti/modificati dopo la conclusione della precedente sessione di scambio), ma anche per una suddivisione semplice ed efficace in pagine.
L'ultimo metodo proposto per minimizzare la quantità di dati trasmessi nell'ambito della replicazione di alto livello mi sembra il più ottimale e universale. Approfondiamo questo aspetto.
Trasferimento dei dati utilizzando il contatore delle versioni delle righe
Implementazione della parte server/master
In MS SQL Server, per implementare un approccio simile, esiste un tipo speciale di colonna — rowversion. Ogni DB ha un contatore che aumenta di uno ogni volta che viene aggiunta/modificata una registrazione nella tabella che ha una colonna di tipo rowversion. Il valore di questo contatore viene assegnato automaticamente al campo di questa colonna nella registrazione aggiunta/aggiornata. Il database Tarantool non ha un meccanismo integrato simile. Tuttavia, in Tarantool è facile implementarlo manualmente. Vediamo come si fa.
Iniziamo con un po' di terminologia: le tabelle in Tarantool sono chiamate spazi (space) e le registrazioni sono tuple (tuple). In Tarantool è possibile creare sequenze (sequence). Le sequenze non sono altro che generatori nominati di valori ordinali interi. Cioè, è esattamente ciò che serve per le nostre esigenze. Di seguito creeremo tale sequenza.
Prima di eseguire qualsiasi operazione sul database in Tarantool, è necessario eseguire il seguente comando:
box.cfg{}Come risultato, Tarantool inizierà a registrare nella directory corrente i snapshot del DB e il log delle transazioni.
Creiamo la sequenza row_version:
box.schema.sequence.create('row_version',
{ if_not_exists = true }) Opzione if_not_exists permette di eseguire lo script di creazione più volte: se l'oggetto esiste, Tarantool non tenterà di ricrearlo. Questa opzione sarà utilizzata in tutti i comandi DDL successivi.
Creeremo uno spazio per esempio.
box.schema.space.create('goods', {
format = {
{
name = 'id',
type = 'unsigned'
},
{
name = 'name',
type = 'string'
},
{
name = 'code',
type = 'unsigned'
},
{
name = 'row_ver',
type = 'unsigned'
}
},
if_not_exists = true
}) Qui abbiamo impostato il nome dello spazio (goods), i nomi dei campi e i loro tipi.
I campi auto-incrementali in Tarantool si creano anche tramite sequenze. Creiamo una chiave primaria auto-incrementale per il campo id:
box.schema.sequence.create('goods_id',
{ if_not_exists = true })
box.space.goods:create_index('primary', {
parts = { 'id' },
sequence = 'goods_id',
unique = true,
type = 'HASH',
if_not_exists = true
})Tarantool supporta diversi tipi di indici. I tipi più comunemente utilizzati sono TREE e HASH, basati su strutture corrispondenti ai loro nomi. TREE è il tipo di indice più versatile. Permette di estrarre dati in modo ordinato. Ma per le selezioni di uguaglianza, HASH è più adatto. Di conseguenza, per la chiave primaria è ragionevole utilizzare HASH (come abbiamo fatto).
Per utilizzare la colonna row_ver per la trasmissione dei dati modificati, è necessario associare a queste colonne i valori della sequenza. row_ver. A differenza della chiave primaria, il valore del campo della colonna row_ver deve aumentare di uno non solo con l'aggiunta di nuovi record, ma anche con la modifica di quelli esistenti. A tal fine, è possibile utilizzare i trigger. In Tarantool ci sono due tipi di trigger per gli spazi: before_replace e on_replace. I trigger vengono attivati ad ogni modifica dei dati nello spazio (per ogni tupla interessata dalle modifiche, viene eseguita la funzione del trigger). A differenza di on_replace, before_replace-i trigger consentono di modificare i dati della tupla per cui il trigger viene eseguito. Di conseguenza, l'ultimo tipo di trigger è quello adatto a noi.
box.space.goods:before_replace(function(old, new)
return box.tuple.new({new[1], new[2], new[3],
box.sequence.row_version:next()})
end) Il trigger fornito sostituisce il valore del campo row_ver della tupla memorizzata con il successivo valore della sequenza row_version.
Per poter estrarre dati dallo spazio goods in base alla colonna row_ver, creiamo un indice:
box.space.goods:create_index('row_ver', {
parts = { 'row_ver' },
unique = true,
type = 'TREE',
if_not_exists = true
}) Il tipo di indice è albero (TREE), poiché avremo bisogno di estrarre i dati in ordine crescente dei valori nella colonna row_ver.
Aggiungiamo alcuni dati nello spazio:
box.space.goods:insert{nil, 'penna', 123}
box.space.goods:insert{nil, 'matita', 321}
box.space.goods:insert{nil, 'pennello', 100}
box.space.goods:insert{nil, 'acquerello', 456}
box.space.goods:insert{nil, 'album', 101}
box.space.goods:insert{nil, 'quaderno', 800}
box.space.goods:insert{nil, 'gomma', 531}
box.space.goods:insert{nil, 'righello', 135} Poiché il primo campo è un contatore auto-incrementale, passiamo al suo posto nil. Tarantool inserirà automaticamente il valore successivo. Allo stesso modo, come valore dei campi della colonna row_ver puoi passare nil — oppure non specificare affatto un valore, poiché questa colonna occupa l'ultima posizione nello spazio.
Controlliamo il risultato dell'inserimento:
tarantool> box.space.goods:select()
---
- - [1, 'penna', 123, 1]
- [2, 'matita', 321, 2]
- [3, 'pennello', 100, 3]
- [4, 'acquerello', 456, 4]
- [5, 'album', 101, 5]
- [6, 'quaderno', 800, 6]
- [7, 'gomma', 531, 7]
- [8, 'righello', 135, 8]
... Come vediamo, il primo e l'ultimo campo si sono riempiti automaticamente. Ora non sarà difficile scrivere una funzione per scaricare le modifiche dello spazio pagina per pagina. goods:
local page_size = 5
local function get_goods(row_ver)
local index = box.space.goods.index.row_ver
local goods = {}
local counter = 0
for _, tuple in index:pairs(row_ver, {
iterator = 'GT' }) do
local obj = tuple:tomap({ names_only = true })
table.insert(goods, obj)
counter = counter + 1
if counter >= page_size then
break
end
end
return goods
end La funzione accetta come parametro un valore row_ver, a partire dal quale è necessario esportare le modifiche e restituisce una porzione dei dati modificati.
La selezione dei dati in Tarantool avviene tramite indici. La funzione get_goods utilizza un iteratore sull'indice row_ver per ottenere i dati modificati. Il tipo di iteratore è GT (Greater Than, maggiore di). Ciò significa che l'iteratore eseguirà una scansione sequenziale dei valori dell'indice a partire dalla chiave fornita (valore del campo row_ver).
L'iteratore restituisce tuple. Per consentire successivamente la trasmissione dei dati tramite HTTP, è necessario convertire le tuple in una struttura adeguata per la successiva serializzazione. Nell'esempio, per questo si utilizza la funzione standard tomap. Invece di utilizzare tomap si può scrivere una funzione personalizzata. Ad esempio, potremmo voler rinominare il campo name, non trasmettere il campo code e aggiungere il campo commento:
local function unflatten_goods(tuple)
local obj = {}
obj.id = tuple.id
obj.goods_name = tuple.name
obj.comment = 'some comment'
obj.row_ver = tuple.row_ver
return obj
end La dimensione della pagina dei dati restituiti (numero di record in una porzione) è definita dalla variabile page_size. Nell'esempio, il valore page_size equivale a 5. In un programma reale, la dimensione della pagina ha solitamente un'importanza maggiore. Essa dipende dalla dimensione media della tupla dello spazio. La dimensione ottimale della pagina può essere determinata empiricamente, misurando il tempo di trasferimento dei dati. Maggiore è la dimensione della pagina, minori saranno il numero di round trip tra la parte inviante e quella ricevente. Questo può ridurre il tempo totale di caricamento delle modifiche. Tuttavia, se la dimensione della pagina è troppo grande, il server impiegherà troppo tempo a serializzare il campione. Ne consegue che possono verificarsi ritardi nel processamento di altre richieste inviate al server. Il parametro page_size può essere caricato da un file di configurazione. Per ciascun spazio trasmesso è possibile impostare un valore specifico. Tuttavia, per la maggior parte degli spazi, può andare bene un valore predefinito (ad esempio, 100).
Eseguiamo la funzione get_goods:
tarantool> get_goods(0)
---
- - row_ver: 1
code: 123
name: pen
id: 1
- row_ver: 2
code: 321
name: pencil
id: 2
- row_ver: 3
code: 100
name: brush
id: 3
- row_ver: 4
code: 456
name: watercolour
id: 4
- row_ver: 5
code: 101
name: album
id: 5
... Prendiamo il valore del campo row_ver dall'ultima riga e richiamiamo di nuovo la funzione:
tarantool> get_goods(5)
---
- - row_ver: 6
code: 800
name: notebook
id: 6
- row_ver: 7
code: 531
name: rubber
id: 7
- row_ver: 8
code: 135
name: ruler
id: 8
...E ancora una volta:
tarantool> get_goods(8)
---
- []
... Come possiamo vedere, in questo caso la funzione restituisce paginatamente tutte le voci dello spazio. goods. Dopo l'ultima pagina c'è un campione vuoto.
Apportiamo modifiche allo spazio:
box.space.goods:update(4, {{'=', 6, 'copybook'}})
box.space.goods:insert{nil, 'clip', 234}
box.space.goods:insert{nil, 'folder', 432} Abbiamo modificato il valore del campo name per una voce e aggiunto due nuove voci.
Ripetiamo l'ultimo invocazione della funzione:
tarantool> get_goods(8)
---
- - row_ver: 9
code: 800
name: copybook
id: 6
- row_ver: 10
code: 234
name: clip
id: 9
- row_ver: 11
code: 432
name: folder
id: 10
... La funzione ha restituito le voci modificate e quelle aggiunte. Pertanto, la funzione get_goods permette di ottenere i dati modificati dal suo ultimo invocazione, che è la base del metodo di replicazione in esame.
Lasciamo l'emissione dei risultati tramite HTTP in formato JSON al di fuori di questo articolo. È possibile leggere a riguardo qui:
Implementazione della parte client/slave
Consideriamo come appare l'implementazione del lato ricevente. Creiamo sul lato ricevente uno spazio per memorizzare i dati caricati:
box.schema.space.create('goods', {
format = {
{
name = 'id',
type = 'unsigned'
},
{
name = 'name',
type = 'string'
},
{
name = 'code',
type = 'unsigned'
}
},
if_not_exists = true
})
box.space.goods:create_index('primary', {
parts = { 'id' },
sequence = 'goods_id',
unique = true,
type = 'HASH',
if_not_exists = true
}) La struttura dello spazio è simile a quella dello spazio nel sorgente. Tuttavia, poiché non intendiamo trasmettere i dati ottenuti altrove, la colonna row_ver manca nello spazio di ricezione. Nel campo id verranno registrati gli identificatori della sorgente. Pertanto, sul lato del ricevente non è necessaria l'auto-incrementazione.
Inoltre, avremo bisogno di uno spazio per memorizzare i valori row_ver:
box.schema.space.create('row_ver', {
format = {
{
name = 'space_name',
type = 'string'
},
{
name = 'value',
type = 'string'
}
},
if_not_exists = true
})
box.space.row_ver:create_index('primary', {
parts = { 'space_name' },
unique = true,
type = 'HASH',
if_not_exists = true
}) Per ogni spazio caricato (campo space_name) salveremo qui l'ultimo valore caricato row_ver (campo value). La colonna space_name.
funzionerà come chiave primaria. Creiamo una funzione per caricare i dati dello spazio. goods via HTTP. Per questo avremo bisogno di una libreria che implementi un client HTTP. La seguente riga carica la libreria e crea un'istanza del client HTTP:
local http_client = require('http.client').new()Avremo anche bisogno di una libreria per la deserializzazione del json:
local json = require('json')Questo è sufficiente per creare una funzione che carica i dati:
local function load_data(url, row_ver)
local url = ('%s?rowVer=%s'):format(url,
tostring(row_ver))
local body = nil
local data = http_client:request('GET', url, body, {
keepalive_idle = 1,
keepalive_interval = 1
})
return json.decode(data.body)
end La funzione esegue una richiesta HTTP all'indirizzo url, passando row_ver come parametro e restituisce il risultato della richiesta deserializzato.
La funzione per salvare i dati ricevuti appare come segue:
local function save_goods(goods)
local n = #goods
box.atomic(function()
for i = 1, n do
local obj = goods[i]
box.space.goods:put(
obj.id, obj.name, obj.code)
end
end)
end Il ciclo di salvataggio dei dati nello spazio goods è inserito in una transazione (per questo si usa la funzione box.atomic) per ridurre il numero di operazioni sul disco.
Infine, la funzione di sincronizzazione dello spazio locale goods con la fonte può essere implementata così:
local function sync_goods()
local tuple = box.space.row_ver:get('goods')
local row_ver = tuple and tuple.value or 0
—— set your url here:
local url = 'http://127.0.0.1:81/test/goods/list'
while true do
local goods = load_goods(url, row_ver)
local count = #goods
if count == 0 then
return
end
save_goods(goods)
row_ver = goods[count].rowVer
box.space.row_ver:put({'goods', row_ver})
end
end Per prima cosa leggiamo il valore precedentemente salvato row_ver per lo spazio goods. Se non è presente (primo scambio), prendiamo come row_ver zero. Successivamente, nel ciclo, carichiamo pagina per pagina i dati modificati dalla fonte attraverso l'URL specificato. Ad ogni iterazione, salviamo i dati ricevuti nello spazio corrispondente e aggiorniamo il valore row_ver (nello spazio row_ver e nella variabile row_ver) — prendiamo il valore row_ver dall'ultima riga di dati caricati.
Per proteggere da un ciclo accidentale (in caso di errore nel programma), il ciclo while può essere sostituito con per:
for _ = 1, max_req do ... A seguito dell'esecuzione della funzione sync_goods lo spazio goods nel ricevitore conterrà le ultime versioni di tutti i record nello spazio goods nella fonte.
Evidentemente, in questo modo non è possibile trasmettere la cancellazione dei dati. Se tale necessità sussiste, è possibile utilizzare un contrassegno per la cancellazione. Aggiungiamo allo spazio goods il campo booleano is_deleted e invece di eliminare fisicamente il record, utilizziamo l'eliminazione logica — impostiamo il valore del campo is_deleted su true. A volte, invece del campo booleano is_deleted è più comodo utilizzare il campo deleted, in cui viene memorizzata la data e l'ora della cancellazione logica della registrazione. Dopo aver eseguito la cancellazione logica, la registrazione contrassegnata per la cancellazione verrà trasferita dalla fonte al destinatario (secondo la logica sopra esaminata).
Sequenza row_ver può essere utilizzata per trasmettere dati di altri spazi: non è necessario creare una sequenza separata per ogni spazio trasmesso.
Abbiamo esaminato un modo efficace di replica dati ad alto livello nelle applicazioni che utilizzano il DBMS Tarantool.
Conclusioni
- Il DBMS Tarantool è un prodotto attraente e promettente per la creazione di applicazioni ad alto carico.
- La replica dati ad alto livello presenta diversi vantaggi rispetto alla replica a basso livello.
- Il metodo di replica dati ad alto livello esaminato nell'articolo consente di minimizzare la quantità di dati trasmessi, inviando solo le registrazioni che sono state modificate dall'ultima sessione di scambio.
Fonte: habr.com
