Auf dem Weg zu einem funktionsfähigen DBMS und NoSQL ERP: Salden- und Kalkulationsspeicher

Hallo habr

Wir untersuchen weiterhin die Anwendbarkeit der Prinzipien der funktionalen Programmierung bei der Gestaltung von ERP. In einem früheren Artikel haben wir darüber gesprochen, warum dies notwendig ist, die Grundlagen der Architektur gelegt und die Konstruktion einfacher Windungen am Beispiel einer umgekehrten Aussage demonstriert. Tatsächlich wird der Event-Sourcing- Ansatz vorgeschlagen, aber aufgrund der Trennung der Datenbank in unveränderliche und veränderbare Teile erhalten wir in einem System eine Kombination der Vorteile einer Zuordnung / Reduzierung des Speichers und eines speicherinternen DBMS, wodurch sowohl das Leistungsproblem als auch das Skalierbarkeitsproblem gelöst werden. In diesem Artikel zeige ich (und zeige einen Prototyp für TypeScript- und Deno-Laufzeit ), wie Register von Sofortguthaben in einem solchen System gespeichert und Kosten berechnet werden. Für diejenigen, die den 1. Artikel noch nicht gelesen haben - eine kurze Zusammenfassung:

1. Journal of documents . Ein ERP, das auf der Basis eines RDBMS aufgebaut ist, ist ein riesiger veränderlicher Zustand mit wettbewerbsfähigem Zugriff, daher nicht skalierbar, schwach hörbar und unzuverlässig im Betrieb (es ermöglicht Dateninkonsistenz). Im funktionalen ERP sind alle Daten in Form eines chronologisch geordneten Journals mit unveränderlichen Primärdokumenten organisiert, und es gibt nichts anderes als diese Dokumente. Verknüpfungen werden von neuen zu alten Dokumenten durch vollständige ID aufgelöst (und niemals umgekehrt), und alle anderen Daten (Salden, Register, Vergleiche) sind berechnete Windungen, d. H. Zwischengespeicherte Ergebnisse von reinen Funktionen im Dokumentenfluss. Das Fehlen von Status + Hörbarkeit von Funktionen erhöht die Zuverlässigkeit (die Blockchain passt perfekt zu diesem Schema), und als Bonus erhalten wir eine Vereinfachung des Speicherschemas + adaptiver Cache anstelle von fest (auf der Basis von Tabellen organisiert).

So sieht das Datenfragment in unserem ERP aus
//   { "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ät und Veränderlichkeit . Das Journal of Documents ist in 2 ungleiche Teile unterteilt:

  • Der große, unveränderliche Teil befindet sich in den JSON-Dateien, ist für sequenzielles Lesen verfügbar und kann auf Serverknoten kopiert werden, um eine gleichzeitige Lesung zu gewährleisten. Die auf dem unveränderlichen Teil berechneten Windungen werden zwischengespeichert, und bis zur Verschiebung bleiben auch die Immunitätspunkte unverändert (d. H. Repliziert).
  • Der kleinere veränderbare Teil sind die aktuellen Daten (in Bezug auf die Buchhaltung - die aktuelle Periode), in denen Sie Dokumente bearbeiten und stornieren (aber nicht löschen), Beziehungen nachträglich einfügen und neu organisieren können (z. B. Belege mit Ausgaben abgleichen, Kosten neu berechnen usw.). .). Veränderliche Daten werden als Ganzes in den Speicher geladen, wodurch eine schnelle Faltungsberechnung und ein relativ einfacher Transaktionsmechanismus bereitgestellt werden.

3. Faltung . Aufgrund der fehlenden JOIN-Semantik ist die SQL-Sprache ungeeignet und alle Algorithmen sind im Filter / Reduce-Funktionsstil geschrieben. Für bestimmte Dokumenttypen gibt es auch Trigger (Event-Handler). Die Filter- / Reduktionsberechnung wird Faltung genannt. Der Faltungsalgorithmus für den Anwendungsentwickler sieht aus wie ein vollständiger Durchlauf durch das Dokumentjournal. Der Kernel führt jedoch während der Ausführung eine Optimierung durch. Das aus dem unveränderlichen Teil berechnete Zwischenergebnis wird aus dem Cache entnommen und dann aus dem veränderlichen Teil „gezählt“. Ab dem zweiten Start wird die Faltung also vollständig im Arbeitsspeicher berechnet, was Bruchteile von Sekunden auf einer Million Dokumente dauert (wir werden dies anhand von Beispielen zeigen). Die Faltung wird bei jedem Aufruf gezählt, da es sehr schwierig ist, alle Änderungen in veränderlichen Dokumenten zu verfolgen (imperativ-reaktiver Ansatz), und die Berechnungen im RAM sind billig, und der Benutzercode mit diesem Ansatz wird stark vereinfacht. Eine Faltung kann die Ergebnisse anderer Faltungen verwenden, um Dokumente nach ID zu extrahieren und nach Schlüsseln im oberen Cache nach Dokumenten zu suchen.

4. Versionierung und Zwischenspeicherung von Dokumenten . Jedes Dokument hat einen eindeutigen Schlüssel und eine eindeutige ID (Schlüssel + Zeitstempel). Dokumente mit demselben Schlüssel werden in einer Gruppe organisiert, deren letzter Datensatz aktuell (aktuell) und der Rest historisch ist.

Ein Cache ist alles, was gelöscht werden kann und wird beim Starten der Datenbank aus dem Dokumentjournal wiederhergestellt. Unser System verfügt über 3 Caches:

  • Dokumenten-Cache mit ID-Zugriff. In der Regel handelt es sich dabei um Verzeichnisse und semipermanente Dokumente, z. B. Spesenabrechnungen. Das Caching-Attribut (Ja / Nein) ist an den Dokumenttyp gebunden, der Cache wird beim ersten Start der Datenbank initialisiert und dann vom Kernel unterstützt.
  • Oberster Cache von Dokumenten mit Schlüsselzugriff. Speichert die neuesten Versionen von Verzeichniseinträgen und Sofortregistern (z. B. Salden und Salden). Das Zeichen für die Notwendigkeit eines Top-Caches ist an den Dokumenttyp gebunden. Der Top-Cache wird vom Kernel beim Erstellen / Ändern eines Dokuments aktualisiert.
  • Der aus dem unveränderlichen Teil der Datenbank berechnete Faltungscache ist eine Sammlung von Schlüssel / Wert-Paaren. Der Faltungsschlüssel ist eine Zeichenfolgendarstellung des Algorithmuscodes + des serialisierten Anfangswerts des Akkumulators (in dem die Eingangsberechnungsparameter übertragen werden), und das Ergebnis der Faltung ist der serialisierte Endwert des Akkumulators (es kann sich um ein komplexes Objekt oder eine Sammlung handeln).

Aufbewahrung von Guthaben


Wir gehen weiter zum Thema des Artikels - der Lagerung von Rückständen. Das erste, was mir in den Sinn kommt, ist, den Rest als Faltung zu implementieren, deren Eingabeparameter eine Kombination von Analysten ist (z. B. Nomenklatur + Warehouse + Batch). In ERP müssen wir jedoch den Selbstkostenpreis berücksichtigen, für den ein Vergleich der Kosten mit den Salden erforderlich ist (FIFO-Algorithmen, Batch-FIFO, Lagerdurchschnitt - theoretisch können wir die Kosten für jede Kombination von Analysten berechnen). Mit anderen Worten, wir brauchen den Rest als unabhängige Einheit, und da alles ein Dokument in unserem System ist, ist der Rest auch ein Dokument.

Ein Beleg vom Typ "Saldo" wird vom Auslöser zum Zeitpunkt der Buchung von Belegzeilen für Kauf / Verkauf / Bewegung usw. erzeugt. Der Saldenschlüssel ist eine Kombination von Analysten. Salden mit demselben Schlüssel bilden eine historische Gruppe, deren letztes Element im obersten Cache gespeichert und sofort verfügbar ist. Salden sind keine Buchungen und werden daher nicht zusammengefasst. Der letzte Datensatz ist relevant, und die frühesten Datensätze führen einen Verlauf.

Die Waage speichert die Menge in Lagereinheiten und den Betrag in der Hauptwährung und teilt die zweite in die erste - wir erhalten die Sofortkosten an der Schnittstelle des Analytikers. Somit speichert das System nicht nur die vollständige Historie der Residuen, sondern auch die vollständige Historie der Kosten, was für die Überprüfung der Ergebnisse von Vorteil ist. Der Saldo ist leichtgewichtig, die maximale Anzahl der Salden entspricht der Anzahl der Belegzeilen (tatsächlich weniger, wenn die Zeilen nach Analystenkombinationen gruppiert werden), die Anzahl der Top-Saldendatensätze hängt nicht vom Volumen der Datenbank ab und wird durch die Anzahl der Analystenkombinationen bestimmt, die bei der Saldokontrolle und Kostenberechnung beteiligt sind Unser Top-Cache ist immer vorhersehbar.

Post-Verbrauchsmaterialien


Die Salden werden zunächst aus Belegdokumenten vom Typ „Einkauf“ gebildet und um etwaige Aufwandsbelege bereinigt. Ein Auslöser für einen Verkaufsbeleg führt beispielsweise Folgendes aus:

  • Extrahiert das aktuelle Guthaben aus dem oberen Cache
  • prüft Mengenverfügbarkeit
  • Speichert einen Link zum aktuellen Kontostand in der Belegzeile und den Sofortkosten
  • generiert eine neue Bilanz mit einem reduzierten Betrag und Betrag

Ein Beispiel für eine Veränderung des Gleichgewichts beim Verkauf

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

TypeScript Document Handler-Klassencode

 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() } ) }) } } 

Natürlich wäre es möglich, die Kosten nicht direkt in den Ausgabenzeilen zu speichern, sondern als Referenz aus der Bilanz zu übernehmen, aber die Tatsache ist, dass Salden Dokumente sind, es gibt viele von ihnen, es ist unmöglich, alles zwischenzuspeichern, und das Abrufen eines Dokuments nach ID durch Lesen von der Festplatte ist teuer ( wie man sequentielle Dateien für den schnellen Zugriff indiziert - ich sage es Ihnen beim nächsten Mal).

Das Hauptproblem, auf das Kommentatoren hingewiesen haben, ist die Systemleistung, und wir haben alles, um sie an relativ relevanten Datenmengen zu messen.

Generierung von Quelldaten


Unser System besteht aus 5.000 Gegenparteien (Lieferanten und Kunden), 3.000 Artikeln, 50 Lagern und 100.000 Dokumenten jeder Art - Kauf, Übertragung, Verkauf. Dokumente werden zufällig generiert, durchschnittlich 8,5 Zeilen pro Dokument. Einkaufs- und Verkaufslinien generieren eine Transaktion (und einen Saldo) und zwei Bewegungslinien, was dazu führt, dass 300.000 Primärdokumente etwa 3,4 Millionen Transaktionen generieren, was dem monatlichen Volumen von ERP in den Provinzen entspricht. Wir erzeugen den wandelbaren Teil auf die gleiche Weise, nur mit einem Volumen von 10 mal weniger.

Wir generieren die Dokumente mit einem Skript . Beginnen wir mit den Einkäufen, während der Rest der Dokumente überprüft der Trigger den Saldo an der Schnittstelle von Artikel und Lager. Wenn mindestens eine Zeile nicht erfolgreich ist, versucht das Skript, ein neues Dokument zu generieren. Salden werden automatisch durch Trigger erstellt. Die maximale Anzahl der Analystenkombinationen entspricht der Anzahl der Nomenklaturen * Anzahl der Lager, d. H. 150k.

DB- und Cache-Größe


Nach Abschluss des Skripts werden die folgenden Datenbankmetriken angezeigt:

  • unveränderlicher Teil: 3.7kk Dokumente (300k primär, Restguthaben ) - Datei 770 Mb
  • veränderlicher Teil: 370.000 Dokumente (30.000 Primärdokumente, Restguthaben) - Datei 76 MB
  • Oberer Dokumenten-Cache: 158.000 Dokumente (Referenzen + aktueller Kontostand ) - Datei 20 MB
  • Dokumenten-Cache: 8.8k Dokumente (nur Verzeichnisse) - Datei <1 Mb

Benchmarking


Initialisierung der Basis. Wenn keine Cache-Dateien vorhanden sind, führt die Datenbank beim ersten Start einen vollständigen Scan durch:

  • unveränderliche Datendatei (Füllen von Caches für zwischengespeicherte Dokumenttypen) - 55 Sek
  • veränderbare Datendatei (Laden der gesamten Daten in den Speicher und Aktualisieren des oberen Caches) - 6 Sek

Wenn Caches vorhanden sind, kann die Basis schneller angehoben werden:

  • veränderbare Datendatei - 6 Sek
  • Top-Cache-Datei - 1,8 Sek
  • andere Caches - weniger als 1 Sekunde

Jede Benutzerfaltung (zum Beispiel das Skript zum Erstellen des Umsatzberichts) beim ersten Aufruf startet einen Scan der unveränderlichen Datei, und veränderbare Daten werden bereits im RAM gescannt:

  • unveränderliche Datei - 55 Sek
  • veränderbares Array im Speicher - 0,2 Sek

Wenn bei nachfolgenden Aufrufen die Eingabeparameter übereinstimmen, gibt reduct () das Ergebnis in 0,2 Sekunden zurück , wobei jedes Mal Folgendes ausgeführt wird:

  • Extrahieren des Ergebnisses aus dem Reduce-Cache per Schlüssel (unter Berücksichtigung der Parameter)
  • Scannen eines veränderlichen Arrays im Speicher ( 370.000 Dokumente)
  • "Zählen" des Ergebnisses durch Anwendung des Faltungsalgorithmus auf gefilterte Dokumente ( 20k )

Die Ergebnisse sind für ein solches Datenvolumen, meinen Single-Core-Laptop, das völlige Fehlen von DBMS (wir vergessen nicht, dass dies nur ein Prototyp ist) und einen One-Pass-Algorithmus in der TypeScript-Sprache (der für Unternehmen immer noch als fragwürdige Wahl gilt) recht attraktiv. Backend-Anwendungen).

Technische Optimierung


Nachdem ich die Leistung des Codes untersucht hatte, stellte ich fest, dass mehr als 80% der Zeit damit verbracht wurde, die Datei zu lesen und Unicode zu analysieren, nämlich File.read () und TextDecoder (). Decode () . Außerdem ist die Dateischnittstelle auf hoher Ebene in Deno nur asynchron, und wie ich kürzlich herausgefunden habe, ist der Preis für async / await für meine Aufgabe zu hoch. Daher musste ich meinen eigenen synchronen Reader schreiben, ohne mich wirklich um Optimierungen zu kümmern, um die Geschwindigkeit des reinen Lesens um das Dreifache zu erhöhen, oder, wenn Sie mit JSON-Parsing rechnen - um das Zweifache. Gleichzeitig wurde die Asynchronisierung global beseitigt. Vielleicht muss dieses Stück auf niedriger Ebene umgeschrieben werden (oder vielleicht das ganze Projekt). Das Schreiben von Daten auf die Festplatte ist ebenfalls unannehmbar langsam, obwohl dies für den Prototypen weniger kritisch ist.

Weitere Schritte


1. Demonstrieren Sie die Implementierung der folgenden ERP-Algorithmen in einem funktionalen Stil:

  • Reservemanagement und offene Bedürfnisse
  • Supply-Chain-Planung
  • Berechnung der Produktionskosten unter Berücksichtigung der Gemeinkosten

2. Wechseln Sie in das Binärspeicherformat. Dies beschleunigt möglicherweise das Lesen der Datei. Oder setzen Sie sogar alles in Mongo.

3. Übertragen Sie FuncDB im Mehrbenutzermodus. Gemäß dem CQRS- Prinzip wird das Lesen direkt von Serverknoten ausgeführt, auf die unveränderliche Datenbankdateien kopiert (oder über das Netzwerk durchsucht) werden, und die Aufzeichnung wird über einen einzelnen REST-Punkt ausgeführt, der veränderbare Daten, Caches und Transaktionen verwaltet.

4. Beschleunigung des Erhalts eines nicht zwischengespeicherten Dokuments nach ID aufgrund der Indizierung von sequentiellen Dateien (was natürlich gegen unser Konzept von Single-Pass-Algorithmen verstößt, aber das Vorhandensein einer Möglichkeit ist immer besser als das Nichtvorhandensein).

Zusammenfassung


Bisher habe ich keinen einzigen Grund gefunden, die Idee eines funktionalen DBMS / ERP aufzugeben, denn trotz der Nichtuniversalität eines solchen DBMS für eine bestimmte Aufgabe (Rechnungswesen und Planung) haben wir die Chance, die Skalierbarkeit, Hörbarkeit und Zuverlässigkeit des Zielsystems um ein Vielfaches zu steigern - alles dank der Einhaltung der Grundvoraussetzungen Prinzipien der FP.

Vollständiger Projektcode

Wenn jemand alleine spielen möchte:

  • installiere deno
  • Klonen Sie das Repository
  • Führen Sie das Datenbankgenerierungsskript mit Residuen-Kontrolle aus (generate_sample_database_with_balanses.ts)
  • Führen Sie die Skripte der Beispiele 1..4 aus, die sich im Stammverzeichnis befinden
  • Überlegen Sie sich ein eigenes Beispiel, kodieren Sie, testen Sie und geben Sie mir Feedback

PS
Die Konsolenausgabe ist für Linux konzipiert, möglicherweise funktionieren Esc-Sequenzen unter Windows nicht richtig, aber ich habe nichts zu überprüfen :)

Vielen Dank für Ihre Aufmerksamkeit.

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


All Articles