Ciao, sto creando applicazioni per DBMS
Sentire il potere! (…ovvero goditi lo spettacolo)
Tutto ciò rende Tarantool una piattaforma interessante per la creazione di applicazioni ad alto carico che funzionano con i database. In tali applicazioni è spesso necessaria la replica dei dati.
Come accennato in precedenza, Tarantool dispone di una replica dei dati integrata. Il principio del suo funzionamento è quello di eseguire sequenzialmente sulle repliche tutte le transazioni contenute nel master log (WAL). Di solito tale replica (lo chiameremo ulteriormente basso livello) viene utilizzato per garantire la tolleranza agli errori dell'applicazione e/o per distribuire il carico di lettura tra i nodi del cluster.
Riso. 1. Replica all'interno di un cluster
Un esempio di scenario alternativo potrebbe essere il trasferimento dei dati creati in un database a un altro database per l'elaborazione/monitoraggio. In quest'ultimo caso, una soluzione più conveniente potrebbe essere quella di utilizzare alto livello replica: replica dei dati a livello di logica aziendale dell'applicazione. Quelli. Non utilizziamo una soluzione già pronta integrata nel DBMS, ma implementiamo la replica da soli all'interno dell'applicazione che stiamo sviluppando. Questo approccio presenta sia vantaggi che svantaggi. Elenchiamo i vantaggi.
1. Risparmio di traffico:
- Non puoi trasferire tutti i dati, ma solo parte di essi (ad esempio puoi trasferire solo alcune tabelle, alcune loro colonne o record che soddisfano un determinato criterio);
- A differenza della replica di basso livello, che viene eseguita continuamente in modalità asincrona (implementata nell'attuale versione di Tarantool - 1.10) o sincrona (da implementare nelle versioni successive di Tarantool), la replica di alto livello può essere eseguita in sessioni (ovvero, il l'applicazione prima sincronizza i dati - una sessione di scambio dati, poi c'è una pausa nella replica, dopodiché avviene la sessione di scambio successiva, ecc.);
- se un record è stato modificato più volte, è possibile trasferire solo la sua ultima versione (a differenza della replica di basso livello, in cui tutte le modifiche apportate sul master verranno riprodotte in sequenza sulle repliche).
2. Non ci sono difficoltà con l'implementazione dello scambio HTTP, che consente di sincronizzare database remoti.
Riso. 2. Replica su HTTP
3. Le strutture di database tra le quali vengono trasferiti i dati non devono essere le stesse (del resto, nel caso generale, è anche possibile utilizzare DBMS, linguaggi di programmazione, piattaforme, ecc. diversi).
Riso. 3. Replicazione in sistemi eterogenei
Lo svantaggio è che, in media, la programmazione è più difficile/costosa della configurazione e, invece di personalizzare le funzionalità integrate, dovrai implementarne di tue.
Se nella tua situazione i vantaggi di cui sopra sono cruciali (o sono una condizione necessaria), allora ha senso utilizzare la replica di alto livello. Diamo un'occhiata a diversi modi per implementare la replica dei dati di alto livello nel DBMS Tarantool.
Minimizzazione del traffico
Pertanto, uno dei vantaggi della replica di alto livello è il risparmio di traffico. Affinché questo vantaggio possa essere pienamente realizzato è necessario ridurre al minimo la quantità di dati trasferiti durante ogni sessione di scambio. Naturalmente non dobbiamo dimenticare che al termine della sessione il destinatario dei dati deve essere sincronizzato con la sorgente (almeno per quella parte dei dati coinvolta nella replica).
Come ridurre al minimo la quantità di dati trasferiti durante la replica di alto livello? Una soluzione semplice potrebbe essere quella di selezionare i dati per data e ora. Per fare ciò è possibile utilizzare il campo data-ora già esistente nella tabella (se esiste). Ad esempio, un documento "ordine" può avere un campo "tempo di esecuzione dell'ordine richiesto" - delivery_time
. Il problema con questa soluzione è che i valori in questo campo non devono essere nella sequenza che corrisponde alla creazione degli ordini. Quindi non possiamo ricordare il valore massimo del campo delivery_time
, trasmesso durante la sessione di scambio precedente, e durante la sessione di scambio successiva seleziona tutti i record con un valore di campo più alto delivery_time
. È possibile che tra le sessioni di scambio siano stati aggiunti record con un valore di campo inferiore delivery_time
. Inoltre, l'ordine avrebbe potuto subire modifiche, che tuttavia non hanno influito sul campo delivery_time
. In entrambi i casi, le modifiche non verranno trasferite dall'origine alla destinazione. Per risolvere questi problemi, dovremo trasferire i dati "sovrapposti". Quelli. in ogni sessione di scambio trasferiremo tutti i dati con il valore del campo delivery_time
, superando un certo punto nel passato (ad esempio, N ore dal momento attuale). Tuttavia, è ovvio che per i sistemi di grandi dimensioni questo approccio è altamente ridondante e può ridurre a zero il risparmio di traffico a cui miriamo. Inoltre la tabella in fase di trasferimento potrebbe non avere un campo associato ad una data-ora.
Un'altra soluzione, più complessa dal punto di vista implementativo, è quella di confermare la ricezione dei dati. In questo caso, durante ogni sessione di scambio, vengono trasmessi tutti i dati la cui ricezione non è stata confermata dal destinatario. Per implementare ciò, dovrai aggiungere una colonna booleana alla tabella di origine (ad esempio, is_transferred
). Se il destinatario conferma la ricezione del record, il campo corrispondente assume il valore true
, dopodiché la voce non è più oggetto di scambi. Questa opzione di implementazione presenta i seguenti svantaggi. Innanzitutto, per ogni record trasferito, deve essere generata e inviata una conferma. In parole povere, ciò potrebbe essere paragonabile al raddoppio della quantità di dati trasferiti e al conseguente raddoppio del numero di viaggi di andata e ritorno. In secondo luogo non è prevista la possibilità di inviare lo stesso record a più destinatari (il primo destinatario che riceverà confermerà la ricezione per sé e per tutti gli altri).
Un metodo che non presenta gli svantaggi sopra indicati consiste nell'aggiungere una colonna alla tabella trasferita per tenere traccia delle modifiche nelle sue righe. Tale colonna può essere di tipo data-ora e deve essere impostata/aggiornata dall'applicazione all'ora corrente ogni volta che vengono aggiunti/modificati record (atomicamente con l'aggiunta/modifica). Ad esempio, chiamiamo la colonna update_time
. Salvando il valore massimo del campo di questa colonna per i record trasferiti, possiamo avviare la sessione di scambio successiva con questo valore (selezionare i record con il valore del campo update_time
, superando il valore precedentemente memorizzato). Il problema con quest'ultimo approccio è che le modifiche ai dati possono avvenire in batch. Come risultato dei valori del campo nella colonna update_time
potrebbe non essere unico. Pertanto, questa colonna non può essere utilizzata per l'output di dati suddivisi in parti (pagina per pagina). Per visualizzare i dati pagina per pagina, dovrai inventare meccanismi aggiuntivi che molto probabilmente avranno un'efficienza molto bassa (ad esempio, recuperando dal database tutti i record con il valore update_time
superiore ad uno dato e producendo un certo numero di record, a partire da un certo offset dall'inizio del campione).
È possibile migliorare l'efficienza del trasferimento dei dati migliorando leggermente l'approccio precedente. Per fare ciò, utilizzeremo il tipo intero (long integer) come valori del campo della colonna per tenere traccia delle modifiche. Diamo un nome alla colonna row_ver
. Il valore del campo di questa colonna deve comunque essere impostato/aggiornato ogni volta che un record viene creato/modificato. Ma in questo caso al campo non verrà assegnata la data-ora corrente, ma il valore di un contatore, aumentato di uno. Di conseguenza, la colonna row_ver
conterrà valori univoci e potrà essere utilizzato non solo per visualizzare i dati “delta” (dati aggiunti/modificati dalla fine della sessione di scambio precedente), ma anche per scomporli in pagine in modo semplice ed efficace.
L'ultimo metodo proposto per ridurre al minimo la quantità di dati trasferiti nell'ambito della replica di alto livello mi sembra il più ottimale e universale. Diamo un'occhiata più in dettaglio.
Passaggio di dati utilizzando un contatore di versioni di riga
Implementazione della parte server/master
In MS SQL Server esiste un tipo di colonna speciale per implementare questo approccio: rowversion
. Ogni database ha un contatore che aumenta di uno ogni volta che un record viene aggiunto/modificato in una tabella che ha una colonna simile rowversion
. Il valore di questo contatore viene assegnato automaticamente al campo di questa colonna nel record aggiunto/modificato. Il DBMS di Tarantool non ha un meccanismo integrato simile. Tuttavia, in Tarantool non è difficile implementarlo manualmente. Diamo un'occhiata a come è fatto.
Innanzitutto, un po' di terminologia: le tabelle in Tarantool sono chiamate spazi e i record sono chiamati tuple. In Tarantool puoi creare sequenze. Le sequenze non sono altro che generatori di valori interi ordinati. Quelli. questo è esattamente ciò di cui abbiamo bisogno per i nostri scopi. Di seguito creeremo una tale sequenza.
Prima di eseguire qualsiasi operazione sul database in Tarantool, è necessario eseguire il seguente comando:
box.cfg{}
Di conseguenza, Tarantool inizierà a scrivere istantanee del database e registri delle transazioni nella directory corrente.
Creiamo una sequenza row_version
:
box.schema.sequence.create('row_version',
{ if_not_exists = true })
Opzione if_not_exists
permette di eseguire più volte lo script di creazione: se l'oggetto esiste, Tarantool non tenterà di crearlo nuovamente. Questa opzione verrà utilizzata in tutti i successivi comandi DDL.
Creiamo uno spazio come 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 impostiamo il nome dello spazio (goods
), nomi di campo e relativi tipi.
Anche i campi con incremento automatico in Tarantool vengono creati utilizzando sequenze. Creiamo una chiave primaria con incremento automatico per 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 utilizzati sono i tipi TREE e HASH, che si basano su strutture corrispondenti al nome. TREE è il tipo di indice più versatile. Ti consente di recuperare i dati in modo organizzato. Ma per la selezione dell’uguaglianza, HASH è più adatto. Di conseguenza, è consigliabile utilizzare HASH come chiave primaria (che è ciò che abbiamo fatto).
Per utilizzare la colonna row_ver
per trasferire i dati modificati è necessario associare i valori della sequenza ai campi di questa colonna row_ver
. Ma a differenza della chiave primaria, il valore del campo della colonna row_ver
dovrebbe aumentare di uno non solo quando si aggiungono nuovi record, ma anche quando si modificano quelli esistenti. Puoi usare i trigger per questo. Il Tarantool ha due tipi di trigger spaziali: before_replace
и on_replace
. I trigger vengono attivati ogni volta che i dati nello spazio cambiano (per ogni tupla interessata dalle modifiche, viene avviata una funzione trigger). A differenza di on_replace
, before_replace
-triggers ti consente di modificare i dati della tupla per la quale viene eseguito il trigger. Di conseguenza, l'ultimo tipo di trigger è 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 seguente trigger sostituisce il valore del campo row_ver
tupla memorizzata al valore successivo della sequenza row_version
.
Per poter estrarre dati dallo spazio goods
per colonna row_ver
, creiamo un indice:
box.space.goods:create_index('row_ver', {
parts = { 'row_ver' },
unique = true,
type = 'TREE',
if_not_exists = true
})
Tipo di indice - albero (TREE
), Perché dovremo estrarre i dati in ordine crescente rispetto ai valori presenti nella colonna row_ver
.
Aggiungiamo alcuni dati allo 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}
Perché Il primo campo è un contatore ad incremento automatico; passiamo invece nil. Tarantool sostituirà automaticamente il valore successivo. Allo stesso modo, come il valore dei campi della colonna row_ver
puoi passare nil - o non specificare affatto il valore, perché questa colonna occupa l'ultima posizione nello spazio.
Controlliamo il risultato dell'inserimento:
tarantool> box.space.goods:select()
---
- - [1, 'pen', 123, 1]
- [2, 'pencil', 321, 2]
- [3, 'brush', 100, 3]
- [4, 'watercolour', 456, 4]
- [5, 'album', 101, 5]
- [6, 'notebook', 800, 6]
- [7, 'rubber', 531, 7]
- [8, 'ruler', 135, 8]
...
Come puoi vedere, il primo e l'ultimo campo vengono compilati automaticamente. Ora sarà facile scrivere una funzione per il caricamento pagina per pagina delle modifiche allo 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 prende come parametro il valore row_ver
, a partire dal quale è necessario scaricare le modifiche, e restituisce una parte dei dati modificati.
Il campionamento dei dati in Tarantool avviene tramite indici. Funzione get_goods
utilizza un iteratore per indice row_ver
per ricevere i dati modificati. Il tipo di iteratore è GT (Maggiore di, maggiore di). Ciò significa che l'iteratore percorrerà sequenzialmente i valori dell'indice a partire dalla chiave passata (campo value row_ver
).
L'iteratore restituisce tuple. Per poter successivamente trasferire i dati tramite HTTP è necessario convertire le tuple in una struttura comoda per la successiva serializzazione. L'esempio utilizza a questo scopo la funzione standard tomap
. Invece di usare tomap
puoi scrivere la tua funzione. Ad esempio, potremmo voler rinominare un campo name
, non passare il campo code
e aggiungi un campo comment
:
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 di output (il numero di record in una porzione) è determinata dalla variabile page_size
. Nell'esempio il valore page_size
è 5. In un programma reale, la dimensione della pagina solitamente conta di più. Dipende dalla dimensione media della tupla spaziale. La dimensione ottimale della pagina può essere determinata empiricamente misurando il tempo di trasferimento dei dati. Maggiore è la dimensione della pagina, minore è il numero di viaggi di andata e ritorno tra il lato di invio e quello di ricezione. In questo modo è possibile ridurre il tempo complessivo necessario per il download delle modifiche. Tuttavia, se la dimensione della pagina è troppo grande, passeremo troppo tempo sul server a serializzare l'esempio. Di conseguenza, potrebbero verificarsi ritardi nell'elaborazione di altre richieste in arrivo al server. Parametro page_size
può essere caricato dal file di configurazione. Per ogni spazio trasmesso è possibile impostare il proprio valore. Tuttavia, per la maggior parte degli spazi il valore predefinito (ad esempio 100) potrebbe essere adatto.
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 richiamare 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:
tarantool> get_goods(8)
---
- []
...
Come puoi vedere, se utilizzata in questo modo, la funzione restituisce tutti i record dello spazio pagina per pagina goods
. L'ultima pagina è seguita da una selezione vuota.
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 cambiato il valore del campo name
per una voce e aggiunte due nuove voci.
Ripetiamo l'ultima chiamata di 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 i record modificati e aggiunti. Quindi la funzione get_goods
consente di ricevere dati che sono cambiati dalla sua ultima chiamata, che è la base del metodo di replica in esame.
Lasceremo l'emissione di risultati tramite HTTP sotto forma di JSON fuori dall'ambito di questo articolo. Puoi leggere questo argomento qui:
Implementazione della parte client/slave
Diamo un'occhiata a come appare l'implementazione della parte ricevente. Creiamo uno spazio sul lato ricevente per archiviare i dati scaricati:
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 assomiglia alla struttura dello spazio nella fonte. Ma poiché non trasferiremo i dati ricevuti da nessun'altra parte, la colonna row_ver
non è nello spazio del destinatario. Nel campo id
gli identificatori della fonte verranno registrati. Pertanto, dal lato del ricevitore non è necessario renderlo autoincrementante.
Inoltre, abbiamo bisogno di uno spazio per salvare 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 funge da chiave primaria space_name
.
Creiamo una funzione per caricare i dati dello spazio goods
tramite HTTP. Per fare ciò, abbiamo bisogno di una libreria che implementi un client HTTP. La riga seguente carica la libreria e istanzia il client HTTP:
local http_client = require('http.client').new()
Abbiamo anche bisogno di una libreria per la deserializzazione json:
local json = require('json')
Questo è sufficiente per creare una funzione di caricamento 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 e la invia row_ver
come parametro e restituisce il risultato deserializzato della richiesta.
La funzione per salvare i dati ricevuti è simile alla 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
Ciclo di salvataggio dei dati nello spazio goods
inserito in una transazione (a questo scopo viene utilizzata la funzione box.atomic
) per ridurre il numero di operazioni sul disco.
Infine, la funzione di sincronizzazione dello spazio locale goods
con una fonte puoi implementarlo 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
Per prima cosa leggiamo il valore precedentemente salvato row_ver
per lo spazio goods
. Se manca (la prima sessione di scambio), lo prendiamo come row_ver
zero. Successivamente nel ciclo eseguiamo un download pagina per pagina dei dati modificati dalla fonte all'URL specificato. Ad ogni iterazione, salviamo i dati ricevuti nello spazio locale appropriato e aggiorniamo il valore row_ver
(nello spazio row_ver
e nella variabile row_ver
) - prendi il valore row_ver
dall'ultima riga di dati caricati.
Per proteggersi da loop accidentali (in caso di errore nel programma), il file loop while
può essere sostituito con for
:
for _ = 1, max_req do ...
Come risultato dell'esecuzione della funzione sync_goods
spazio goods
il ricevitore conterrà le ultime versioni di tutti i record spaziali goods
nella fonte.
Ovviamente la cancellazione dei dati non può essere effettuata in questo modo. Se esiste tale necessità, è possibile utilizzare un segno di cancellazione. Aggiungi allo spazio goods
campo booleano is_deleted
e invece di eliminare fisicamente un record, utilizziamo l'eliminazione logica: impostiamo il valore del campo is_deleted
nel significato true
. A volte invece di un campo booleano is_deleted
è più conveniente usare il campo deleted
, che memorizza la data-ora della cancellazione logica del record. Dopo aver eseguito un'eliminazione logica, il record contrassegnato per l'eliminazione verrà trasferito dall'origine alla destinazione (secondo la logica discussa sopra).
Sequenza row_ver
può essere utilizzato per trasmettere dati da altri spazi: non è necessario creare una sequenza separata per ogni spazio trasmesso.
Abbiamo esaminato un modo efficace per la replica dei dati di alto livello nelle applicazioni utilizzando il DBMS Tarantool.
risultati
- Tarantool DBMS è un prodotto interessante e promettente per la creazione di applicazioni ad alto carico.
- La replica dei dati di alto livello presenta numerosi vantaggi rispetto alla replica di basso livello.
- Il metodo di replica di alto livello discusso nell'articolo consente di ridurre al minimo la quantità di dati trasferiti trasferendo solo i record che sono cambiati dall'ultima sessione di scambio.
Fonte: habr.com