Ciao, mi occupo della creazione di applicazioni per database. — è una piattaforma sviluppata da Mail.ru Group che combina un database ad alte prestazioni e un server di applicazioni in linguaggio Lua. L'elevata velocità delle soluzioni basate su Tarantool è raggiunta in particolare grazie al supporto della modalità in-memory del database e alla possibilità di eseguire la logica di business dell'applicazione nello stesso spazio indirizzabile dei dati. La persistenza dei dati è garantita utilizzando transazioni ACID (un WAL è mantenuto su disco). Tarantool supporta la replica e lo sharding. A partire dalla versione 2.1, sono supportate le query in linguaggio SQL. Tarantool ha codice sorgente aperto e viene distribuito con licenza BSD semplificata. È disponibile anche una versione Enterprise commerciale.

Senti il potere! (…ovvero goditi le prestazioni)
Tutto ciò rende Tarantool una piattaforma attraente per la creazione di applicazioni ad alta intensità di carico che lavorano con i database. In tali applicazioni, spesso si rende necessaria la replicazione dei dati.
Come detto in precedenza, Tarantool ha la replica dei dati integrata. Il principio del suo funzionamento consiste nell'esecuzione sequenziale di tutte le transazioni contenute nel registro del master (WAL) su replica. Di solito, questa replica (che in seguito chiameremo a basso livello) è utilizzata per garantire la tolleranza ai guasti 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 creati in un database a un altro database per elaborazione/monitoraggio. In quest'ultimo caso, una soluzione più comoda potrebbe essere l'utilizzo della replica ad alto livello cioè la replica dei dati a livello della logica di business dell'applicazione. Cioè, non utilizziamo la soluzione preconfezionata integrata nel database, ma implementiamo noi stessi la replica all'interno dell'applicazione che stiamo sviluppando. Questo approccio ha sia vantaggi che svantaggi. Elenchiamo i punti positivi.
1. Risparmio di traffico:
- si possono trasferire non tutti i dati, ma solo una parte di essi (ad esempio, si possono trasferire solo alcune tabelle, alcune delle loro colonne o record che corrispondono a determinati criteri);
- A differenza della replicazione a basso livello, che viene eseguita continuamente in modalità asincrona (implementata nell'attuale versione di Tarantool - 1.10) o sincrona (che sarà implementata nelle versioni successive di Tarantool), la replicazione ad alto livello può essere eseguita in sessioni (cioè l'applicazione prima esegue la sincronizzazione dei dati - sessione di scambio dati, poi si verifica una pausa nella replicazione, dopo la quale avviene la successiva sessione di scambio, e così via);
- Se la registrazione è stata modificata più volte, è possibile trasmettere solo la sua versione più recente (a differenza della replicazione a basso livello, in cui le repliche riprodurranno sequenzialmente tutte le modifiche apportate sul master).
2. Non ci sono complessità nell'implementazione dello scambio tramite HTTP, il che permette di sincronizzare i database remoti.

Fig. 2. Replicazione via HTTP
3. Le strutture dei database tra cui vengono trasferiti i dati non devono essere identiche (anzi, in generale è possibile utilizzare anche diversi DBMS, linguaggi di programmazione, piattaforme, ecc.).

Fig. 3. Replicazione in sistemi eterogenei
Il lato negativo consiste nel fatto che in media la programmazione è più complessa/costosa rispetto alla configurazione, e al posto di configurare una funzionalità integrata si dovrà realizzare la propria.
Se nella vostra situazione i vantaggi indicati hanno un'importanza decisiva (o sono una condizione necessaria), ha senso utilizzare la replicazione ad alto livello. Esaminiamo alcuni modi di implementare la replicazione ad alto livello dei dati nei database Tarantool.
Minimizzazione del traffico
Quindi, uno dei vantaggi della replicazione ad alto livello è il risparmio di traffico. Affinché questo vantaggio si manifesti appieno, è necessario minimizzare la quantità di dati trasmessi ad ogni sessione di scambio. È evidente che, in tal modo, non si deve dimenticare che alla fine della sessione il ricevente dei dati deve essere sincronizzato con la fonte (almeno per la parte di dati coinvolta nella replicazione).
Come si può minimizzare la quantità di dati trasmessi durante la replicazione ad alto livello? Una soluzione 'diretta' potrebbe essere la selezione dei dati in base alla data e all'ora. A tal fine, si può utilizzare il campo data-ora già presente nella tabella (se esiste). Ad esempio, nel documento 'ordine' potrebbe esserci un campo 'data richiesta di esecuzione dell'ordine' - tempo di consegna. Il problema di questa soluzione è che i valori in questo campo non devono necessariamente essere collocati in un ordine corrispondente alla creazione degli ordini. Pertanto, non possiamo ricordare il valore massimo del campo tempo di consegna, trasmesso durante la sessione di scambio precedente, e nella prossima sessione di scambio selezionare tutte le registrazioni con un valore del campo superiore tempo di consegna. Nel frattempo, durante le sessioni di scambio, potrebbero essere state aggiunte registrazioni con valori inferiori del campo tempo di consegna. Inoltre, l'ordine avrà potuto subire modifiche, che tuttavia non hanno interessato il campo tempo di consegna. In entrambi i casi, le modifiche non verranno trasmesse dal mittente al destinatario. Per risolvere questi problemi, sarà necessario trasmettere i dati "in sovrapposizione". Cioè, ad ogni sessione di scambio trasmetteremo tutti i dati con un valore del campo tempo di consegna, superiore a un certo momento nel passato (ad esempio, N ore dal momento attuale). Tuttavia, è ovvio che per sistemi di grandi dimensioni questo approccio è fortemente ridondante e potrebbe annullare il risparmio di traffico che stiamo cercando. Inoltre, nella tabella trasmessa potrebbe non esserci un campo collegato 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 trasmessi tutti i dati la cui ricezione non è stata confermata dal destinatario. Per l'implementazione sarà necessario aggiungere nella tabella sorgente una colonna booleana (ad esempio, is_transferred). Se il destinatario conferma la ricezione della registrazione, il campo corrispondente assume il valore true, dopodiché la registrazione non partecipa più agli scambi. Questa opzione di implementazione presenta i seguenti svantaggi. Innanzitutto, per ogni registrazione trasmessa è necessario generare e inviare una conferma. In parole povere, questo potrebbe essere paragonabile a raddoppiare la quantità di dati trasmessi e portare a un raddoppio del numero di round trip. In secondo luogo, non c'è possibilità di inviare la stessa registrazione a più destinatari (il primo destinatario che riceve confermerà la ricezione per sé e per tutti gli altri).
Il metodo privo dei difetti sopra citati consiste nell'aggiungere alla tabella da trasferire una colonna per monitorare le modifiche alle sue righe. Questa colonna può avere un tipo data-ora e deve essere impostata/aggiornata dall'applicazione all'orario attuale ogni volta che vengono aggiunti/modificati record (in modo atomico con l'aggiunta/modifica). Come esempio, chiamiamo la colonna update_time. Salvando il valore massimo del campo di questa colonna per i record trasferiti, possiamo iniziare la prossima sessione di scambio da quel valore (selezionare record con il valore del campo update_time, superiore al valore precedentemente salvato). Il problema associato a quest'ultimo approccio è 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 (pagina per pagina) dei dati. Per effettuare una restituzione paginata dei dati saranno necessari meccanismi aggiuntivi che, molto probabilmente, avranno un'efficienza molto bassa (ad esempio, recuperare dal DB tutti i record con il valore update_time superiore a quello specificato e restituire un certo numero di record, a partire da un certo offset all'inizio della selezione).
Possiamo migliorare l'efficienza della trasmissione dei dati apportando alcune modifiche al metodo precedente. A tal fine, utilizzeremo un tipo intero (long integer) come valori dei campi della colonna per monitorare le modifiche. Chiamiamo la colonna row_ver. Il valore del campo di questa colonna deve comunque essere impostato/aggiornato ogni volta che viene creato/modificato un record. Ma in questo caso, al campo verrà assegnato non il dato-ora attuale, 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 restituire la «delta» dei dati (dati aggiunti/cambiati dopo il completamento della precedente sessione di scambio), ma anche per una semplice ed efficace suddivisione in pagine.
L'ultimo metodo proposto per minimizzare la quantità di dati trasferiti nell'ambito della replica ad alto livello mi sembra il più ottimale e universale. Approfondiamo questo argomento.
Trasferimento dei dati utilizzando un contatore di versioni delle righe
Implementazione della parte server/master
In MS SQL Server, per implementare un approccio simile, esiste un tipo di colonna speciale — rowversion. Ogni database 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 automaticamente assegnato al campo di questa colonna nella registrazione aggiunta/modificata. Il DBMS Tarantool non ha un meccanismo incorporato analogo. Tuttavia, in Tarantool è facile da implementare manualmente. Vediamo come fare.
Per iniziare, un po' di terminologia: le tabelle in Tarantool si chiamano spazi (space), mentre le registrazioni si chiamano tuple. In Tarantool è possibile creare sequenze (sequence). Le sequenze sono semplicemente generatori nominati di valori interi ordinati. Cioè, è proprio ciò di cui abbiamo bisogno per i nostri scopi. Di seguito creeremo una di queste sequenze.
Prima di eseguire qualsiasi operazione con il database in Tarantool, è necessario eseguire il seguente comando:
box.cfg{}Di conseguenza, Tarantool inizierà a registrare nella directory corrente i snapshot del DB e il log delle transazioni.
Creiamo una sequenza row_version:
box.schema.sequence.create('row_version',
{ if_not_exists = true }) Opzione if_not_exists consente di eseguire lo script di creazione più volte: se l'oggetto esiste, Tarantool non tenterà di ricrearlo. Questa opzione verrà utilizzata in tutti i successivi comandi DDL.
Creiamo uno spazio per l'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 specificato il nome dello spazio (goods), i nomi dei campi e i loro tipi.
I campi a incremento automatico in Tarantool vengono creati anche con le sequenze. Creiamo una chiave primaria a incremento automatico 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. Gli indici più comunemente usati sono i tipi TREE e HASH, basati sulle rispettive strutture. TREE è il tipo di indice più versatile. Permette di estrarre i dati in modo ordinato. Tuttavia, per le selezioni basate sull'uguaglianza, è più adatto HASH. Pertanto, è sensato utilizzare HASH per la chiave primaria (come abbiamo fatto).
Per utilizzare la colonna row_ver per la trasmissione dei dati modificati, è necessario collegare ai campi di questa colonna i valori della sequenza row_ver. Ma a differenza della chiave primaria, il valore del campo della colonna row_ver deve aumentare di uno non solo quando si aggiungono nuovi record, ma anche quando si modificano quelli esistenti. Per questo è possibile utilizzare dei trigger. In Tarantool ci sono due tipi di trigger per gli spazi: before_replace e on_replace. I trigger si attivano ad ogni modifica dei dati nello spazio (per ogni tupla colpita dalle modifiche viene eseguita la funzione del trigger). A differenza dei on_replace, before_replace-i trigger consentono di modificare i dati della tupla per cui viene eseguito il trigger. Pertanto, ci interessa l'ultimo tipo di trigger.
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 salvata con il valore successivo 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é i dati devono essere estratti in ordine crescente dei valori nella colonna row_ver.
Aggiungiamo alcuni dati nello spazio:
box.space.goods:insert{nil, 'pen', 123} box.space.goods:insert{nil, 'pencil', 321} box.space.goods:insert{nil, 'brush', 100} box.space.goods:insert{nil, 'watercolour', 456} box.space.goods:insert{nil, 'album', 101} box.space.goods:insert{nil, 'notebook', 800} box.space.goods:insert{nil, 'rubber', 531} box.space.goods:insert{nil, 'ruler', 135} Poiché il primo campo è un contatore auto-incrementale, passiamo nil al suo posto. Tarantool inserirà automaticamente il valore successivo. In modo simile, come valore dei campi della colonna row_ver è possibile passare nil — o 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 possiamo vedere, il primo e l'ultimo campo si sono riempiti automaticamente. Ora sarà facile scrivere una funzione per il caricamento a pagine delle modifiche dello spazio. 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 eseguire il caricamento delle modifiche, e restituisce una porzione di 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à un'iterazione sequenziale dei valori dell'indice a partire dalla chiave fornita (valore del campo row_ver).
L'iteratore restituisce tuple. Per avere poi la possibilità di inviare i dati tramite HTTP, è necessario eseguire una conversione delle tuple in una struttura adatta per la successiva serializzazione. Nell'esempio, per questo viene utilizzata la funzione standard tomap. Invece di utilizzare tomap si può scrivere una funzione personalizzata. Ad esempio, potremmo voler rinominare il campo name, non passare 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 = 'alcuni commenti'
obj.row_ver = tuple.row_ver
return obj
end La dimensione della pagina dei dati restituiti (il numero di registrazioni in una porzione) è determinata dalla variabile page_size. Nell'esempio il valore page_size uguale a 5. Nella programmazione reale, la dimensione della pagina ha solitamente maggiore importanza. 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. Più grande è la dimensione della pagina, minore è il numero di round trip tra la parte che trasmette e quella che riceve. In questo modo, si 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. Di conseguenza, potrebbero sorgere ritardi nell'elaborazione di altre richieste inviate al server. Il parametro page_size può essere caricato dal file di configurazione. Per ogni spazio trasmesso, è possibile specificare il proprio valore. Per la maggior parte degli spazi, il valore predefinito (ad esempio, 100) può andare bene.
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 chiamiamo nuovamente 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 vediamo, con questo utilizzo la funzione restituisce tutte le registrazioni dello spazio a pagina. goodsDopo l'ultima pagina segue 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 registrazione e aggiunto due nuove registrazioni.
Ripetiamo l'ultimo chiamata 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 registrazioni modificate e quelle aggiunte. In questo modo, la funzione get_goods consente di ottenere dati che sono cambiati dall'ultima chiamata, che è alla base del modello di replicazione considerato.
Lasciamo l'emissione dei risultati tramite HTTP in formato JSON al di fuori di questo articolo. Puoi leggere qui:
Implementazione della parte client/slave
Vediamo come appare l'implementazione del lato ricevente. Creiamo nello spazio 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 ricorda la struttura dello spazio nella fonte. Ma poiché non abbiamo intenzione di trasferire i dati ottenuti altrove, la colonna row_ver è assente nello spazio del ricevente. Nel campo id saranno registrati gli identificatori della fonte. Pertanto, sul lato del ricevente non è necessario renderlo auto-incrementale.
Oltre a questo, 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 ciascun spazio caricato (campo space_name) salveremo qui l'ultimo valore caricato row_ver (campo value). Come chiave primaria, funge la colonna space_name.
Creiamo una funzione per caricare i dati dello spazio goods via HTTP. Per questo avremo bisogno di una libreria che implementa il 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 di caricamento dei 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 il row_ver come parametro e restituendo il risultato della richiesta deserializzato.
La funzione di salvataggio dei dati ottenuti è la seguente:
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 utilizza la funzione box.atomic) per ridurre il numero di operazioni su disco.
Infine, la funzione di sincronizzazione dello spazio locale goods con la fonte può essere implementata in questo modo:
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 Prima leggiamo il valore salvato in precedenza row_ver per lo spazio goods. Se è assente (primo scambio di sessione), allora prendiamo come row_ver zero. Successivamente, nel ciclo, eseguiamo il caricamento paginato dei dati modificati dalla fonte all'url specificato. Ad ogni iterazione salviamo i dati ricevuti nello spazio locale corrispondente e aggiorniamo il valore row_ver (nello spazio row_ver e nella variabile row_ver) — prendiamo il valore row_ver dell'ultima riga dei dati caricati.
Per proteggersi da un ciclo indesiderato (in caso di errore nel programma), si può sostituire il ciclo while con per:
for _ = 1, max_req do ... Al termine dell'esecuzione della funzione sync_goods lo spazio goods nel ricevitore conterrà le versioni più recenti di tutti i record dello spazio goods nella fonte.
È evidente che in questo modo non si può trasmettere l'eliminazione dei dati. Se esiste tale necessità, si può utilizzare un'etichetta per l'eliminazione. Aggiungiamo nello spazio goods un campo booleano is_deleted e invece di eliminare fisicamente il record utilizziamo l'eliminazione logica — impostiamo il valore del campo is_deleted a valore true. A volte, invece del campo booleano is_deleted è più comodo usare un campo deletednel quale è memorizzata la data e ora dell'eliminazione logica del record. Dopo aver eseguito l'eliminazione logica, il record contrassegnato per l'eliminazione sarà trasferito dalla fonte al ricevitore (secondo la logica esaminata sopra).
La sequenza row_ver può essere utilizzata per trasferire dati di altri spazi: non è necessario creare una sequenza separata per ogni spazio da trasferire.
Abbiamo esaminato un modo efficace di replica dei dati ad alto livello in applicazioni che utilizzano il DBMS Tarantool.
Conclusioni
- Il DBMS Tarantool è un prodotto attraente e promettente per la creazione di applicazioni ad alta richiesta.
- La replica dei dati ad alto livello presenta numerosi vantaggi rispetto alla replica a basso livello.
- Il metodo di replica ad alto livello descritto nell'articolo consente di minimizzare la quantità di dati trasferiti trasmettendo solo i record che sono stati modificati dall'ultimo scambio.
Fonte: habr.com
