Привет, Хабр!

Продолжаем исследовать применимость принципов функционального программирования при проектировании 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 чтением с диска — дорого (как индексировать последовательные файлы для быстрого доступа — расскажу в след. раз).

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

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


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

Генерацию документов выполняем скриптом. Начнем с покупок, при проведении остальных документов триггер проверит остаток на пересечении номенклатуры и склада, и если хотя бы одна строка не проходит — скрипт будет пытаться cгенерировать новый документ. Балансы создаются автоматически, триггерами, максимальное количество комбинаций аналитик равно кол-во номенклатур * кол-во складов, т.е. 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 приложений).

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


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

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


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

  • управление резервами и открытыми потребностями
  • планирование логистических и производственных цепочек
  • расчет себестоимости в производстве с учетом накладных расходов

2. Перейти на бинарный формат хранения, возможно это ускорит чтение файла. Или вообще в Mongo все положить.

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

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

Резюме


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

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

Если кто захочет поиграться самостоятельно:

  • установить Deno
  • склонировать репозитарий
  • запустить скрипт генерации базы с контролем остатков (generate_sample_database_with_balanses.ts)
  • запустить скрипты примеров 1..4, лежащих в корневой папке
  • придумать свой пример, закодить, протестировать, и дать мне обратную связь

P.S.
Консольный вывод расчитан на Linux, возможно под Windows esc-последовательности будут работать некорректно, но мне не на чем это проверить :)

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