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 clusterUm 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 HTTP3. 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êneosA 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()
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)
Pegue o valor do campo
row_ver
da última linha e chame a função novamente:
tarantool> repl.get_goods(5)
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)
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
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
- O Tarantool DBMS é um produto atraente e promissor para a criação de aplicativos altamente carregados.
- 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.
- 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.