Replicação de alto nível no DBMS Tarantool

Olá, estou criando aplicativos para o Tarantool DBMS - esta é uma plataforma desenvolvida pelo Mail.ru Group que combina um DBMS de alto desempenho e um servidor de aplicativos em Lua. A alta velocidade das soluções baseadas em Tarantool é alcançada, principalmente, suportando o modo DBMS na memória e a capacidade de executar lógica de aplicativos de negócios em um único espaço de endereço com dados. Isso garante a persistência dos dados usando transações ACID (um log WAL é mantido no disco). O Tarantool possui suporte embutido de replicação e fragmentação. A partir da versão 2.1, as consultas SQL são suportadas. O Tarantool é de código aberto e licenciado sob o BSD simplificado. Há também uma versão comercial da empresa.


Sinta o poder! (... também aprecie o desempenho)

Tudo isso torna o Tarantool uma plataforma atraente para criar aplicativos de banco de dados altamente carregados. Nessas aplicações, a replicação de dados geralmente se torna necessária.

Como mencionado acima, o Tarantool possui replicação de dados embutida. O princípio de seu trabalho é a execução seqüencial em réplicas de todas as transações contidas no log do assistente (WAL). Normalmente, essa replicação (chamaremos de baixo nível abaixo) é usada para fornecer tolerância a falhas do aplicativo e / ou distribuir a carga de leitura entre os nós do cluster.


Fig. 1. Replicação dentro do cluster

Um exemplo de cenário alternativo é a transferência de dados criados em um banco de dados para outro banco de dados para processamento / monitoramento. No último caso, uma solução mais conveniente pode ser usar a replicação de alto nível - replicação de dados no nível da lógica de negócios do aplicativo. I.e. Não usamos uma solução pronta incorporada no DBMS, mas, por conta própria, implementamos a replicação dentro do aplicativo que estamos desenvolvendo. Essa abordagem tem vantagens e desvantagens. Listamos os profissionais.

1. Economize tráfego:

  • você pode transferir não todos os dados, mas apenas parte deles (por exemplo, você pode transferir apenas algumas tabelas, algumas de suas colunas ou registros que atendem a um determinado critério);
  • Ao contrário da replicação de baixo nível, que é executada continuamente no modo assíncrono (implementado na versão atual do Tarantool - 1.10) ou síncrona (a ser implementada em versões futuras do Tarantool), a replicação de alto nível pode ser executada por sessões (ou seja, o aplicativo primeiro executa a sincronização de dados - sessão de troca dados, há uma pausa na replicação, após a qual a próxima sessão de troca ocorre etc.);
  • se o registro tiver sido alterado várias vezes, você poderá transferir apenas sua versão mais recente (diferente da replicação de baixo nível, na qual todas as alterações feitas no assistente serão reproduzidas sequencialmente nas réplicas).

2. Não há dificuldades com a implementação do intercâmbio via HTTP, o que permite sincronizar bancos de dados remotos.


Fig. 2. replicação HTTP

3. As estruturas de banco de dados entre as quais os dados são transmitidos não precisam ser as mesmas (além disso, no caso geral, é possível usar DBMSs diferentes, linguagens de programação, plataformas etc.).


Fig. 3. Replicação em sistemas heterogêneos

A desvantagem é que, em média, a programação é mais complicada / mais cara que a configuração e, em vez de configurar a funcionalidade interna, você precisa implementar sua própria.

Se na sua situação as vantagens acima desempenham um papel decisivo (ou são uma condição necessária), faz sentido usar a replicação de alto nível. Vamos considerar várias maneiras de implementar a replicação de dados de alto nível no Tarantool DBMS.

Minimização de tráfego


Portanto, um dos benefícios da replicação de alto nível é economizar tráfego. Para que essa vantagem seja totalmente manifestada, é necessário minimizar a quantidade de dados transmitidos durante cada sessão de troca. Obviamente, você não deve esquecer que, no final da sessão, o receptor de dados deve estar sincronizado com a fonte (pelo menos para a parte dos dados envolvidos na replicação).

Como minimizar a quantidade de dados transferidos durante a replicação de alto nível? A solução "na testa" pode ser a seleção de dados por data e hora. Para fazer isso, você pode usar o campo de data e hora já na tabela (se houver). Por exemplo, um documento "pedido" pode ter um campo "tempo necessário para a execução do pedido" - delivery_time . O problema com esta solução é que os valores nesse campo não precisam estar na sequência correspondente à criação de pedidos. Portanto, não podemos lembrar o valor máximo do campo delivery_time transmitido durante a sessão de troca anterior e, na próxima sessão de troca, selecione todos os registros com um valor mais alto do campo delivery_time . No intervalo entre as sessões de troca, os registros com um valor menor do campo delivery_time podem ser adicionados. Além disso, o pedido pode sofrer alterações, o que não afeta o campo delivery_time . Nos dois casos, as alterações não serão transmitidas da fonte para o receptor. Para resolver esses problemas, precisaremos transmitir dados "sobrepostos". I.e. durante cada sessão de troca, transferiremos todos os dados com um valor de campo delivery_time que exceda algum ponto no passado (por exemplo, N horas a partir do momento atual). No entanto, é óbvio que, para sistemas grandes, essa abordagem é muito redundante e pode reduzir a economia de tráfego que estamos buscando. Além disso, a tabela transmitida pode não ter um campo de data e hora.

Outra solução, mais complexa em termos de implementação, é confirmar o recebimento de dados. Nesse caso, em cada sessão de troca, todos os dados são transmitidos, cuja recepção não é confirmada pelo destinatário. Para implementação, você precisa adicionar uma coluna booleana à tabela de origem (por exemplo, is_transferred ). Se o destinatário confirmar o recebimento do registro, o campo correspondente será definido como true , após o qual o registro não estará mais envolvido nas trocas. Esta opção de implementação possui as seguintes desvantagens. Primeiramente, para cada registro transferido, é necessário gerar e enviar uma confirmação. Grosso modo, isso pode ser comparável à duplicação da quantidade de dados transferidos e à duplicação do número de viagens de ida e volta. Em segundo lugar, não há possibilidade de enviar o mesmo registro para vários receptores (o primeiro receptor confirmará o recebimento para si e para todos os outros).

O método, desprovido das desvantagens acima, é adicionar colunas à tabela a serem transmitidas para rastrear alterações em suas linhas. Essa coluna pode ser do tipo data e hora e deve ser configurada / atualizada pelo aplicativo para a hora atual sempre que adicionar / alterar registros (atomicamente com adicionar / alterar). Como exemplo, vamos chamar a coluna update_time . Depois de salvar o valor máximo do campo desta coluna para os registros transferidos, podemos iniciar a próxima sessão de troca a partir desse valor (selecione registros com o valor do campo update_time exceda o valor salvo anteriormente). O problema com a última abordagem é que as alterações de dados podem ocorrer no modo em lote. Como resultado, os valores do campo na coluna update_time não ser exclusivos. Portanto, esta coluna não pode ser usada para saída de dados em lote (página). Para saída de dados paginados, mecanismos adicionais terão que ser inventados com uma eficiência muito baixa (por exemplo, recuperando do banco de dados todos os registros com update_time acima do valor especificado e emitindo um certo número de registros, iniciando com um certo deslocamento desde o início da amostra).

Você pode aumentar a eficiência da transferência de dados melhorando ligeiramente a abordagem anterior. Para fazer isso, usaremos um tipo inteiro (inteiro longo) como valores dos campos da coluna para rastrear alterações. row_ver coluna row_ver . O valor do campo dessa coluna ainda deve ser definido / atualizado sempre que um registro for criado / modificado. Mas, nesse caso, o campo será atribuído não à data e hora atuais, mas ao valor de algum contador aumentado em um. Como resultado, a coluna row_ver conterá valores exclusivos e pode ser usada não apenas para row_ver dados "delta" (dados adicionados / alterados após o final da sessão de troca anterior), mas também para uma paginação simples e eficiente.

O último método proposto para minimizar a quantidade de dados transferidos como parte da replicação de alto nível me parece o mais ideal e universal. Vamos insistir nisso com mais detalhes.

Transferência de dados usando o contador de versão de linha


Implementação de servidor / mestre


No MS SQL Server, para implementar essa abordagem, existe um tipo de coluna especial - rowversion . Cada banco de dados possui um contador, que aumenta um a cada vez que você adiciona / altera um registro em uma tabela que possui uma coluna do tipo rowversion . O valor desse contador é automaticamente atribuído ao campo desta coluna no registro adicionado / alterado. O Tarantool DBMS não possui um mecanismo interno semelhante. No entanto, no Tarantool, não é difícil implementá-lo manualmente. Considere como isso é feito.

Primeiro, um pouco de terminologia: as tabelas no Tarantool são chamadas de espaço e os registros são chamados de tupla. No Tarantool, você pode criar sequências. Sequências nada mais são do que geradores nomeados de valores ordenados de números inteiros. I.e. isso é exatamente o que precisamos para nossos propósitos. Abaixo, criaremos essa sequência.

Antes de executar qualquer operação de banco de dados no Tarantool, você deve executar o seguinte comando:

 box.cfg{} 

Como resultado, o Tarantool começará a gravar instantâneos e um log de transações no diretório atual.

Crie uma sequência row_version :

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

A opção if_not_exists permite executar o script de criação várias vezes: se o objeto existir, o Tarantool não tentará recriá-lo. Esta opção será usada em todos os comandos DDL subsequentes.

Vamos criar um espaço para um 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 }) 

Aqui, definimos o nome do espaço ( goods ), os nomes dos campos e seus tipos.

Os campos de incremento automático do Tarantool também são criados usando sequências. Crie uma chave primária de incremento automático para o campo de 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 }) 

O Tarantool suporta vários tipos de índices. Na maioria das vezes, são usados ​​índices dos tipos TREE e HASH, baseados nas estruturas correspondentes ao nome. TREE é o tipo de índice mais versátil. Permite recuperar dados de maneira ordenada. Mas para a escolha da igualdade, o HASH é mais adequado. Portanto, é aconselhável usar o HASH para a chave primária (o que fizemos).

Para usar a coluna row_ver para transmitir dados alterados, você deve vincular os valores da sequência row_ver aos campos nesta coluna. Mas, diferentemente da chave primária, o valor do campo na coluna row_ver deve aumentar em um, não apenas ao adicionar novos registros, mas também ao alterar os existentes. Para fazer isso, você pode usar gatilhos. O Tarantool possui dois tipos de gatilhos para espaços: before_replace e on_replace . Os acionadores são acionados toda vez que os dados no espaço são alterados (para cada tupla afetada pelas alterações, a função de acionamento é acionada). Ao contrário de on_replace , os gatilhos before_replace permitem modificar os dados da tupla para a qual o gatilho é executado. Consequentemente, o último tipo de gatilhos nos convém.

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

Esse acionador substitui o valor do campo row_ver da tupla armazenada pelo próximo row_version sequência row_version .

Para poder extrair dados do espaço de goods na coluna row_ver , crie um índice:

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

O tipo de índice é uma árvore ( TREE ), porque precisamos recuperar os dados em ordem crescente de valores na coluna row_ver .

Adicione alguns dados ao espaço:

 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 é um contador de incremento automático, passamos nulo. O Tarantool substituirá automaticamente o próximo valor. Da mesma forma, você pode passar nulo como o valor dos campos na coluna row_ver - ou não especificar o valor, porque essa coluna ocupa a última posição no espaço.

Verifique o resultado da inserção:

 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 você pode ver, o primeiro e o último campo foram preenchidos automaticamente. Agora será fácil escrever uma função para paginar o descarregamento das 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 função usa como parâmetro o valor row_ver do último registro recebido (0 para a primeira chamada) e retorna o próximo lote de dados alterados (se houver um, caso contrário, um array vazio).

A recuperação de dados no Tarantool é feita através de índices. A função get_goods usa o row_ver índice row_ver para recuperar os dados alterados. O tipo de iterador é GT (Maior que, mais que). Isso significa que o iterador percorrerá sequencialmente os valores do índice, começando no próximo valor após a chave passada.

O iterador retorna as tuplas. Para posteriormente poder transferir dados via HTTP, é necessário converter as tuplas em uma estrutura conveniente para serialização subsequente. No exemplo, a função tomap padrão é usada para isso. Em vez de usar o tomap você pode escrever sua própria função. Por exemplo, convém renomear o campo de name , não passar o campo de code e adicionar o campo de 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 tamanho da página dos dados de saída (o número de registros em uma parte) é determinado pela variável page_size . No exemplo, o valor page_size é 5. Em um programa real, o tamanho da página geralmente é mais importante. Depende do tamanho médio da tupla de espaço. O tamanho ideal da página pode ser selecionado empiricamente, medindo o tempo da transferência de dados. Quanto maior a página, menor o número de viagens de ida e volta entre os lados de envio e de recebimento. Assim, você pode reduzir o tempo total para o upload de alterações. No entanto, se o tamanho da página for muito grande, o servidor levará muito tempo para serializar a seleção. Como resultado, pode haver atrasos no processamento de outras solicitações que chegaram ao servidor. O parâmetro page_size pode ser carregado no arquivo de configuração. Para cada espaço transmitido, você pode definir seu próprio valor. No entanto, para a maioria dos espaços, o valor padrão (por exemplo, 100) pode ser adequado.

get_goods função get_goods no módulo. Crie um arquivo repl.lua contendo a descrição da variável page_size e a função get_goods . No final do arquivo, adicione a função de exportação:

 return { get_goods = get_goods } 

Para carregar o módulo, execute:

 tarantool> repl = require('repl') --- ... 

Vamos executar a função get_goods :

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

Pegue o valor do campo row_ver da última linha e chame a função novamente:

 tarantool> repl.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 novamente:

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

Como você pode ver, com esse uso, a função página por página retorna todos os registros do espaço de goods . A última página é seguida por uma seleção vazia.

Vamos fazer alterações no espaço:

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

Alteramos o valor do campo de name para um registro e adicionamos dois novos registros.

Repita a última chamada de função:

 tarantool> repl.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 função retornou os registros alterados e adicionados. Portanto, a função get_goods permite obter dados que foram alterados desde a última chamada, que é a base do método de replicação em consideração.

Deixamos a saída dos resultados via HTTP no formato JSON além do escopo deste artigo. Você pode ler sobre isso aqui: https://habr.com/ru/company/mailru/blog/272141/

Implementação da parte cliente / escravo


Vamos considerar a aparência da implementação do lado receptor. Crie um espaço no lado receptor para armazenar os dados baixados:

 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 espaço se assemelha à estrutura do espaço na fonte. Mas como não vamos transferir os dados recebidos para outro lugar, a coluna row_ver está row_ver no espaço do destinatário. No campo id serão escritos os identificadores da fonte. Portanto, no lado do receptor, não há necessidade de incrementar automaticamente.

Além disso, precisamos de um espaço para salvar os 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 espaço carregado (campo space_name ), salvaremos aqui o último valor carregado row_ver ( value campo). A chave primária é a coluna space_name .

Vamos criar uma função para carregar dados do espaço de goods via HTTP. Para fazer isso, precisamos de uma biblioteca que implemente um cliente HTTP. A linha a seguir carrega a biblioteca e instancia o cliente HTTP:

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

Também precisamos de uma biblioteca para desserialização do json:

 local json = require('json') 

Isso é suficiente para criar uma função de carregamento de dados:

 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 função executa uma solicitação HTTP no URL, passa row_ver para ele como um parâmetro e retorna o resultado desserializado da solicitação.

A função de salvar os dados recebidos é a seguinte:

 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 

O ciclo de armazenamento de dados no espaço de goods é colocado em uma transação (a função box.atomic é usada para isso) para reduzir o número de operações em disco.

Por fim, a função de sincronização dos goods espaciais locais com a fonte pode ser implementada da seguinte maneira:

 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 row_ver salvo anteriormente para o espaço de goods . Se estiver ausente (a primeira sessão de troca), tomaremos zero como row_ver . Em seguida, no loop, paginamos os dados modificados da fonte para o URL especificado. A cada iteração, salvamos os dados recebidos no espaço local correspondente e atualizamos o valor row_ver (no row_ver row_ver e na variável row_ver ) - pegamos o valor row_ver da última linha dos dados carregados.

Para proteger contra loops acidentais (no caso de um erro no programa), o while pode ser substituído por:

 for _ = 1, max_req do ... 

Como resultado da função sync_goods , os goods no receptor conterão as versões mais recentes de todos goods registros de espaço de goods na origem.

Obviamente, a exclusão de dados não pode ser transmitida dessa maneira. Se essa necessidade existir, você pode usar a marca de exclusão. is_deleted campo booleano is_deleted espaço de goods e, em vez de excluir fisicamente o registro, usamos a exclusão lógica - defina o valor do campo is_deleted como true . Às vezes, em vez do campo booleano is_deleted , is_deleted mais conveniente usar o campo deleted , que armazena a data e hora da exclusão lógica do registro. Após realizar uma exclusão lógica, o registro marcado para exclusão será transferido da fonte para o receptor (de acordo com a lógica discutida acima).

A sequência row_ver pode ser usada para transferir dados de outros espaços: não há necessidade de criar uma sequência separada para cada espaço transmitido.

Examinamos uma maneira eficaz de replicação de dados de alto nível em aplicativos usando o Tarantool DBMS.

Conclusões


  1. O Tarantool DBMS é um produto atraente e promissor para a criação de aplicativos altamente carregados.
  2. A replicação de alto nível fornece uma abordagem mais flexível para a transferência de dados em comparação com a replicação de baixo nível.
  3. O método de replicação de alto nível considerado no artigo permite minimizar a quantidade de dados transmitidos transferindo apenas os registros que foram alterados desde a última sessão de troca.

Source: https://habr.com/ru/post/pt455146/


All Articles