En route vers un SGBD fonctionnel et un ERP NoSQL: stockage des soldes et chiffrage

Bonjour, Habr!

Nous continuons d'étudier l'applicabilité des principes de programmation fonctionnelle dans la conception d'ERP. Dans l' article précédent, nous avons expliqué pourquoi cela était nécessaire, jeté les bases de l'architecture et démontré la construction de convolutions simples en utilisant l'exemple d'une déclaration inverse. En fait, l'approche de sourcing d'événements est proposée, mais en raison de la séparation de la base de données en parties immuables et mutables, nous obtenons dans un système une combinaison des avantages d'une carte / réduire le stockage et d'un SGBD en mémoire, ce qui résout à la fois le problème de performances et le problème d'évolutivité. Dans cet article, je vais expliquer (et montrer un prototype sur l' environnement d'exécution TypeScript et Deno ) comment stocker des registres de soldes instantanés dans un tel système et calculer le coût. Pour ceux qui n'ont pas lu le 1er article - un bref résumé:

1. Journal des documents . Un ERP construit sur la base d'un SGBDR est un énorme état mutable avec un accès compétitif, il n'est donc pas évolutif, faiblement audible et peu fiable en fonctionnement (il permet l'incohérence des données). Dans l'ERP fonctionnel, toutes les données sont organisées sous la forme d'un journal chronologiquement ordonné de documents primaires immuables, et il n'y a rien d'autre que ces documents. Les liens sont résolus des nouveaux documents aux anciens par un ID complet (et jamais l'inverse), et toutes les autres données (soldes, registres, comparaisons) sont des convolutions calculées, c'est-à-dire des résultats mis en cache de fonctions pures sur le flux de documents. Le manque d'état + d'audibilité des fonctions nous donne une fiabilité accrue (la blockchain correspond parfaitement à ce schéma), et en bonus nous obtenons une simplification du schéma de stockage + cache adaptatif au lieu de dur (organisé sur la base de tableaux).

Voici à quoi ressemble le fragment de données dans notre ERP
//   { "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. Immunité et mutabilité . Le journal des documents est divisé en 2 parties inégales:

  • La grande partie immuable se trouve dans les fichiers JSON, est disponible pour la lecture séquentielle et peut être copiée sur les nœuds du serveur, garantissant la simultanéité de la lecture. Les convolutions calculées sur la partie immuable sont mises en cache, et jusqu'au décalage, les points d'immunité sont également inchangés (c'est-à-dire répliqués).
  • La plus petite partie modifiable correspond aux données actuelles (en termes de comptabilité - la période actuelle), où vous pouvez modifier et annuler des documents (mais pas les supprimer), insérer et réorganiser rétroactivement des relations (par exemple, faire correspondre les reçus aux dépenses, recalculer les coûts, etc.). .). Les données mutables sont chargées dans la mémoire dans son ensemble, ce qui permet un calcul de convolution rapide et un mécanisme transactionnel relativement simple.

3. Convolution . En raison du manque de sémantique JOIN, le langage SQL ne convient pas et tous les algorithmes sont écrits dans le style fonctionnel filtrer / réduire, il existe également des déclencheurs (gestionnaires d'événements) pour certains types de documents. Le calcul du filtre / réduction est appelé convolution. L'algorithme de convolution pour le développeur de l'application ressemble à un passage complet dans le journal des documents, cependant, le noyau fait une optimisation pendant l'exécution - le résultat intermédiaire calculé à partir de la partie immuable est pris dans le cache puis «compté» dans la partie mutable. Ainsi, à partir du deuxième lancement, la convolution est entièrement calculée en RAM, ce qui prend des fractions de seconde sur un million de documents (nous le montrerons avec des exemples). La convolution est comptée à chaque appel, car il est très difficile de suivre tous les changements dans les documents mutables (approche impérative-réactive), et les calculs dans la RAM sont bon marché, et le code utilisateur avec cette approche est grandement simplifié. Une convolution peut utiliser les résultats d'autres convolutions, extraire des documents par ID et rechercher des documents dans le cache supérieur par clé.

4. Versionnement et mise en cache des documents . Chaque document possède une clé unique et un identifiant unique (clé + horodatage). Les documents avec la même clé sont organisés en groupe, dont le dernier enregistrement est actuel (actuel) et les autres sont historiques.

Un cache est tout ce qui peut être supprimé et est à nouveau restauré à partir du journal des documents lorsque la base de données démarre. Notre système dispose de 3 caches:

  • Cache de documents avec accès ID. Il s'agit généralement de répertoires et de documents semi-permanents, tels que des journaux de taux de dépenses. L'attribut de mise en cache (oui / non) est lié au type de document, le cache est initialisé au premier démarrage de la base de données puis pris en charge par le noyau.
  • Cache supérieur des documents avec accès par clé. Stocke les dernières versions des entrées d'annuaire et des registres instantanés (par exemple, les soldes et les soldes). Le signe de la nécessité d'une mise en cache supérieure est lié au type de document, le cache supérieur est mis à jour par le noyau lors de la création / modification d'un document.
  • Le cache de convolution calculé à partir de la partie immuable de la base de données est une collection de paires clé / valeur. La clé de convolution est une représentation sous forme de chaîne du code de l'algorithme + la valeur initiale sérialisée de l'accumulateur (dans laquelle les paramètres de calcul d'entrée sont transmis), et le résultat de la convolution est la valeur finale sérialisée de l'accumulateur (il peut s'agir d'un objet ou d'une collection complexe).

Stockage des soldes


Nous passons au sujet réel de l'article - le stockage des résidus. La première chose qui me vient à l'esprit est d'implémenter le reste comme une convolution, dont le paramètre d'entrée sera une combinaison d'analystes (par exemple, nomenclature + entrepôt + lot). Cependant, dans l'ERP, nous devons considérer le prix de revient, pour lequel il est nécessaire de comparer les coûts avec les soldes (algorithmes FIFO, FIFO par lot, moyenne de l'entrepôt - théoriquement, nous pouvons faire la moyenne du coût pour n'importe quelle combinaison d'analystes). En d'autres termes, nous avons besoin du reste en tant qu'entité indépendante, et puisque tout est un document dans notre système, le reste est également un document.

Un document de type «solde» est généré par le déclencheur lors de la validation des lignes de documents d'achat / vente / mouvement, etc. La clé de solde est une combinaison d'analystes, les soldes avec la même clé forment un groupe historique, dont le dernier élément est stocké dans le cache supérieur et disponible instantanément. Les soldes ne sont pas des écritures et ne sont donc pas résumés - le dernier enregistrement est pertinent et les premiers enregistrements conservent un historique.

Le solde stocke la quantité dans les unités de stockage et le montant dans la devise principale, et en divisant la seconde en première - nous obtenons le coût instantané à l'intersection de l'analyste. Ainsi, le système stocke non seulement l'historique complet des résidus, mais également l'historique complet des coûts, ce qui est un plus pour l'audit des résultats. Le solde est léger, le nombre maximal de soldes est égal au nombre de lignes de documents (en fait moins si les lignes sont regroupées par combinaison d'analystes), le nombre d'enregistrements de solde supérieur ne dépend pas du volume de la base de données et est déterminé par le nombre de combinaisons d'analystes impliqués dans le contrôle des soldes et le calcul des coûts, donc la taille Notre cache supérieur est toujours prévisible.

Consommables


Initialement, les soldes sont constitués par des documents de réception de type «achat» et sont ajustés par tous les documents de dépenses. Par exemple, un déclencheur pour un document de vente effectue les opérations suivantes:

  • extrait le solde actuel du cache supérieur
  • vérifie la disponibilité des quantités
  • enregistre un lien vers le solde actuel dans la ligne du document et le coût instantané
  • génère un nouveau bilan avec un montant et un montant réduits

Un exemple de changement d'équilibre lors de la vente

 //    { "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 } 

Code de classe du gestionnaire de document 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() } ) }) } } 

Bien sûr, il serait possible de ne pas stocker le coût directement dans les lignes de dépenses, mais de le prendre par référence du bilan, mais le fait est que les soldes sont des documents, ils sont nombreux, il est impossible de tout mettre en cache, et obtenir un document par ID en lisant sur le disque coûte cher ( comment indexer des fichiers séquentiels pour un accès rapide - je vous le dirai la prochaine fois).

Le principal problème que les commentateurs ont souligné est la performance du système, et nous avons tout pour le mesurer sur des quantités de données relativement pertinentes.

Génération de données source


Notre système comprendra 5 000 contreparties (fournisseurs et clients), 3 000 articles, 50 entrepôts et 100 000 documents de chaque type - achat, transfert, vente. Les documents sont générés de manière aléatoire, en moyenne 8,5 lignes par document. Les lignes d'achat et de vente génèrent une transaction (et un solde), et deux lignes de mouvement, résultant en 300 000 documents primaires, génèrent environ 3,4 millions de transactions, ce qui correspond au volume mensuel de l'ERP provincial. Nous générons la partie mutable de la même manière, uniquement avec un volume 10 fois inférieur.

Nous générons les documents avec un script . Commençons par les achats, pendant le reste des documents, le déclencheur vérifiera le solde à l'intersection de l'article et de l'entrepôt, et si au moins une ligne ne passe pas, le script tentera de générer un nouveau document. Les soldes sont créés automatiquement par des déclencheurs, le nombre maximum de combinaisons d'analystes est égal au nombre de nomenclatures * nombre d'entrepôts, soit 150k.

Taille de la base de données et du cache


Une fois le script terminé, nous verrons les mesures de base de données suivantes:

  • partie immuable: documents de 3,7kk (300k primaire, les soldes restants) - fichier 770 Mb
  • partie mutable: 370k documents (30k primaire, les soldes restants) - fichier 76 Mb
  • cache supérieur des documents: 158k documents (références + tranche de soldes actuelle) - fichier 20 Mb
  • cache de documents: 8,8 k documents (répertoires uniquement) - fichier <1 Mo

Analyse comparative


Initialisation de la base. En l'absence de fichiers cache, la base de données au premier démarrage implémente une analyse complète:

  • fichier de données immuable (remplissage des caches pour les types de documents mis en cache) - 55 sec
  • fichier de données mutable (chargement de données entières dans la mémoire et mise à jour du cache supérieur) - 6 sec

Lorsque des caches existent, augmenter la base est plus rapide:

  • fichier de données mutable - 6 sec
  • fichier cache supérieur - 1,8 sec
  • autres caches - moins de 1 seconde

Toute convolution d'utilisateur (par exemple, prenez le script pour construire la feuille de chiffre d'affaires) lors du premier appel lance une analyse du fichier immuable, et les données mutables sont déjà analysées dans la RAM:

  • fichier de données immuable - 55 sec
  • tableau mutable en mémoire - 0,2 sec

Dans les appels suivants, lorsque les paramètres d'entrée correspondent, la fonction de réduction () renvoie le résultat en 0,2 seconde , tout en procédant comme suit à chaque fois:

  • extraire le résultat du cache réduit par clé (en tenant compte des paramètres)
  • numérisation d'un tableau mutable en mémoire ( 370k documents)
  • «Compter» le résultat en appliquant l'algorithme de convolution aux documents filtrés ( 20k )

Les résultats sont assez attrayants pour de tels volumes de données, mon ordinateur portable à cœur unique, l'absence complète de tout SGBD (nous n'oublions pas que ce n'est qu'un prototype) et un algorithme à un passage dans le langage TypeScript (qui est toujours considéré comme un choix frivole pour les entreprises). applications dorsales).

Optimisation technique


Après avoir examiné les performances du code, j'ai constaté que plus de 80% du temps est passé à lire le fichier et à analyser Unicode, à savoir File.read () et TextDecoder (). Decode () . De plus, l' interface de fichiers de haut niveau de Deno est uniquement asynchrone et, comme je l'ai découvert récemment , le prix de l' async / wait est trop élevé pour ma tâche. Par conséquent, j'ai dû écrire mon propre lecteur synchrone, et sans vraiment me soucier des optimisations, pour augmenter la vitesse de lecture pure de 3 fois, ou, si vous comptez avec l'analyse JSON - de 2 fois, en même temps, globalement, vous vous êtes débarrassé de l'asynchronisation. Peut-être que cette pièce doit être réécrite à bas niveau (ou peut-être l'ensemble du projet). L'écriture des données sur le disque est également trop lente, bien que cela soit moins critique pour le prototype.

Etapes supplémentaires


1. Démontrer l'implémentation des algorithmes ERP suivants dans un style fonctionnel:

  • gestion des réserves et besoins ouverts
  • planification de la chaîne d'approvisionnement
  • calcul des coûts de production en tenant compte des frais généraux

2. Passez au format de stockage binaire, cela accélérera peut-être la lecture du fichier. Ou même tout mettre à Mongo.

3. Transférez FuncDB en mode multi-utilisateur. Conformément au principe CQRS , la lecture est effectuée directement par les nœuds de serveur sur lesquels les fichiers de base de données immuables sont copiés (ou fouillés sur le réseau), et l'enregistrement est effectué via un seul point REST qui gère les données, les caches et les transactions mutables.

4. Accélération de l'obtention de tout document non mis en cache par ID en raison de l'indexation des fichiers séquentiels (ce qui viole bien sûr notre concept d'algorithmes en un seul passage, mais la présence de toute possibilité est toujours meilleure que son absence).

Résumé


Jusqu'à présent, je n'ai trouvé aucune raison d'abandonner l'idée d'un SGBD / ERP fonctionnel, car malgré la non-universalité d'un tel SGBD pour une tâche spécifique (comptabilité et planification), nous avons une chance d'obtenir une augmentation multiple de l'évolutivité, de l'audibilité et de la fiabilité du système cible - tout cela grâce au respect du système de base principes de la PF.

Code de projet complet

Si quelqu'un veut jouer seul:

  • installer deno
  • cloner le référentiel
  • exécuter le script de génération de base de données avec contrôle des résidus (generate_sample_database_with_balanses.ts)
  • exécuter des scripts d'exemples 1 à 4 situés dans le dossier racine
  • trouver votre propre exemple, encoder, tester et me donner des commentaires

PS
La sortie de la console est conçue pour Linux, peut-être que sous Windows, les séquences d'échappement ne fonctionneront pas correctement, mais je n'ai rien à vérifier :)

Merci de votre attention.

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


All Articles