Привет, Хабр!
Продолжаем исследовать применимость принципов функционального программирования при проектировании ERP. В предыдущей статье мы рассказали зачем это нужно, заложили основы архитектуры, и продемонстрировали построение простых сверток на примере оборотной ведомости. По сути, предлагается подход event sourcing, но за счет разделения БД на иммутабельную и мутабельную часть, мы получаем в одной системе комбинацию преимуществ map / reduce-хранилища и in-memory СУБД, что решает как проблему производительности, так и проблему масштабируемости. В этой статье я расскажу (и покажу прототип на TypeScript и рантайме Deno), как в такой системе хранить регистры мгновенных остатков и рассчитывать себестоимость. Для тех, кто не читал 1-ю статью — краткое резюме:
1. Журнал документов. ERP, построенная на базе РСУБД представляет собой огромный мутабельный стейт с конкурентным доступом, поэтому не масштабируется, слабо-аудируема, и ненадежна в эксплуатации (допускает рассогласование данных). В функциональной ERP все данные организованы в виде хронологически-упорядоченного журнала иммутабельных первичных документов, и в ней нет ничего кроме этих документов. Связи разрешаются от новых документов к старым по полному ID (и никогда наоборот), а все остальные данные (остатки, регистры, сопоставления) являются вычисляемыми свертками, то есть кэшируемыми результами работы чистых функций на потоке документов. Отсутствие стейта + аудируемость функций дает нам повышенную надежность (блокчейн на эту схему прекрасно ложится), а бонусом мы получаем упрощение схемы хранения + адаптивный кэш вместо жесткого (организованного на базе таблиц).
2. Иммутабельность и мутабельность. Журнал документов делится на 2 неравные части:
3. Свертки. Ввиду отсутствия семантики JOIN — язык SQL непригоден, и все алгоритмы пишутся в функциональном стиле filter / reduce, также имеются триггеры (обработчики событий) на отдельные типы документов. Вычисление filter / reduce назовем сверткой. Алгоритм свертки для прикладного разработчика выглядит как полный проход по журналу документов, однако ядро при исполнении делает оптимизацию — промежуточный результат, вычисленный по иммутабельной части, берется из кэша, а затем «досчитывается» по мутабельной части. Таким образом, начиная со второго запуска — свертка вычисляется целиком в оперативной памяти, что занимает доли секунд на миллионе документов (мы это покажем на примерах). Свертка досчитывается при каждом вызове, так как отследить все изменения в мутабельных документах (императивно-реактивный подход) очень сложно, а вычисления в оперативной памяти дешевы, и пользовательский код при таком подходе сильно упрощается. Свертка может использовать результаты других сверток, извлечение документов по ID, и поиск документов в топ-кэше по ключу.
4. Версионность документов и кэширование. Каждый документ имеет уникальный ключ и уникальный ID (ключ + таймштамп). Документы с одинаковым ключом организованы в группу, последняя запись которой является текущей (актуальной), а остальные — историческими.
Кэшем называется все, что может быть удалено, и снова восстановлено из журнала документов при старте БД. Наша система имеет 3 кэша:
Переходим собственно к теме статьи — хранение остатков. Первое что приходит в голову — реализовать остаток как свертку, входным параметром которой будет комбинация аналитик (например номенклатура + склад + партия). Однако в ERP нам нужно считать себестоимость, для чего необходимо сопоставлять расходы с остатками (алгоритмы ФИФО, партионный ФИФО, среднее по складу — теоретически мы можем усреднять себестоимость по любой комбинации аналитик). Другими словами, остаток нам нужен как самостоятельная сущность, а поскольку в нашей системе все является документом — остаток это тоже документ со специальным типом «баланс».
Балансы формируются триггером в момент разноски строк документов покупки / продажи / перемещения, и т.д. Ключ баланса — это комбинация аналитик, балансы с одинаковым ключом образуют историческую группу, последний элемент которой сохраняется в топ-кэше и мгновенно-доступен. Балансы это не проводки, и поэтому не суммируются — последняя запись актуальна, а ранние записи хранят историю.
В балансе хранится количество в единицах хранения и сумма в основной валюте, а разделив второе на первое — мы получаем мгновенную себестоимость на пересечении аналитик. Таким образом, в системе хранится не только полная история остатков, но и полная история себестоимостей, что является плюсом для аудируемости результатов. Баланс легковесен, максимальное количество балансов равно количеству строк документов (реально меньше, если строки группируются по комбинациям аналитик), количество топ-записей баланса не зависит от объема БД, и определяется количеством комбинаций аналитик, участвующих в контроле остатков и расчете себестоимости, таким образом размер нашего топ-кэша всегда прогнозируем.
Изначально балансы формируются приходными документами типа «покупка» и корректируются любыми расходными документами. К примеру, триггер документа «продажа» делает следующее:
Пример изменения баланса при продаже
Код класса-обработчика документа «продажа» на TypeScript
Конечно, можно было бы не хранить себестоимость прямо в расходных строках, а брать ее по ссылке из баланса, но дело в том, что балансы — это документы, их много, закэшировать все невозможно, а получать документ по ID чтением с диска — дорого (как индексировать последовательные файлы для быстрого доступа — расскажу в след. раз).
Основная проблема, на которую указывали комментаторы — производителность системы, и у нас есть все, чтобы померить ее на относительно релевантных объемах данных.
Наша система будет состоять из 5000 контрагентов (поставщики и клиенты), 3000 номенклатур, 50 складов, и по 100k документов каждого вида — покупки, перемещения, продажи. Документы генерируются случайным образом, в среднем по 8.5 строк на документ. Cтроки покупок и продаж порождают по одной транзакции (и одному балансу), а строки перемещения по две, в результате 300k первичных документов порождают около 3.4 миллиона транзакций, что вполне соответствует месячным объемам провинциальной ERP. Мутабельную часть генерируем аналогично, только объемом в 10 раз меньше.
Генерацию документов выполняем скриптом. Начнем с покупок, при проведении остальных документов триггер проверит остаток на пересечении номенклатуры и склада, и если хотя бы одна строка не проходит — скрипт будет пытаться cгенерировать новый документ. Балансы создаются автоматически, триггерами, максимальное количество комбинаций аналитик равно кол-во номенклатур * кол-во складов, т.е. 150k.
После завершения скрипта мы увидим следующие метрики базы:
Инициализация базы. При отсутствии кэш-файлов, база при первом запуске осуществляет фуллскан:
Когда кэши существуют, подъем базы происходит быстрее:
Любая пользовательская свертка (возьмем для примера скрипт построения оборотной ведомости) при первом вызове запускает скан иммутабельного файла, а мутабельные данные сканируются уже в оперативной памяти:
При последующих вызовах, при совпадении входных параметров — reduce() будет возвращать результат за 0.2 сек, при этом каждый раз выполняя шаги:
Полученные результаты вполне привлекательны для таких объемов данных, моего одноядерного ноутбука, полного отсутствия какой-бы то ни было СУБД (не забываем, что это всего лишь прототип), и однопроходного алгоритма на языке TypeScript (который до сих пор считается несерьезным выбором для enterprise-backend приложений).
Исследовав производительность кода, я обнаружил, что более 80% времени тратится на чтение файла и парсинг юникода, а именно File.read() и TextDecoder().decode(). К тому же высокоуровневый файловый интерфейс в Deno только асинхронный, а как я недавно выяснил, цена async / await для моей задачи слишком велика. Поэтому пришлось написать собственный синхронный ридер, и не особо заморачиваясь с оптимизациями, увеличить скорость чистого чтения в 3 раза, или, если считать вместе с парсингом JSON — в 2 раза, Заодно глобально избавился от асинхронщины. Возможно, этот кусок нужно переписать низкоуровнево (а может и весь проект). Запись данных на диск также неприемлемо медленная, хотя это менее критично для прототипа.
1. Продемонстрировать реализацию следующих алгоритмов ERP в функциональном стиле:
2. Продемонстрировать реализацию вложенных транзакций (для данной архитектуры это очень просто, так как отсутствует удаление документов, а любое изменение заключается в добавлении новой версии документа).
3. Перевод FuncDB в многопользовательский режим. В соответствие с принципом CQRS — сканирование иммутабельных данных осуществляется т.н. «клиентскими» нодами, на которые копируются иммутабельные файлы БД (или шарятся по сети), а работа с текущими данными, кэшем, и транзакциями — осуществляется через выделенную «серверную» ноду. Это позволит масштабировать все тяжелые и медленные операции, а транзакционный сервер оставить легким, а значит — быстрым.
4. Ускорение получения любого некэшированного документа по ID за счет индексирования последовательных файлов (что конечно нарушает нашу концепцию однопроходных алгоритмов, но наличие любой возможности всегда лучше чем ее отсутствие).
Пока я не обнаружил ни одной причины отказаться от идеи функциональной СУБД / ERP, ведь несмотря на неуниверсальность такой СУБД, применительно конкретной задаче (учет и планирование) — мы имеем шанс получить многократное повышение масштабируемости, аудируемости и надежности целевой системы — и все благодаря соблюдению основных принципов ФП.
Полный код проекта
Если кто захочет поиграться самостоятельно:
P.S.
Консольный вывод расчитан на Linux, возможно под Windows esc-последовательности будут работать некорректно, но мне не на чем это проверить :)
Спасибо за внимание.
Продолжаем исследовать применимость принципов функционального программирования при проектировании 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. Продемонстрировать реализацию вложенных транзакций (для данной архитектуры это очень просто, так как отсутствует удаление документов, а любое изменение заключается в добавлении новой версии документа).
3. Перевод FuncDB в многопользовательский режим. В соответствие с принципом CQRS — сканирование иммутабельных данных осуществляется т.н. «клиентскими» нодами, на которые копируются иммутабельные файлы БД (или шарятся по сети), а работа с текущими данными, кэшем, и транзакциями — осуществляется через выделенную «серверную» ноду. Это позволит масштабировать все тяжелые и медленные операции, а транзакционный сервер оставить легким, а значит — быстрым.
4. Ускорение получения любого некэшированного документа по ID за счет индексирования последовательных файлов (что конечно нарушает нашу концепцию однопроходных алгоритмов, но наличие любой возможности всегда лучше чем ее отсутствие).
Резюме
Пока я не обнаружил ни одной причины отказаться от идеи функциональной СУБД / ERP, ведь несмотря на неуниверсальность такой СУБД, применительно конкретной задаче (учет и планирование) — мы имеем шанс получить многократное повышение масштабируемости, аудируемости и надежности целевой системы — и все благодаря соблюдению основных принципов ФП.
Полный код проекта
Если кто захочет поиграться самостоятельно:
- установить Deno
- склонировать репозитарий
- запустить скрипт генерации базы с контролем остатков (generate_sample_database_with_balanses.ts)
- запустить скрипты примеров 1..4, лежащих в корневой папке
- придумать свой пример, закодить, протестировать, и дать мне обратную связь
P.S.
Консольный вывод расчитан на Linux, возможно под Windows esc-последовательности будут работать некорректно, но мне не на чем это проверить :)
Спасибо за внимание.