Продолжаем исследовать применимость принципов функционального программирования при проектировании ERP. В предыдущей статье мы рассказали зачем это нужно, заложили основы архитектуры, и продемонстрировали построение простых сверток на примере оборотной ведомости. По сути, предлагается подход event sourcing, но за счет разделения БД на иммутабельную и мутабельную часть, мы получаем в одной системе комбинацию преимуществ map / reduce-хранилища и in-memory СУБД, что решает как проблему производительности, так и проблему масштабируемости. В этой статье я расскажу (и покажу прототип на TypeScript и рантайме Deno), как в такой системе хранить регистры мгновенных остатков и рассчитывать себестоимость. Для тех, кто не читал 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 чтением с диска — дорого (как индексировать последовательные файлы для быстрого доступа — расскажу в след. раз).
Основная проблема, на которую указывали комментаторы — производителность системы, и у нас есть все, чтобы померить ее на относительно релевантных объемах данных.
Генерация исходных данных
Наша система будет состоять из 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-последовательности будут работать некорректно, но мне не на чем это проверить :)
Спасибо за внимание.
NitroJunkie
Это все конечно классно, но вопрос, как вы собрались производительность без индексов обеспечивать? То есть нужно оборотную ведомость по одной группе товаров с даты по дату сформировать. SQL может построить план с пробегом по индексам, в том числе составным. А как вы это в NoSQL делать собрались?
Ну и с ACID я так и не понял, что вы собираетесь делать. У SQL есть вполне декларативная и простая модель с блокировками (более) или update conflict'ами при RR/SERIALIZABLE уровнях изоляции. Как вы тот же остаток предлагаете целостным поддерживать?
balajahe Автор
1) Индексы нужны для прямого доступа к данным любой глубины, отсюда и сбор статистики, и партиционирование. У нас нет такой необходимости — объем иммутабельных данных конечен, и цена перебора данных непосредственно в памяти соизмерима с поиском по нескольким индексам и поднятию блока из файла (как делают СУБД). Проблема, если мы захотим нестандартную свертку за старые периоды (например с 12 по 28 января прошлого года) — тогда это фуллскан. Хотя на самом деле нет, ведь в этом конкретном случае мы знаем диапазон timestamp, а значит можем прикинуть смещение в файле JSON, а далее seek() + read() за конечное время. Но это уже тема индексирования последовательного хранилища.
2) Остатки не нужно блокировать, остаток при каждой операции генерируется новый. Блокировать нужно кэш на момент расчета этого остатка, самое простое решение — мьютекс на коллекцию, реально конечно придется заморочиться с уровнями изоляции, но сейчас у меня задача только прототипирование идеи, принципиальных проблем не вижу, если все данные в памяти, то наложить блокировку уж точно не сложнее, чем в РСУБД.
NitroJunkie
1) Не совсем понял про цену? Каким образом цена перебора всех данных соизмерима с поиском по нескольким индексам? У них алгоритмическая сложность на порядок отличается (один — n, второе log(n)). Вы наверное забываете, что сейчас процессор узкое место (так как их производительность практически не растет), с памятью (со случайным доступом, как оперативной так и постоянной (SSD)) сейчас проблем нет, и можно хоть всю SQL СУБД в память положить. То есть закладываться на медленные последовательные HDD (и большой оверхед на долгое чтение блоков) в современном мире никакого смысла нет.
Ну и не понял зачет этот огород со смещениями и seek и read, если SQL умеет это делать сам и из коробки.
2) Ну то есть ручные пессимистичные блокировки. Опять-таки SQL это из коробки, а главное куда эффективнее за счет оптимистичных блокировок умеет делать.
То есть я так и не понял чем вас SQL не устраивает?
balajahe Автор
Это мы уже в детали реализации погружаемся, я не спорю, придется решать все те же проблемы что решены в СУБД. А чем не устраивает SQL я уже ранее писал — мы не говорим о СУБД вообще, мы говорим о конкретном применении — учетная система. Ни одна из известных мне систем не масштабируется до уровня триллиона транзакций и петабайта данных. У них всех узкое место — блокировки, и неконтролируемое расползание ошибок пользовательских алгоритмов по сотне таблиц (из тысячи штатных). Изменить схему хранения данных, поле добавить — выгоняй пользователей на час, и т.д. Мне нужна биг-дата ERP, а там не пользуются ни блокировками ни транзакциями, и довольно редко — SQL.
NitroJunkie
Так их не от хорошей жизни делают. А именно потому что в ERP целостность данных действительно важна. Тут если остаток поплывет, это будет очень плохо (куда хуже чем незагруженная фотка в условном инстраграмме). И как раз версионные СУБД с их оптимистичными блокировками позволяют более менее эффективно решать проблему целостности, не сильно жертвуя масштабируемостью. То что многие учетные системы используют ручные пессимистичные блокировки это конкретно кривость рук их разработчиков, но вы то же предлагаете тоже самое.
Это решается построением еще одного уровня абстракции над SQL. Но никак не переходом на NoSQL, где с этим может быть все еще хуже.
Вообще ЕМНИП СУБД позволяют очень много вещей делать не выгоняя пользователей. Не говоря уже о том, что динамическое изменение структуры по ходу и надежность — это противоположные вещи.
Вообще у вас странная ERP. 24x7 (что уже редкость), с петабайтами данных и триллионами транзакций (что строго говоря для SQL на примитивной логике тоже не проблема, вспомните убер) и с логикой где не нужны выборки по условиям и целостность данных. Вы уверены что такие ERP существуют? И вообще в моем понимании ERP это именно что сложно-функциональные бизнес-приложения с высокими требованиями к целостности. А что это в вашем представлении?
balajahe Автор
Я с вами и не спорю. Статья не про то, что SQL плохо, а про схему разделения данных, и способ написания запросов. Проектик этот — чисто исследовательский, по результатам которого я кое-чему научился, и понял, что например могу миллиард записей обработать за минуты (а не часы), а на каком нибудь Rust и того быстрее, и что функциональные запросы не сложнее писать чем SQL, и так далее.
По последнему вопросу — я достаточно с ERP поработал, и с нашими, и с ненашими, нет там высоких требований к плотности транзакций — максимум что я видел в штатном режиме работы — это одна транзакция в секунду :) Больше только в режиме закрытия периода, а это обычно по ночам. У биллингов и финтеха намного больше, вот там нужны вообще in-memory СУБД. А данных в ERP накапливается много, аудируемость важна, защита от злонамеренного разработчика или админа. Неспроста придумали и старательно форсят функциональное программирование — оно не про скорость, оно про надежность и тестируемость кода.
edo1h
дурное дело нехитрое.
запустил
select count(*)
в ms sql с условием, не попадающим в индексы, на таблице с 200кк записей, в один поток — 24с. экстраполируем на миллиард записей — 2 минуты. если убратьoption (maxdop 1)
— будет в разы быстрее.NitroJunkie
А у вас эти 200кк записей не в памяти (в shared_buffers или как они там в ms sql называется)? Потому как если вся таблица в памяти, там цифры куда меньше будут.
NitroJunkie
А причем тут функциональное программирование? Это классная штука не спорю, но его можно одинаково «компилировать» как в SQL (что многие и делают) так и в NoSQL. И причин «компилировать» все в SQL куда больше, прежде всего из-за важных оптимизаций (с индексами и параллелизмом) и ACID из коробки.
edo1h
а ваша система с fullscan на каждый чих масштабируется?
из статьи:
как тут без блокировок/транзакций решается проблема с race condition при одновременном обновлении баланса двумя пользовтелями?
balajahe Автор
Системы бигдаты построены на последовательных файлах и map / reduce, и таки да, они масштабируются до размеров, где ни одна РСУБД не встанет. И там таки фуллскан, с параллельностью (не все алгоритмы можно распараллелить в принципе). И никто не говорил, что блокировки и транзакции не нужны, просто статья не про это немного, она больше ответ на вопросы к первой статье, и про функциональное программирование :)