Replica di alto livello nel DBMS Tarantool

Ciao, sto creando applicazioni per DBMS Tarantoolo è una piattaforma sviluppata da Mail.ru Group che combina un DBMS ad alte prestazioni e un server applicativo in linguaggio Lua. L'elevata velocità delle soluzioni basate su Tarantool è raggiunta, in particolare, grazie al supporto della modalità in-memory del DBMS e alla capacità di eseguire la logica aziendale dell'applicazione in un unico spazio di indirizzi con i dati. Allo stesso tempo, la persistenza dei dati è garantita tramite transazioni ACID (sul disco viene mantenuto un registro WAL). Tarantool ha il supporto integrato per la replica e lo sharding. A partire dalla versione 2.1 sono supportate le interrogazioni in linguaggio SQL. Tarantool è open source e concesso in licenza con la licenza BSD semplificata. Esiste anche una versione Enterprise commerciale.

Replica di alto livello nel DBMS Tarantool
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.

Replica di alto livello nel DBMS Tarantool
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.

Replica di alto livello nel DBMS Tarantool
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).

Replica di alto livello nel DBMS Tarantool
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: https://habr.com/ru/company/mailru/blog/272141/

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

  1. Tarantool DBMS è un prodotto interessante e promettente per la creazione di applicazioni ad alto carico.
  2. La replica dei dati di alto livello presenta numerosi vantaggi rispetto alla replica di basso livello.
  3. 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

Aggiungi un commento