Replicación de alto nivel en DBMS Tarantool

Ola, estou creando aplicacións para DBMS Tarantool é unha plataforma desenvolvida por Mail.ru Group que combina un DBMS de alto rendemento e un servidor de aplicacións en lingua Lua. A alta velocidade das solucións baseadas en Tarantool conséguese, en particular, debido ao soporte para o modo en memoria do DBMS e á capacidade de executar a lóxica empresarial da aplicación nun único espazo de enderezos con datos. Ao mesmo tempo, a persistencia dos datos está garantida mediante transaccións ACID (consérvase un rexistro WAL no disco). Tarantool ten soporte integrado para replicación e fragmentación. A partir da versión 2.1, admítense consultas en linguaxe SQL. Tarantool é de código aberto e ten licenza baixo a licenza Simplified BSD. Tamén hai unha versión comercial Enterprise.

Replicación de alto nivel en DBMS Tarantool
Sente o poder! (... tamén goza da actuación)

Todo o anterior fai de Tarantool unha plataforma atractiva para crear aplicacións de alta carga que funcionen con bases de datos. En tales aplicacións, moitas veces hai unha necesidade de replicación de datos.

Como se mencionou anteriormente, Tarantool ten incorporada a replicación de datos. O principio do seu funcionamento é executar secuencialmente en réplicas todas as transaccións contidas no rexistro mestre (WAL). Normalmente tal replicación (chamámola máis adiante de baixo nivel) úsase para garantir a tolerancia a fallos da aplicación e/ou para distribuír a carga de lectura entre os nodos do clúster.

Replicación de alto nivel en DBMS Tarantool
Arroz. 1. Replicación dentro dun clúster

Un exemplo dun escenario alternativo sería transferir datos creados nunha base de datos a outra base de datos para o seu procesamento/seguimento. Neste último caso, unha solución máis conveniente pode ser empregar nivel alto replicación: replicación de datos a nivel de lóxica empresarial da aplicación. Eses. Non usamos unha solución preparada integrada no DBMS, senón que implementamos a replicación pola nosa conta na aplicación que estamos a desenvolver. Este enfoque ten vantaxes e desvantaxes. Imos enumerar as vantaxes.

1. Aforro de tráfico:

  • Non pode transferir todos os datos, senón só parte del (por exemplo, pode transferir só algunhas táboas, algunhas das súas columnas ou rexistros que cumpran un determinado criterio);
  • A diferenza da replicación de baixo nivel, que se realiza de forma continua en modo asíncrono (implementado na versión actual de Tarantool - 1.10) ou síncrono (que se implementará en versións posteriores de Tarantool), a replicación de alto nivel pódese realizar en sesións (é dicir, o a aplicación sincroniza primeiro os datos: datos dunha sesión de intercambio, despois hai unha pausa na replicación, despois da cal ocorre a seguinte sesión de intercambio, etc.);
  • se un rexistro cambiou varias veces, só pode transferir a súa última versión (a diferenza da replicación de baixo nivel, na que todos os cambios realizados no mestre reproduciranse secuencialmente nas réplicas).

2. Non hai dificultades para implementar o intercambio HTTP, que permite sincronizar bases de datos remotas.

Replicación de alto nivel en DBMS Tarantool
Arroz. 2. Replicación a través de HTTP

3. As estruturas de bases de datos entre as que se transfiren os datos non teñen por que ser as mesmas (ademais, no caso xeral, mesmo é posible utilizar diferentes DBMS, linguaxes de programación, plataformas, etc.).

Replicación de alto nivel en DBMS Tarantool
Arroz. 3. Replicación en sistemas heteroxéneos

A desvantaxe é que, de media, a programación é máis difícil/custosa que a configuración e, en lugar de personalizar a funcionalidade integrada, terás que implementar a túa.

Se na súa situación as vantaxes anteriores son cruciais (ou son unha condición necesaria), entón ten sentido utilizar a replicación de alto nivel. Vexamos varias formas de implementar a replicación de datos de alto nivel no DBMS de Tarantool.

Minimización do tráfico

Entón, unha das vantaxes da replicación de alto nivel é o aforro de tráfico. Para que esta vantaxe se realice plenamente, é necesario minimizar a cantidade de datos transferidos durante cada sesión de intercambio. Por suposto, non debemos esquecer que ao final da sesión, o receptor de datos debe estar sincronizado coa fonte (polo menos para esa parte dos datos que interveñen na replicación).

Como minimizar a cantidade de datos transferidos durante a replicación de alto nivel? Unha solución sinxela podería ser seleccionar os datos por data e hora. Para iso, pode utilizar o campo data-hora xa existente na táboa (se existe). Por exemplo, un documento de "pedido" pode ter un campo "tempo de execución de pedido necesario" - delivery_time. O problema con esta solución é que os valores deste campo non teñen que estar na secuencia que corresponde á creación de pedidos. Polo tanto, non podemos lembrar o valor máximo do campo delivery_time, transmitido durante a sesión de intercambio anterior e, durante a sesión de intercambio seguinte, seleccione todos os rexistros cun valor de campo superior delivery_time. É posible que se engadiran rexistros cun valor de campo inferior entre sesións de intercambio delivery_time. Así mesmo, a orde puido sufrir cambios, que con todo non afectaron ao campo delivery_time. En ambos os casos, os cambios non se transferirán da orixe ao destino. Para solucionar estes problemas, teremos que transferir datos "superpostos". Eses. en cada sesión de intercambio transferiremos todos os datos co valor do campo delivery_time, superando algún punto no pasado (por exemplo, N horas desde o momento actual). Non obstante, é obvio que para sistemas grandes este enfoque é moi redundante e pode reducir a nada o aforro de tráfico que estamos esforzando. Ademais, é posible que a táboa que se está a transferir non teña un campo asociado cunha data e hora.

Outra solución, máis complexa en canto á implantación, consiste en acusar recibo de datos. Neste caso, durante cada sesión de intercambio transmítense todos os datos, cuxa recepción non foi confirmada polo destinatario. Para implementar isto, terá que engadir unha columna booleana á táboa de orixe (por exemplo, is_transferred). Se o receptor acusa recibo do rexistro, o campo correspondente toma o valor true, despois de que a entrada xa non está implicada nos intercambios. Esta opción de implementación ten as seguintes desvantaxes. En primeiro lugar, por cada rexistro transferido, debe xerarse e enviarse un acuse de recibo. En liñas xerais, isto podería ser comparable a duplicar a cantidade de datos transferidos e levar a duplicar o número de viaxes de ida e volta. En segundo lugar, non existe a posibilidade de enviar o mesmo rexistro a varios receptores (o primeiro receptor en recibir confirmará a recepción por si mesmo e para todos os demais).

Un método que non ten as desvantaxes indicadas anteriormente é engadir unha columna á táboa transferida para rastrexar os cambios nas súas filas. Tal columna pode ser de tipo data e hora e debe ser configurada/actualizada pola aplicación á hora actual cada vez que se engaden/cambian os rexistros (atomicamente coa adición/cambio). Como exemplo, chamemos á columna update_time. Ao gardar o valor máximo de campo desta columna para os rexistros transferidos, podemos iniciar a seguinte sesión de intercambio con este valor (seleccione rexistros co valor de campo update_time, superando o valor previamente almacenado). O problema con este último enfoque é que os cambios de datos poden ocorrer en lotes. Como resultado dos valores de campo na columna update_time pode non ser único. Polo tanto, esta columna non se pode usar para a saída de datos porcionados (páxina por páxina). Para mostrar os datos páxina por páxina, terás que inventar mecanismos adicionais que probablemente teñan unha eficiencia moi baixa (por exemplo, recuperar da base de datos todos os rexistros co valor update_time superior a un determinado e producindo un determinado número de rexistros, partindo dun determinado desfase desde o inicio da mostra).

Pode mellorar a eficiencia da transferencia de datos mellorando lixeiramente o enfoque anterior. Para iso, utilizaremos o tipo de enteiro (enteiro longo) como valores de campo da columna para o seguimento dos cambios. Poñemos un nome á columna row_ver. O valor do campo desta columna aínda debe configurarse/actualizarse cada vez que se crea/modifica un rexistro. Pero neste caso, ao campo non se lle asignará a data e hora actual, senón o valor dalgún contador, aumentado nun. Como resultado, a columna row_ver conterá valores únicos e pode usarse non só para mostrar datos "delta" (datos engadidos/modificados desde o final da sesión de intercambio anterior), senón tamén para dividilos de forma sinxela e eficaz en páxinas.

O último método proposto para minimizar a cantidade de datos transferidos no marco da replicación de alto nivel paréceme o máis óptimo e universal. Vexámolo con máis detalle.

Pasar datos mediante un contador de versións de filas

Implantación da parte servidor/mestra

En MS SQL Server, hai un tipo de columna especial para implementar este enfoque: rowversion. Cada base de datos ten un contador que aumenta nun cada vez que se engade/cambia un rexistro nunha táboa que ten unha columna como rowversion. O valor deste contador asígnase automaticamente ao campo desta columna no rexistro engadido/modificado. O DBMS Tarantool non ten un mecanismo integrado similar. Non obstante, en Tarantool non é difícil implementalo manualmente. Vexamos como se fai isto.

En primeiro lugar, un pouco de terminoloxía: as táboas en Tarantool chámanse espazos e os rexistros chámanse tuplas. En Tarantool podes crear secuencias. As secuencias non son máis que xeradores nomeados de valores enteiros ordenados. Eses. isto é exactamente o que necesitamos para os nosos propósitos. A continuación imos crear unha secuencia deste tipo.

Antes de realizar calquera operación de base de datos en Tarantool, cómpre executar o seguinte comando:

box.cfg{}

Como resultado, Tarantool comezará a escribir instantáneas da base de datos e rexistros de transaccións no directorio actual.

Imos crear unha secuencia row_version:

box.schema.sequence.create('row_version',
    { if_not_exists = true })

Opción if_not_exists permite que o script de creación se execute varias veces: se o obxecto existe, Tarantool non tentará crealo de novo. Esta opción empregarase en todos os comandos DDL posteriores.

Imos crear un espazo como exemplo.

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
})

Aquí establecemos o nome do espazo (goods), nomes de campo e os seus tipos.

Os campos de incremento automático en Tarantool tamén se crean mediante secuencias. Imos crear unha clave primaria de incremento automático por 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 admite varios tipos de índices. Os índices máis utilizados son os tipos TREE e HASH, que se basean en estruturas correspondentes ao nome. TREE é o tipo de índice máis versátil. Permítelle recuperar datos de forma organizada. Pero para a selección de igualdade, HASH é máis axeitado. En consecuencia, é recomendable usar HASH para a chave primaria (que é o que fixemos).

Para usar a columna row_ver para transferir os datos modificados, cómpre vincular os valores de secuencia aos campos desta columna row_ver. Pero a diferenza da clave primaria, o valor do campo da columna row_ver debería aumentar nun non só ao engadir novos rexistros, senón tamén ao cambiar os existentes. Podes usar disparadores para iso. Tarantool ten dous tipos de disparadores espaciais: before_replace и on_replace. Os disparadores lánzanse sempre que os datos do espazo cambian (para cada tupla afectada polos cambios, lánzase unha función de activación). A diferenza on_replace, before_replace-triggers permiten modificar os datos da tupla para a que se executa o disparador. En consecuencia, o último tipo de disparadores convénnos.

box.space.goods:before_replace(function(old, new)
    return box.tuple.new({new[1], new[2], new[3],
        box.sequence.row_version:next()})
end)

O seguinte activador substitúe o valor do campo row_ver tupla almacenada ao seguinte valor da secuencia row_version.

Para poder extraer datos do espazo goods por columna row_ver, imos crear un índice:

box.space.goods:create_index('row_ver', {
    parts = { 'row_ver' },
    unique = true,
    type = 'TREE',
    if_not_exists = true
})

Tipo de índice - árbore (TREE), porque teremos que extraer os datos en orde ascendente dos valores da columna row_ver.

Engadimos algúns datos ao espazo:

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}

Porque O primeiro campo é un contador de incremento automático; no seu lugar pasamos cero. Tarantool substituirá automaticamente o seguinte valor. Do mesmo xeito, como o valor dos campos da columna row_ver pode pasar nil - ou non especificar o valor en absoluto, porque esta columna ocupa a última posición do espazo.

Comprobamos o resultado da inserción:

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]
...

Como podes ver, o primeiro e o último campo enchense automaticamente. Agora será doado escribir unha función para cargar páxina por páxina os cambios de espazo 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

A función toma como parámetro o valor row_ver, a partir do cal é necesario descargar os cambios, e devolve unha parte dos datos modificados.

A mostraxe de datos en Tarantool faise a través de índices. Función get_goods usa un iterador por índice row_ver para recibir datos modificados. O tipo de iterador é GT (Maior que, maior que). Isto significa que o iterador percorrerá secuencialmente os valores do índice a partir da clave pasada (valor do campo row_ver).

O iterador devolve tuplas. Para posteriormente poder transferir datos vía HTTP, é necesario converter as tuplas nunha estrutura conveniente para a posterior serialización. O exemplo usa a función estándar para iso tomap. En lugar de usar tomap pode escribir a súa propia función. Por exemplo, é posible que queiramos cambiar o nome dun campo name, non pases polo campo code e engade 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

O tamaño da páxina dos datos de saída (o número de rexistros nunha parte) está determinado pola variable page_size. No exemplo o valor page_size é 5. Nun programa real, o tamaño da páxina adoita importar máis. Depende do tamaño medio da tupla espacial. O tamaño óptimo da páxina pódese determinar empíricamente medindo o tempo de transferencia de datos. Canto maior sexa o tamaño da páxina, menor será o número de viaxes de ida e volta entre os lados de envío e recepción. Deste xeito, pode reducir o tempo total para descargar os cambios. Non obstante, se o tamaño da páxina é demasiado grande, pasaremos demasiado tempo no servidor serializando a mostra. Como resultado, pode haber atrasos no procesamento doutras solicitudes que chegan ao servidor. Parámetro page_size pódese cargar desde o ficheiro de configuración. Para cada espazo transmitido, pode establecer o seu propio valor. Non obstante, para a maioría dos espazos o valor predeterminado (por exemplo, 100) pode ser axeitado.

Imos executar a función 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
...

Tomemos o valor do campo row_ver desde a última liña e chamar de novo á función:

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
...

Unha vez mais:

tarantool> get_goods(8)
---
- []
...

Como podes ver, cando se usa deste xeito, a función devolve todos os rexistros de espazo páxina por páxina goods. A última páxina vai seguida dunha selección baleira.

Imos facer cambios no espazo:

box.space.goods:update(4, {{'=', 6, 'copybook'}})
box.space.goods:insert{nil, 'clip', 234}
box.space.goods:insert{nil, 'folder', 432}

Cambiamos o valor do campo name para unha entrada e engadiu dúas novas entradas.

Repetimos a última chamada de función:

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
...

A función devolveu os rexistros modificados e engadidos. Entón a función get_goods permítelle recibir datos que cambiaron desde a súa última chamada, que é a base do método de replicación en consideración.

Deixaremos a emisión de resultados vía HTTP en forma de JSON fóra do ámbito deste artigo. Podes ler sobre isto aquí: https://habr.com/ru/company/mailru/blog/272141/

Implantación da parte cliente/escravo

Vexamos como é a implementación do lado receptor. Imos crear un espazo no lado receptor para almacenar os datos descargados:

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
})

A estrutura do espazo aseméllase á estrutura do espazo na fonte. Pero como non imos pasar os datos recibidos a ningún outro lugar, a columna row_ver non está no espazo do destinatario. En campo id rexistraranse os identificadores da fonte. Polo tanto, no lado do receptor non hai necesidade de facelo auto-incremento.

Ademais, necesitamos un espazo para gardar valores 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
})

Para cada espazo cargado (campo space_name) gardaremos aquí o último valor cargado row_ver (campo value). A columna actúa como chave primaria space_name.

Imos crear unha función para cargar datos do espazo goods vía HTTP. Para iso, necesitamos unha biblioteca que implemente un cliente HTTP. A seguinte liña carga a biblioteca e crea unha instancia do cliente HTTP:

local http_client = require('http.client').new()

Tamén necesitamos unha biblioteca para a deserialización de json:

local json = require('json')

Isto é suficiente para crear unha función de carga de datos:

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

A función executa unha solicitude HTTP ao enderezo url e envíao row_ver como parámetro e devolve o resultado deserializado da solicitude.

A función para gardar os datos recibidos é así:

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 de gardar datos no espazo goods colocado nunha transacción (a función úsase para iso box.atomic) para reducir o número de operacións de disco.

Finalmente, a función de sincronización do espazo local goods cunha fonte podes implementala como esta:

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

Primeiro lemos o valor gardado anteriormente row_ver para o espazo goods. Se falta (a primeira sesión de intercambio), entón tomámolo como row_ver cero. A continuación no ciclo realizamos unha descarga páxina por páxina dos datos modificados desde a fonte no URL especificado. En cada iteración, gardamos os datos recibidos no espazo local axeitado e actualizamos o valor row_ver (no espazo row_ver e na variable row_ver) - toma o valor row_ver desde a última liña de datos cargados.

Para protexer contra o bucle accidental (en caso de erro no programa), o bucle while pode ser substituído por for:

for _ = 1, max_req do ...

Como resultado da execución da función sync_goods espazo goods o receptor conterá as últimas versións de todos os rexistros espaciais goods na fonte.

Obviamente, a eliminación de datos non se pode transmitir deste xeito. Se existe esa necesidade, pode usar unha marca de eliminación. Engadir ao espazo goods campo booleano is_deleted e en lugar de eliminar fisicamente un rexistro, usamos a eliminación lóxica: establecemos o valor do campo is_deleted en significado true. Ás veces en lugar dun campo booleano is_deleted é máis cómodo usar o campo deleted, que almacena a data e hora da eliminación lóxica do rexistro. Despois de realizar unha eliminación lóxica, o rexistro marcado para a eliminación transferirase da orixe ao destino (segundo a lóxica comentada anteriormente).

Secuencia row_ver pódese utilizar para transmitir datos doutros espazos: non é necesario crear unha secuencia separada para cada espazo transmitido.

Analizamos un xeito eficaz de replicar datos de alto nivel en aplicacións que usan o DBMS Tarantool.

Descubrimentos

  1. Tarantool DBMS é un produto atractivo e prometedor para crear aplicacións de alta carga.
  2. A replicación de datos de alto nivel ten unha serie de vantaxes sobre a replicación de baixo nivel.
  3. O método de replicación de alto nivel que se comenta no artigo permítelle minimizar a cantidade de datos transferidos transferindo só aqueles rexistros que cambiaron desde a última sesión de intercambio.

Fonte: www.habr.com

Engadir un comentario