A caminho de um DBMS e ERP NoSQL funcional: armazenamento de saldos e custos

Olá Habr!

Continuamos estudando a aplicabilidade dos princípios da programação funcional no design do ERP. Em um artigo anterior, falamos sobre por que isso é necessário , lançamos os fundamentos da arquitetura e demonstramos a construção de convoluções simples usando o exemplo de uma declaração inversa. De fato, a abordagem de fornecimento de eventos é proposta, mas, devido à separação do banco de dados nas partes imutáveis ​​e mutáveis, obtemos em um sistema uma combinação das vantagens de um mapa / redução de armazenamento e um DBMS na memória, que resolve o problema de desempenho e o problema de escalabilidade. Neste artigo, mostrarei (e mostrarei um protótipo no tempo de execução TypeScript e Deno ) como armazenar registros de saldos instantâneos em um sistema assim e calcular o custo. Para quem não leu o primeiro artigo - um breve resumo:

1. Diário de documentos . Um ERP construído com base em um RDBMS é um enorme estado mutável com acesso competitivo, portanto, não é escalável, pouco audível e não é confiável na operação (permite inconsistência de dados). No ERP funcional, todos os dados são organizados na forma de um diário cronologicamente ordenado de documentos primários imutáveis, e não há nada além desses documentos. Os links são resolvidos de novos documentos para antigos por ID completo (e nunca vice-versa), e todos os outros dados (saldos, registros, comparações) são convoluções calculadas, ou seja, resultados armazenados em cache de funções puras no fluxo de documentos. A falta de estado + audibilidade das funções nos dá maior confiabilidade (o blockchain se encaixa perfeitamente nesse esquema) e, como bônus, temos uma simplificação do esquema de armazenamento + cache adaptável em vez de rígido (organizado com base em tabelas).

É assim que o fragmento de dados em nosso ERP se parece
//   { "type": "person", //  ,      "key": "person.0", //    "id": "person.0^1580006048190", //  +    ID "erp_type": "person.retail", "name": "   " } //  "" { "type": "purch", "key": "purch.XXX", "id": "purch.XXX^1580006158787", "date": "2020-01-21", "person": "person.0^1580006048190", //    "stock": "stock.0^1580006048190", //    "lines": [ { "nomen": "nomen.0^1580006048190", //    "qty": 10000, "price": 116.62545127448834 } ] } 

2. Imunidade e mutabilidade . O diário de documentos é dividido em 2 partes desiguais:

  • A parte grande e imutável está nos arquivos JSON, está disponível para leitura sequencial e pode ser copiada para nós do servidor, garantindo o paralelismo de leitura. As convoluções calculadas na parte imutável são armazenadas em cache e, até a mudança, os pontos de imunidade também permanecem inalterados (isto é, replicados).
  • A parte mutável menor são os dados atuais (em termos de contabilidade - o período atual), nos quais é possível editar e cancelar documentos (mas não excluir), inserir e reorganizar retroativamente relacionamentos (por exemplo, correspondência de recebimentos com despesas, recálculo de custos etc.) .). Os dados mutáveis ​​são carregados na memória como um todo, o que fornece um cálculo rápido de convolução e um mecanismo transacional relativamente simples.

3. Convolução . Devido à falta de semântica JOIN, a linguagem SQL é inadequada e todos os algoritmos são gravados no estilo funcional de filtro / redução, também existem gatilhos (manipuladores de eventos) para certos tipos de documentos. O cálculo de filtro / redução é chamado de convolução. O algoritmo de convolução para o desenvolvedor de aplicativos parece uma passagem completa no diário de documentos, no entanto, o kernel realiza otimização durante a execução - o resultado intermediário calculado da parte imutável é retirado do cache e depois “contado” da parte mutável. Assim, a partir do segundo lançamento, a convolução é calculada inteiramente em RAM, o que leva frações de segundo em um milhão de documentos (mostraremos isso com exemplos). A convolução é contada a cada chamada, pois é muito difícil rastrear todas as alterações em documentos mutáveis ​​(abordagem imperativo-reativa), e os cálculos na RAM são baratos, e o código do usuário com essa abordagem é bastante simplificado. Uma convolução pode usar os resultados de outras convoluções, extraindo documentos por ID e pesquisando documentos no cache superior por chave.

4. Versão do documento e armazenamento em cache . Cada documento possui uma chave exclusiva e um ID exclusivo (chave + registro de data e hora). Os documentos com a mesma chave são organizados em um grupo, cujo último registro é atual (atual) e o restante é histórico.

Um cache é tudo o que pode ser excluído e restaurado novamente no diário de documentos quando o banco de dados é iniciado. Nosso sistema possui 3 caches:

  • Cache de documentos com acesso ao ID. Normalmente, são diretórios e documentos semi-permanentes, como diários de taxas de despesa. O atributo de armazenamento em cache (sim / não) está vinculado ao tipo de documento, o cache é inicializado no primeiro início do banco de dados e, em seguida, suportado pelo kernel.
  • Cache superior de documentos com acesso chave. Armazena as versões mais recentes de entradas de diretório e registradores instantâneos (por exemplo, saldos e saldos). O sinal da necessidade de armazenamento em cache superior está vinculado ao tipo de documento; o cache superior é atualizado pelo kernel ao criar / modificar qualquer documento.
  • O cache de convolução calculado a partir da parte imutável do banco de dados é uma coleção de pares de chave / valor. A chave de convolução é uma representação em cadeia do código do algoritmo + valor inicial serializado do acumulador (no qual os parâmetros de cálculo de entrada são transmitidos), e o resultado da convolução é o valor final serializado do acumulador (pode ser um objeto ou coleção complexo).

Armazenamento de saldos


Passamos ao tópico atual do artigo - o armazenamento de resíduos. A primeira coisa que vem à mente é implementar o restante como uma convolução, cujo parâmetro de entrada será uma combinação de analistas (por exemplo, nomenclatura + armazém + lote). No entanto, no ERP, precisamos calcular o custo, para o qual é necessário comparar os custos com os saldos (algoritmos FIFO, FIFO de lote, média de armazém - em teoria, podemos calcular o custo de qualquer combinação de analistas). Em outras palavras, precisamos do restante como uma entidade independente e, como tudo é um documento em nosso sistema, o restante também é um documento.

Um documento com o tipo “saldo” é gerado pelo acionador no momento do lançamento de linhas de documentos de compra / venda / movimentação, etc. A chave de saldo é uma combinação de analistas, os saldos com a mesma chave formam um grupo histórico, cujo último elemento é armazenado no cache superior e disponível instantaneamente. Os saldos não são lançamentos e, portanto, não são resumidos - o último registro é relevante e os primeiros registros mantêm um histórico.

O saldo armazena a quantidade em unidades de armazenamento e o valor na moeda principal e dividindo a segunda na primeira - obtemos o custo instantâneo na interseção do analista. Assim, o sistema armazena não apenas o histórico completo de resíduos, mas também o histórico completo de custos, que é uma vantagem para a auditoria dos resultados. O saldo é leve, o número máximo de saldos é igual ao número de linhas de documentos (na verdade, menor se as linhas são agrupadas por combinações de analistas), o número de entradas de saldo superior não depende do volume do banco de dados e é determinado pelo número de combinações de analistas envolvidas no controle de saldos e no cálculo de custos, portanto, o tamanho Nosso cache principal é sempre previsível.

Post Consumíveis


Inicialmente, os saldos são formados por documentos de recebimento do tipo "compra" e são ajustados por quaisquer documentos de despesa. Por exemplo, um gatilho para um documento de vendas faz o seguinte:

  • extrai o saldo atual do cache superior
  • verifica a disponibilidade da quantidade
  • salva um link para o saldo atual na linha do documento e o custo instantâneo
  • gera um novo balanço patrimonial com valor e valor reduzidos

Um exemplo de mudança de saldo ao vender

 //    { "type": "bal", "key": "bal|nomen.0|stock.0", "id": "bal|nomen.0|stock.0^1580006158787", "qty": 11209, //  "val": 1392411.5073958784 //  } //  "" { "type": "sale", "key": "sale.XXX", "id": "sale.XXX^1580006184280", "date": "2020-01-21", "person": "person.0^1580006048190", "stock": "stock.0^1580006048190", "lines": [ { "nomen": "nomen.0^1580006048190", "qty": 20, "price": 295.5228788368553, //   "cost": 124.22263425781769, //  "from": "bal|nomen.0|stock.0^1580006158787" // - } ] } //    { "type": "bal", "key": "bal|nomen.0|stock.0", "id": "bal|nomen.0|stock.0^1580006184281", "qty": 11189, "val": 1389927.054710722 } 

Código da classe do manipulador de documentos TypeScript

 import { Document, DocClass, IDBCore } from '../core/DBMeta.ts' export default class Sale extends DocClass { static before_add(doc: Document, db: IDBCore): [boolean, string?] { let err = '' doc.lines.forEach(line => { const key = 'bal' + '|' + db.key_from_id(line.nomen) + '|' + db.key_from_id(doc.stock) const bal = db.get_top(key, true) // true -  ,    - const bal_qty = bal?.qty ?? 0 //   const bal_val = bal?.val ?? 0 //   if (bal_qty < line.qty) { err += '\n"' + key + '": requested ' + line.qty + ' but balance is only ' + bal_qty } else { line.cost = bal_val / bal_qty //     line.from = bal.id } }) return err !== '' ? [false, err] : [true,] } static after_add(doc: Document, db: IDBCore): void { doc.lines.forEach(line => { const key = 'bal' + '|' + db.key_from_id(line.nomen) + '|' + db.key_from_id(doc.stock) const bal = db.get_top(key, true) const bal_qty = bal?.qty ?? 0 const bal_val = bal?.val ?? 0 db.add_mut( { type: 'bal', key: key, qty: bal_qty - line.qty, val: bal_val - line.cost * line.qty // cost   before_add() } ) }) } } 

Obviamente, seria possível não armazenar o custo diretamente nas linhas de despesa, mas tomá-lo como referência no balanço, mas o fato é que os saldos são documentos, existem muitos, é impossível armazenar em cache tudo, e obter um documento por ID lendo um disco é caro ( como indexar arquivos seqüenciais para acesso rápido - da próxima vez, digo).

O principal problema que os comentaristas apontaram é o desempenho do sistema, e temos tudo para medir quantidades de dados relativamente relevantes.

Geração de dados de origem


Nosso sistema consistirá em 5.000 contraagentes (fornecedores e clientes), 3.000 itens, 50 armazéns e 100 mil documentos de cada tipo - compra, transferência, venda. Os documentos são gerados aleatoriamente, uma média de 8,5 linhas por documento. As linhas de compra e venda geram uma transação (e um saldo) e duas linhas de movimento, resultando em 300 mil documentos principais, gerando cerca de 3,4 milhões de transações, o que é consistente com o volume mensal do ERP provincial. Geramos a parte mutável da mesma maneira, apenas com um volume 10 vezes menor.

Geramos os documentos com um script . Vamos começar com as compras, durante o restante dos documentos, o gatilho verificará o saldo na interseção do item e do armazém e, se pelo menos uma linha não passar, o script tentará gerar um novo documento. Os saldos são criados automaticamente por gatilhos, o número máximo de combinações de analistas é igual ao número de nomenclaturas * número de armazéns, ou seja, 150k.

Tamanho do banco de dados e cache


Após a conclusão do script, veremos as seguintes métricas do banco de dados:

  • parte imutável: documentos de 3,7kk (300k primário, o restante equilibra) - arquivo 770 Mb
  • parte mutável: 370k documentos (30k primário, o restante equilibra) - arquivo 76 Mb
  • cache superior de documentos: 158k documentos (referências + fatia atual de saldos) - arquivo 20 Mb
  • cache de documentos: 8,8k documentos (somente diretórios) - arquivo <1 Mb

Benchmarking


Inicialização da base. Na ausência de arquivos de cache, o banco de dados na primeira inicialização implementa uma verificação completa:

  • arquivo de dados imutáveis ​​(preenchimento de caches para tipos de documentos armazenados em cache) - 55 seg
  • arquivo de dados mutáveis ​​(carregando dados inteiros na memória e atualizando o cache superior) - 6 seg

Quando existem caches, aumentar a base é mais rápido:

  • arquivo de dados mutáveis ​​- 6 seg
  • arquivo de cache superior - 1,8 s
  • outros caches - menos de 1 segundo

Qualquer convolução do usuário (por exemplo, pegue o script para construir a folha de rotatividade) na primeira chamada inicia uma varredura do arquivo imutável e os dados mutáveis ​​já são varridos na RAM:

  • arquivo de dados imutáveis ​​- 55 seg
  • matriz mutável na memória - 0,2 s

Nas chamadas subseqüentes, quando os parâmetros de entrada corresponderem, reduza () retornará o resultado em 0,2 segundos , enquanto faz o seguinte a cada vez:

  • extrair o resultado do cache de redução por chave (levando em consideração os parâmetros)
  • digitalização de matriz mutável na memória (documentos de 370k )
  • "Contando" o resultado aplicando o algoritmo de convolução a documentos filtrados ( 20k )

Os resultados são bastante atraentes para esses volumes de dados, meu laptop de núcleo único, a completa ausência de qualquer DBMS (não esquecemos que isso é apenas um protótipo) e um algoritmo de uma passagem na linguagem TypeScript (que ainda é considerada uma escolha frívola para empresas). aplicativos de back-end).

Otimização técnica


Depois de examinar o desempenho do código, descobri que mais de 80% do tempo é gasto lendo o arquivo e analisando Unicode, ou seja, File.read () e TextDecoder (). Decode () . Além disso, a interface de arquivo de alto nível no Deno é apenas assíncrona e, como descobri recentemente , o preço do async / waitit é muito alto para a minha tarefa. Portanto, eu tive que escrever meu próprio leitor síncrono e, sem realmente me preocupar com otimizações, para aumentar a velocidade da leitura pura em 3 vezes ou, se você contar com a análise JSON - em 2 vezes, ao mesmo tempo em que se livrava globalmente da assincronização. Talvez essa peça precise ser reescrita em baixo nível (ou talvez em todo o projeto). A gravação de dados no disco também é inaceitavelmente lenta, embora isso seja menos crítico para o protótipo.

Passos adicionais


1. Demonstre a implementação dos seguintes algoritmos ERP em um estilo funcional:

  • gestão de reservas e necessidades em aberto
  • planejamento da cadeia de suprimentos
  • cálculo dos custos de produção, levando em consideração os custos indiretos

2. Alterne para o formato de armazenamento binário, talvez isso acelere a leitura do arquivo. Ou até mesmo colocar tudo em Mongo.

3. Transfira o FuncDB no modo multiusuário. De acordo com o princípio CQRS , a leitura é realizada diretamente pelos nós do servidor nos quais os arquivos imutáveis ​​do banco de dados são copiados (ou revirados pela rede), e a gravação é realizada através de um único ponto REST que gerencia dados, caches e transações mutáveis.

4. Aceleração do recebimento de qualquer documento não armazenado em cache por ID devido à indexação de arquivos seqüenciais (o que naturalmente viola nosso conceito de algoritmos de passagem única, mas a presença de qualquer possibilidade é sempre melhor do que sua ausência).

Sumário


Até agora, não encontrei um único motivo para abandonar a ideia de um SGBD / ERP funcional, porque, apesar da não universalidade de um SGBD para uma tarefa específica (contabilidade e planejamento), temos a chance de obter um aumento múltiplo na escalabilidade, audibilidade e confiabilidade do sistema de destino - tudo graças à observância do básico princípios do FP.

Código completo do projeto

Se alguém quiser jogar por conta própria:

  • instalar deno
  • clonar o repositório
  • execute o script de geração do banco de dados com controle de resíduos (generate_sample_database_with_balanses.ts)
  • executar scripts dos exemplos 1..4 na pasta raiz
  • crie seu próprio exemplo, codifique, teste e me dê um feedback

PS
A saída do console foi projetada para Linux, talvez no Windows as sequências esc não funcionem corretamente, mas não tenho nada para verificar :)

Obrigado pela atenção.

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


All Articles