En el camino hacia un DBMS funcional y NoSQL ERP: almacenamiento de saldos y costos

Hola Habr!

Continuamos estudiando la aplicabilidad de los principios de programación funcional en el diseño de ERP. En el artículo anterior, hablamos sobre por qué esto es necesario, sentamos las bases de la arquitectura y demostramos la construcción de convoluciones simples usando el ejemplo de una declaración inversa. De hecho, se propone el enfoque de abastecimiento de eventos , pero debido a la separación de la base de datos en las partes inmutables y mutables, obtenemos en un sistema una combinación de las ventajas de un mapa / reducir el almacenamiento y un DBMS en memoria, que resuelve tanto el problema de rendimiento como el problema de escalabilidad. En este artículo explicaré (y mostraré un prototipo en tiempo de ejecución de TypeScript y Deno ) cómo almacenar registros de saldos instantáneos en dicho sistema y calcular el costo. Para aquellos que no han leído el primer artículo, un breve resumen:

1. Diario de documentos . Un ERP construido sobre la base de un RDBMS es un gran estado mutable con acceso competitivo, por lo tanto, no es escalable, débilmente audible y poco confiable en operación (permite inconsistencia de datos). En el ERP funcional, todos los datos se organizan en forma de un diario ordenado cronológicamente de documentos primarios inmutables, y no hay nada más que estos documentos. Los enlaces se resuelven de documentos nuevos a documentos antiguos por ID completa (y nunca al revés), y todos los demás datos (saldos, registros, comparaciones) son convoluciones calculadas, es decir, resultados en caché de funciones puras en el flujo de documentos. La falta de estado + audibilidad de las funciones nos brinda una mayor confiabilidad (la cadena de bloques se adapta perfectamente a este esquema), y como beneficio adicional obtenemos una simplificación del esquema de almacenamiento + caché adaptativo en lugar de duro (organizado en base a tablas).

Así es como se ve el fragmento de datos en nuestro 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. Inmunidad y mutabilidad . El diario de documentos se divide en 2 partes desiguales:

  • La parte grande e inmutable se encuentra en los archivos JSON, está disponible para lectura secuencial y se puede copiar a los nodos del servidor, lo que garantiza la concurrencia de la lectura. Las circunvoluciones calculadas en la parte inmutable se almacenan en caché, y hasta el cambio, los puntos de inmunidad también permanecen sin cambios (es decir, replicados).
  • La parte mutable más pequeña son los datos actuales (en términos de contabilidad, el período actual), donde puede editar y cancelar documentos (pero no eliminarlos), insertar y reorganizar relaciones retroactivamente (por ejemplo, hacer coincidir los recibos con los gastos, recalcular los costos, etc. .). Los datos mutables se cargan en la memoria como un todo, lo que proporciona un cálculo de convolución rápido y un mecanismo transaccional relativamente simple.

3. Convolución . Debido a la falta de semántica JOIN, el lenguaje SQL no es adecuado, y todos los algoritmos están escritos en el estilo funcional de filtro / reducción, también hay activadores (controladores de eventos) para ciertos tipos de documentos. El cálculo de filtro / reducción se llama convolución. El algoritmo de convolución para el desarrollador de la aplicación parece un paso completo a través del diario de documentos, sin embargo, el núcleo realiza la optimización durante la ejecución: el resultado intermedio calculado a partir de la parte inmutable se toma del caché y luego se "cuenta" desde la parte mutable. Por lo tanto, a partir del segundo lanzamiento, la convolución se calcula completamente en RAM, que toma fracciones de segundo en un millón de documentos (lo mostraremos con ejemplos). La convolución se cuenta en cada llamada, ya que es muy difícil rastrear todos los cambios en documentos mutables (enfoque imperativo-reactivo), y los cálculos en la RAM son baratos, y el código de usuario con este enfoque se simplifica enormemente. Una convolución puede usar los resultados de otras convoluciones, extraer documentos por ID y buscar documentos en la caché superior por clave.

4. Versiones de documentos y almacenamiento en caché . Cada documento tiene una clave única y una ID única (clave + marca de tiempo). Los documentos con la misma clave se organizan en un grupo, cuyo último registro es actual (actual) y el resto son históricos.

Un caché es todo lo que se puede eliminar y se restaura nuevamente del diario de documentos cuando se inicia la base de datos. Nuestro sistema tiene 3 cachés:

  • Caché de documentos con acceso de identificación. Por lo general, se trata de directorios y documentos semipermanentes, como los diarios de tasa de gastos. El atributo de almacenamiento en caché (sí / no) está vinculado al tipo de documento, el caché se inicializa en el primer inicio de la base de datos y luego es compatible con el núcleo.
  • Caché superior de documentos con acceso clave. Almacena las últimas versiones de entradas de directorio y registros instantáneos (por ejemplo, saldos y saldos). El signo de la necesidad de almacenamiento en caché superior está vinculado al tipo de documento, el núcleo actualiza el caché superior al crear / modificar cualquier documento.
  • El caché de convolución calculado a partir de la parte inmutable de la base de datos es una colección de pares clave / valor. La clave de convolución es una representación de cadena del código del algoritmo + valor inicial serializado del acumulador (en el que se transmiten los parámetros de cálculo de entrada), y el resultado de la convolución es el valor final serializado del acumulador (puede ser un objeto o colección compleja).

Almacenamiento de saldos


Pasamos al tema real del artículo: el almacenamiento de residuos. Lo primero que viene a la mente es implementar el resto como una convolución, cuyo parámetro de entrada será una combinación de analistas (por ejemplo, nomenclatura + almacén + lote). Sin embargo, en ERP debemos considerar el precio de costo, para lo cual es necesario comparar los costos con los saldos (algoritmos FIFO, FIFO por lotes, promedio de almacén; en teoría, podemos promediar el costo para cualquier combinación de analistas). En otras palabras, necesitamos el resto como una entidad independiente, y dado que todo es un documento en nuestro sistema, el resto también es un documento.

El activador genera un documento con el tipo "saldo" en el momento de la publicación de líneas de documentos de compra / venta / movimiento, etc. Balance Key es una combinación de analistas, los balances con la misma clave forman un grupo histórico, cuyo último elemento se almacena en la caché superior y está disponible al instante. Los saldos no son contabilizaciones y, por lo tanto, no se resumen: el último registro es relevante y los primeros registros mantienen un historial.

El saldo almacena la cantidad en unidades de almacenamiento y la cantidad en la moneda principal, y dividiendo la segunda en la primera, obtenemos el costo instantáneo en la intersección del analista. Por lo tanto, el sistema almacena no solo el historial completo de los residuos, sino también el historial completo de los costos, lo cual es una ventaja para la auditoría de los resultados. El saldo es liviano, el número máximo de saldos es igual al número de líneas de documentos (en realidad menor si las líneas se agrupan por combinación de analistas), el número de registros de saldo superior no depende del volumen de la base de datos y está determinado por el número de combinaciones de analistas involucrados en el control de saldos y el cálculo de costos, por lo que el tamaño Nuestro caché superior siempre es predecible.

Publicar consumibles


Inicialmente, los saldos se forman mediante documentos de recibo del tipo de "compra" y se ajustan por cualquier documento de gastos. Por ejemplo, un activador para un documento de ventas hace lo siguiente:

  • extrae el saldo actual del caché superior
  • comprueba disponibilidad de cantidad
  • guarda un enlace al saldo actual en la línea del documento y el costo instantáneo
  • genera un nuevo balance con una cantidad y cantidad reducidas

Un ejemplo de cambio de saldo al 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 de clase de controlador 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() } ) }) } } 

Por supuesto, sería posible no almacenar el costo directamente en las líneas de gastos, sino tomarlo como referencia del balance general, pero el hecho es que los saldos son documentos, hay muchos, es imposible almacenar todo en caché, y obtener un documento por ID al leerlo desde el disco es costoso ( cómo indexar archivos secuenciales para un acceso rápido: te lo diré la próxima vez).

El principal problema que los comentaristas señalaron es el rendimiento del sistema, y ​​tenemos todo para medirlo en cantidades de datos relativamente relevantes.

Generación de datos fuente


Nuestro sistema constará de 5,000 contrapartes (proveedores y clientes), 3,000 artículos, 50 almacenes y 100k documentos de cada tipo: compra, transferencia, venta. Los documentos se generan aleatoriamente, un promedio de 8.5 líneas por documento. Las líneas de compra y venta generan una transacción (y un saldo), y dos líneas de movimiento, lo que da como resultado 300k documentos primarios generan aproximadamente 3,4 millones de transacciones, lo que es consistente con el volumen mensual de ERP provincial. Generamos la parte mutable de la misma manera, solo con un volumen de 10 veces menos.

Generamos los documentos con un script . Comencemos con las compras, durante el resto de los documentos, el activador verificará el saldo en la intersección del artículo y el almacén, y si al menos una línea no pasa, el script intentará generar un nuevo documento. Los saldos se crean automáticamente por disparadores, el número máximo de combinaciones de analistas es igual al número de nomenclaturas * número de almacenes, es decir 150k.

DB y tamaño de caché


Una vez que finalice el script, veremos las siguientes métricas de la base de datos:

  • parte inmutable: 3.7kk documentos (300k primaria, el resto saldos) - archivo 770 Mb
  • parte mutable: 370k documentos (30k primaria, saldos restantes) - archivo 76 Mb
  • caché superior de documentos: 158k documentos (referencias + segmento actual de saldos) - archivo 20 MB
  • caché de documentos: 8.8k documentos (solo directorios) - archivo <1 Mb

Benchmarking


Inicialización de la base. En ausencia de archivos de caché, la base de datos en el primer inicio implementa una exploración completa:

  • archivo de datos inmutable (cachés de relleno para tipos de documentos en caché): 55 segundos
  • archivo de datos mutables (cargando datos completos en la memoria y actualizando la caché superior) - 6 segundos

Cuando existen cachés, elevar la base es más rápido:

  • archivo de datos mutables - 6 segundos
  • archivo de caché superior: 1,8 segundos
  • otros cachés: menos de 1 segundo

Cualquier convolución del usuario (por ejemplo, tome el script para construir la hoja de facturación) en la primera llamada inicia un escaneo del archivo inmutable, y los datos mutables ya se escanean en la RAM:

  • archivo de datos inmutable - 55 segundos
  • matriz mutable en memoria - 0.2 segundos

En llamadas posteriores, cuando los parámetros de entrada coinciden, reduce () devolverá el resultado en 0.2 segundos , mientras hace lo siguiente cada vez:

  • extraer el resultado del caché reducido por clave (teniendo en cuenta los parámetros)
  • Escaneo de matriz mutable en memoria ( 370k documentos)
  • "Contando" el resultado aplicando el algoritmo de convolución a documentos filtrados ( 20k )

Los resultados son bastante atractivos para tales volúmenes de datos, mi computadora portátil de un solo núcleo, la ausencia total de cualquier DBMS (no olvidemos que esto es solo un prototipo) y un algoritmo de un solo paso en el lenguaje TypeScript (que todavía se considera una opción frívola para la empresa) aplicaciones de fondo).

Optimización técnica


Después de examinar el rendimiento del código, descubrí que más del 80% del tiempo se pasa leyendo el archivo y analizando Unicode, es decir, File.read () y TextDecoder (). Decode () . Además, la interfaz de archivo de alto nivel en Deno es solo asíncrona y, como descubrí recientemente , el precio de async / wait es demasiado alto para mi tarea. Por lo tanto, tuve que escribir mi propio lector síncrono, y sin realmente molestarme con las optimizaciones, para aumentar la velocidad de la lectura pura en 3 veces o, si cuenta con el análisis JSON, en 2 veces, al mismo tiempo, de forma global, eliminé la asincronización. Quizás esta pieza necesita ser reescrita a bajo nivel (o tal vez todo el proyecto). Escribir datos en el disco también es inaceptablemente lento, aunque esto es menos crítico para el prototipo.

Pasos adicionales


1. Demuestre la implementación de los siguientes algoritmos ERP en un estilo funcional:

  • gestión de reservas y necesidades abiertas
  • planificación de la cadena de suministro
  • cálculo de los costos de producción teniendo en cuenta los costos generales

2. Cambie al formato de almacenamiento binario, quizás esto acelerará la lectura del archivo. O incluso poner todo en Mongo.

3. Transfiera FuncDB en modo multiusuario. De acuerdo con el principio CQRS , la lectura se realiza directamente por los nodos del servidor en los que se copian archivos de bases de datos inmutables (o se revuelven en la red), y la grabación se realiza a través de un único punto REST que gestiona datos mutables, cachés y transacciones.

4. Aceleración de la obtención de cualquier documento no almacenado en caché por ID debido a la indexación de archivos secuenciales (que, por supuesto, viola nuestro concepto de algoritmos de paso único, pero la presencia de cualquier posibilidad siempre es mejor que su ausencia).

Resumen


Hasta ahora, no he encontrado una sola razón para abandonar la idea de un DBMS / ERP funcional, porque a pesar de la no universalidad de tal DBMS para una tarea específica (contabilidad y planificación), tenemos la oportunidad de obtener un aumento múltiple en la escalabilidad, audibilidad y confiabilidad del sistema objetivo, todo gracias a la observancia de lo básico principios de FP.

Código completo del proyecto

Si alguien quiere jugar solo:

  • instalar deno
  • clonar el repositorio
  • ejecutar el script de generación de base de datos con control de residuos (generate_sample_database_with_balanses.ts)
  • ejecutar scripts de ejemplos 1..4 en la carpeta raíz
  • inventa tu propio ejemplo, codifica, prueba y dame tu opinión

PS
La salida de la consola está diseñada para Linux, tal vez en Windows las secuencias esc no funcionen correctamente, pero no tengo nada que verificar :)

Gracias por su atencion

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


All Articles