На пути к функциональной СУБД и NoSQL ERP: хранение остатков и расчет себестоимости

в 11:41, , рубрики: Deno, ERP-системы, nosql, TypeScript, Анализ и проектирование систем, Программирование, функциональная ERP, Функциональная СУБД, функциональное программирование

Привет!

Продолжаем исследовать применимость принципов функционального программирования при проектировании ERP. В предыдущей статье мы рассказали зачем это нужно, заложили основы архитектуры, и продемонстрировали построение простых сверток на примере оборотной ведомости. По сути, предлагается подход event sourcing, но за счет разделения БД на иммутабельную и мутабельную часть, мы получаем в одной системе комбинацию преимуществ map / reduce-хранилища и in-memory СУБД, что решает как проблему производительности, так и проблему масштабируемости. В этой статье я расскажу (и покажу прототип на TypeScript и рантайме Deno), как в такой системе хранить регистры мгновенных остатков и рассчитывать себестоимость. Для тех, кто не читал 1-ю статью — краткое резюме:

1. Журнал документов. ERP, построенная на базе РСУБД представляет собой огромный мутабельный стейт с конкурентным доступом, поэтому не масштабируется, слабо-аудируема, и ненадежна в эксплуатации (допускает рассогласование данных). В функциональной ERP все данные организованы в виде хронологически-упорядоченного журнала иммутабельных первичных документов, и в ней нет ничего кроме этих документов. Связи разрешаются от новых документов к старым по полному ID (и никогда наоборот), а все остальные данные (остатки, регистры, сопоставления) являются вычисляемыми свертками, то есть кэшируемыми результами работы чистых функций на потоке документов. Отсутствие стейта + аудируемость функций дает нам повышенную надежность (блокчейн на эту схему прекрасно ложится), а бонусом мы получаем упрощение схемы хранения + адаптивный кэш вместо жесткого (организованного на базе таблиц).

Так выглядит фрагмент данных в нашей 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. Иммутабельность и мутабельность. Журнал документов делится на 2 неравные части:

— Большая по размеру иммутабельная часть лежит в файлах JSON, доступна для последовательного чтения, и может копироваться на серверные ноды, обеспечивая параллелизм чтения. Свертки, рассчитанные по иммутабельной части — кэшируются, и до момента сдвига точки иммутабельности также являются неизменными (т.е. реплицируемыми).

— Меньшая мутабельная часть, представляет собой собой текущие данные (в терминах учета — текущий период), где возможно редактирование и отмена документов (но не удаление), вставка задним числом и реорганизация связей (например, сопоставление приходов с расходами, пересчет себестоимости и т.д.). Мутабельные данные загружаются в память целиком, что обеспечивает быстрое вычисление сверток и относительно простой транзакционный механизм.

3. Свертки. Ввиду отсутствия семантики JOIN — язык SQL непригоден, и все алгоритмы пишутся в функциональном стиле filter / reduce, также имеются триггеры (обработчики событий) на отдельные типы документов. Вычисление filter / reduce назовем сверткой. Алгоритм свертки для прикладного разработчика выглядит как полный проход по журналу документов, однако ядро при исполнении делает оптимизацию — промежуточный результат, вычисленный по иммутабельной части, берется из кэша, а затем «досчитывается» по мутабельной части. Таким образом, начиная со второго запуска — свертка вычисляется целиком в оперативной памяти, что занимает доли секунд на миллионе документов (мы это покажем на примерах). Свертка досчитывается при каждом вызове, так как отследить все изменения в мутабельных документах (императивно-реактивный подход) очень сложно, а вычисления в оперативной памяти дешевы, и пользовательский код при таком подходе сильно упрощается. Свертка может использовать результаты других сверток, извлечение документов по ID, и поиск документов в топ-кэше по ключу.

4. Версионность документов и кэширование. Каждый документ имеет уникальный ключ и уникальный ID (ключ + таймштамп). Таким образом, документы с одинаковым ключом организованы в группу, последняя запись которой является текущей (актуальной), а остальные — историческими. Кэшем называется все, что может быть удалено, и снова восстановлено из журнала документов при старте БД. Наша система имеет 3 кэша:

— Кэш документов с доступом по ID. Обычно это справочники и условно-постоянные документы, например журналы норм расходов. Признак кэширования (да/нет) привязан к типу документа, кэш инициализируется при первом старте БД и далее поддерживается ядром.

— Топ-кэш документов с доступом по ключу. Хранит последние версии записей справочников и мгновенных регистров (например остатки и балансы). Признак необходимости топ-кэширования привязан к типу документа, топ-кэш обновляется ядром при создании / изменении любого документа.

— Кэш сверток, вычисленных по иммутабельной части БД представляет собой коллекцию пар ключ / значение. Ключ свертки — это строковое представление кода алгоритма + сериализованное начальное значение аккумулятора (в котором передаются входные параметры расчета), а результат свертки — сериализованное конечное значение аккумулятора (может быть сложным объектом или коллекцией).

Хранение остатков (балансов)

Переходим собственно к теме статьи — хранение остатков. Первое что приходит в голову — реализовать остаток как свертку, входным параметром которой будет комбинация аналитик хранения. Однако в ERP нам нужно считать себестоимость, для чего необходимо сопоставлять расходы с остатками (алгоритмы ФИФО, партионный ФИФО, среднее по складу — теоретически мы можем усреднять себестоимость по любой комбинации аналитик). Другими словами, остаток нам нужен как самостоятельная сущность, а поскольку в нашей системе все является документом — остаток это тоже документ с типом «баланс». Такой специальный документ формируется триггером в момент разноски строк документов покупки / продажи / перемещения, и т.д. Ключ баланса — это комбинация аналитик (например номенклатура + склад + партия), балансы с одинаковым ключом образуют историческую группу, последний элемент которой сохраняется в топ-кэше, и мгновенно-доступен. Балансы это не проводки, и поэтому не суммируются — последняя запись содержит актуальный баланс на текущий момент, а ранние записи хранят историю балансов. В балансе хранится количество в единицах хранения и сумма в основной валюте, разделив второе на первое мы получаем мгновенную себестоимость на пересечении аналитик. Таким образом, в системе хранится не только полная история остатков, но и полная история себестоимостей, что является плюсом для аудируемости результатов. Баланс легковесен, максимальное количество балансов равно количеству строк документов (реально меньше, если строки группируются по комбинациям аналитик), количество топ-записей баланса не зависит от объема БД, и определяется лишь количеством комбинаций аналитик, участвующих в контроле остатков и расчете себестоимости, таким образом размер нашего топ-кэша всегда прогнозируем.

Разноска расходных документов

Изначально балансы формируются приходными документами типа «покупка» и корректируются любыми расходными документами. К примеру, триггер документа «продажа» делает следующее:
— извлекает из топ-кэша текущий баланс
— проверяет доступность количества
— сохраняет в строке документа ссылку на текущий баланса и себестоимость
— формирует новый документ баланса с уменьшенным количеством и суммой

Пример изменения баланса при продаже

// предыдущая запись баланса
{
	"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

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

Конечно, можно было бы не хранить себестоимость прямо в расходных строках, а брать ее по ссылке из баланса, но дело в том, что балансы — это документы, их много, закэшировать все невозможно, а получать документ по ID чтением с диска — дорого (как индексировать текстовые JSON-файлы для быстрого доступа к любому документу — расскажу в след. раз).

Основная проблема, на которую указывали комментаторы — производителность системы, и у нас есть все чтобы померить ее на относительно релевантных объемах данных.

Генерация исходных данных

Наша система будет состоять из 5000 контрагентов (поставщики и клиенты), 3000 номенклатур, 50 складов, и по 100k документов каждого вида — покупки, перемещения, продажи. Документы генерируются случайным образом, в среднем по 8.5 строк на документ. Cтроки покупок и продаж порождают по одной транзакции (и одному балансу), а строки перемещения по две, в результате 300k первичных документов порождают около 3.4 миллиона транзакций, что вполне соответствует месячным объемам провинциальной ERP. Мутабельную часть генерируем аналогично, только в 10 раз меньше.

Генерацию документов выполняем скриптом. Начнем с покупок, при проведении остальных документов триггер проверит остаток на пересечении номенклатуры и склада, и если хотя бы одна строка не проходит — будет пытаться генерировать новый документ. Балансы создаются автоматически, триггерами, максимальное количество комбинаций аналитик равно кол-во номенклатур * кол-во складов, т.е. 150k.

Размер БД и кэшей

После завершения скрипта мы увидим следующие метрики базы:
— иммутабельная часть: 3.7kk документов (300k первичных, остальное балансы) — файл 770 Mb
— мутабельная часть: 370k документов (30k первичных, остальное балансы) — файл 76 Mb
— топ-кэш документов: 158k документов (справочники + текущий срез балансов) — файл 20 Mб
— кэш документов: 8.8k документов (только справочники) — файл < 1 Mb

Бенчмаркинг

Инициализация базы. При отсутствии кэш-файлов, база при первом запуске осуществляет скан:
— иммутабельного дата-файла (заполнение кэшей для кэшируемых типов документов) — 55 сек
— мутабельного дата-файла (загрузка данных целиком в память и обновление топ-кэша) — 6 сек
Когда кэши существуют, подъем базы происходит быстрее:
— мутабельный дата-файл — 6 сек
— файл топ-кэша — 1.8 сек
— остальные кэши — менее 1 сек

Любая пользовательская свертка (возьмем для примера скрипт построения оборотной ведомости) при первом вызове запускает скан иммутабельного файла, а мутабельные данные сканируются уже в оперативной памяти:
— иммутабельный дата-файл — 55 сек
— мутабельный массив в памяти — 0.2 сек

При последующих вызовах, при совпадении входных параметров — reduce() будет возвращать результат за 0.2 сек, при этом каждый раз делая следующее:
— извлечение результата из reduce-кэша по ключу (с учетом параметров)
— сканирование мутабельного массива (370k документов)
— «досчет» результата путем применения алгоритма свертки к отфильтрованным документам (20k)

Полученные результаты более чем привлекательные для таких объемов данных, моего старого одноядерного ноутбука, полного отсутствия какой-бы то ни было СУБД, и однопроходного алгоритма на языке TypeScript (который до сих пор считается несерьезным выбором для enterprise-backend приложений).

Технические оптимизации

Исследовав производительность кода, я обнаружил, что более 70% времени тратится на чтение файла и парсинг юникода, а именно TextDecoder().decode(). К тому же высокоуровневый файловый интерфейс в Deno только асинхронный, а как я недавно выяснил, цена async / await для моей задачи слишком велика. Поэтому пришлось написать собственный синхронный ридер, и не особо заморачиваясь с оптимизациями, увеличить скорость чистого чтения в 3 раза, или, если считать вместе с парсингом JSON — в 2 раза, Заодно глобально избавился от асинхронщины. Возможно, этот кусок нужно переписать низкоуровнево, но пока руки не доходят, и непонятно как внедрить нативный код в Deno / V8. Запись на диск также работает неприемлемо медленно, но это менее критичная проблема для прототипа.

Дальнейшие шаги

1. Продемонстрировать реализацию следующих алгоритмов ERP в функциональном стиле:
— управление резервами
— расчет себестоимости в производстве с учетом накладных расходов
— планирование логистических цепочек

2. Перевод FuncDB в многопользовательский режим. В соответствие с принципом CQRS — чтение осуществляется непосредственно серверными нодами, на которые копируются иммутабельные файлы БД (или шарятся по сети), а запись осуществляется через единую REST-точку, которая управляет мутабельными данными, кэшами и транзакциями.

3. Ускорение получения любого некэшированного документа по ID за счет индексирования последовательных файлов JSON (что конечно нарушает нашу концепцию однопроходных алгоритмов, но наличие любой возможности всегда лучше чем ее отсутствие).

Резюме

Пока я не обнаружил ни одной причины отказаться от идеи функциональной СУБД / ERP, тем более что цена вопроса (разработка ядра, пользовательских алгоритмов, и необходимые вычислительные мощности) выглядит весьма приемлемо, тогда как на выходе мы имеем шанс получить многократное повышение масштабируемости, аудируемости и надежности — даже в такой консервативной сфере как ERP-строение.

Полный код проекта

Если кто захочет поиграться самостоятельно:
— установить Deno
— склонировать репозитарий
— запустить скрипт генерации базы с контролем остатков (generate_sample_database_with_balanses.ts)
— запусить скрипты примеров 1..4, лежащих в корневой папке
— придумать свой пример, закодить, протестировать, и дать мне обратную связь
PS
Консольный вывод расчитан на Linux, возможно под Windows esc-последовательности будут работать некорректно, но мне не на чем это проверить :)

Спасибо за внимание.

Автор: Евгений Баладжа

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js