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