- PVSM.RU - https://www.pvsm.ru -
Привет!
Продолжаем исследовать применимость принципов функционального программирования при проектировании ERP. В предыдущей статье [1] мы рассказали зачем это нужно, заложили основы архитектуры, и продемонстрировали построение простых сверток на примере оборотной ведомости. По сути, предлагается подход event sourcing [2], но за счет разделения БД на иммутабельную и мутабельную часть, мы получаем в одной системе комбинацию преимуществ map / reduce-хранилища и in-memory СУБД, что решает как проблему производительности, так и проблему масштабируемости. В этой статье я расскажу (и покажу прототип на TypeScript и рантайме Deno [3]), как в такой системе хранить регистры мгновенных остатков и рассчитывать себестоимость. Для тех, кто не читал 1-ю статью — краткое резюме:
1. Журнал документов. ERP, построенная на базе РСУБД представляет собой огромный мутабельный стейт с конкурентным доступом, поэтому не масштабируется, слабо-аудируема, и ненадежна в эксплуатации (допускает рассогласование данных). В функциональной ERP все данные организованы в виде хронологически-упорядоченного журнала иммутабельных первичных документов, и в ней нет ничего кроме этих документов. Связи разрешаются от новых документов к старым по полному ID (и никогда наоборот), а все остальные данные (остатки, регистры, сопоставления) являются вычисляемыми свертками, то есть кэшируемыми результами работы чистых функций на потоке документов. Отсутствие стейта + аудируемость функций дает нам повышенную надежность (блокчейн на эту схему прекрасно ложится), а бонусом мы получаем упрощение схемы хранения + адаптивный кэш вместо жесткого (организованного на базе таблиц).
// справочник контрагентов
{
"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 раз меньше.
Генерацию документов выполняем скриптом [4]. Начнем с покупок, при проведении остальных документов триггер проверит остаток на пересечении номенклатуры и склада, и если хотя бы одна строка не проходит — будет пытаться генерировать новый документ. Балансы создаются автоматически, триггерами, максимальное количество комбинаций аналитик равно кол-во номенклатур * кол-во складов, т.е. 150k.
После завершения скрипта мы увидим следующие метрики базы:
— иммутабельная часть: 3.7kk документов (300k первичных, остальное балансы) — файл 770 Mb
— мутабельная часть: 370k документов (30k первичных, остальное балансы) — файл 76 Mb
— топ-кэш документов: 158k документов (справочники + текущий срез балансов) — файл 20 Mб
— кэш документов: 8.8k документов (только справочники) — файл < 1 Mb
Инициализация базы. При отсутствии кэш-файлов, база при первом запуске осуществляет скан:
— иммутабельного дата-файла (заполнение кэшей для кэшируемых типов документов) — 55 сек
— мутабельного дата-файла (загрузка данных целиком в память и обновление топ-кэша) — 6 сек
Когда кэши существуют, подъем базы происходит быстрее:
— мутабельный дата-файл — 6 сек
— файл топ-кэша — 1.8 сек
— остальные кэши — менее 1 сек
Любая пользовательская свертка (возьмем для примера скрипт [5] построения оборотной ведомости) при первом вызове запускает скан иммутабельного файла, а мутабельные данные сканируются уже в оперативной памяти:
— иммутабельный дата-файл — 55 сек
— мутабельный массив в памяти — 0.2 сек
При последующих вызовах, при совпадении входных параметров — reduce() будет возвращать результат за 0.2 сек, при этом каждый раз делая следующее:
— извлечение результата из reduce-кэша по ключу (с учетом параметров)
— сканирование мутабельного массива (370k документов)
— «досчет» результата путем применения алгоритма свертки к отфильтрованным документам (20k)
Полученные результаты более чем привлекательные для таких объемов данных, моего старого одноядерного ноутбука, полного отсутствия какой-бы то ни было СУБД, и однопроходного алгоритма на языке TypeScript (который до сих пор считается несерьезным выбором для enterprise-backend приложений).
Исследовав производительность кода, я обнаружил, что более 70% времени тратится на чтение файла и парсинг юникода, а именно TextDecoder().decode(). К тому же высокоуровневый файловый интерфейс [6] в Deno только асинхронный, а как я недавно выяснил [7], цена async / await для моей задачи слишком велика. Поэтому пришлось написать собственный синхронный ридер [8], и не особо заморачиваясь с оптимизациями, увеличить скорость чистого чтения в 3 раза, или, если считать вместе с парсингом JSON — в 2 раза, Заодно глобально избавился от асинхронщины. Возможно, этот кусок нужно переписать низкоуровнево, но пока руки не доходят, и непонятно как внедрить нативный код в Deno / V8. Запись на диск также работает неприемлемо медленно, но это менее критичная проблема для прототипа.
1. Продемонстрировать реализацию следующих алгоритмов ERP в функциональном стиле:
— управление резервами
— расчет себестоимости в производстве с учетом накладных расходов
— планирование логистических цепочек
2. Перевод FuncDB в многопользовательский режим. В соответствие с принципом CQRS [9] — чтение осуществляется непосредственно серверными нодами, на которые копируются иммутабельные файлы БД (или шарятся по сети), а запись осуществляется через единую REST-точку, которая управляет мутабельными данными, кэшами и транзакциями.
3. Ускорение получения любого некэшированного документа по ID за счет индексирования последовательных файлов JSON (что конечно нарушает нашу концепцию однопроходных алгоритмов, но наличие любой возможности всегда лучше чем ее отсутствие).
Пока я не обнаружил ни одной причины отказаться от идеи функциональной СУБД / ERP, тем более что цена вопроса (разработка ядра, пользовательских алгоритмов, и необходимые вычислительные мощности) выглядит весьма приемлемо, тогда как на выходе мы имеем шанс получить многократное повышение масштабируемости, аудируемости и надежности — даже в такой консервативной сфере как ERP-строение.
Полный код проекта [10]
Если кто захочет поиграться самостоятельно:
— установить Deno [3]
— склонировать репозитарий
— запустить скрипт генерации базы с контролем остатков (generate_sample_database_with_balanses.ts)
— запусить скрипты примеров 1..4, лежащих в корневой папке
— придумать свой пример, закодить, протестировать, и дать мне обратную связь
PS
Консольный вывод расчитан на Linux, возможно под Windows esc-последовательности будут работать некорректно, но мне не на чем это проверить :)
Спасибо за внимание.
Автор: Евгений Баладжа
Источник [11]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/344525
Ссылки в тексте:
[1] предыдущей статье: https://habr.com/ru/post/482938/
[2] event sourcing: https://habr.com/ru/post/178259/
[3] Deno: https://deno.land/
[4] скриптом: https://github.com/balajahe/FuncDB/blob/master/FuncDB.deno/generate_sample_database_with_balances.ts
[5] скрипт: https://github.com/balajahe/FuncDB/blob/master/FuncDB.deno/sample2_invent_turnover_balance.ts
[6] интерфейс: https://deno.land/std/io/bufio.ts?doc#BufReader.readString
[7] недавно выяснил: https://habr.com/ru/post/483734/
[8] ридер: https://github.com/balajahe/FuncDB/blob/master/FuncDB.deno/core/DBIO.ts
[9] CQRS: https://ru.wikipedia.org/wiki/CQRS
[10] Полный код проекта: https://github.com/balajahe/FuncDB/tree/master/FuncDB.deno
[11] Источник: https://habr.com/ru/post/485508/?utm_source=habrahabr&utm_medium=rss&utm_campaign=485508
Нажмите здесь для печати.