Проблема
Возьмём типичный enterprise-объект — скажем, заказ. Он связан с клиентом, позициями, каждая позиция — с товаром, у товара — категория, у заказа — доставка с адресом, оплата с транзакциями. Итого 10–30 связанных сущностей. EF Core:
var order = await context.Orders .Include(o => o.Customer) .Include(o => o.Items).ThenInclude(i => i.Product) .ThenInclude(p => p.Category) .Include(o => o.Items).ThenInclude(i => i.Discounts) .Include(o => o.Shipping).ThenInclude(s => s.Address) .Include(o => o.Payment).ThenInclude(p => p.Transactions) // ... ещё строк 30 ... .FirstOrDefaultAsync(o => o.Id == orderId);
Забыл один Include — runtime. Добавить поле в модель — миграция → заявка на DBA → staging → deploy. Три дня на ALTER TABLE ADD COLUMN.
Хотелось: описать модель как C#-класс, и чтобы движок сам разобрался как это хранить. Без миграций, без маппинга, без Include.
Написал. Выложил под Apache 2.0.
Production case
Работает в проде в крупном HoReCa-дистрибьюторе (~150k заказов/мес, ~20k B2B-клиентов, собственный автопарк). Внутренняя TMS — ~500 водителей + ~50 диспетчеров, 3-нодовый кластер (Xeon, 4 ядра / 8 ГБ / 50 ГБ SSD на ноду), ~3 месяца стабильной работы, 10–15% CPU под полной нагрузкой. Интеграции через redb.Route: SAP, Kafka, RabbitMQ, GPS-фиды, Меркурий, ЕГАИС, Честный ЗНАК, ФГИС Зерно.
Второй production-продукт: аналитическая платформа (~672k объектов, ~8M свойств). Ни одной миграции за весь срок эксплуатации. Добавить поле в модель — добавить свойство в C#-класс → SyncSchemeAsync() → готово.
Как выглядит в коде
Вот реальная модель из redb.Examples:
[RedbScheme("Employee")]public class EmployeeProps{ public string FirstName { get; set; } = ""; public string LastName { get; set; } = ""; public int Age { get; set; } public decimal Salary { get; set; } public string Department { get; set; } = ""; public DateTime HireDate { get; set; } public string[]? Skills { get; set; } public Address? HomeAddress { get; set; } public Contact[]? Contacts { get; set; } public RedbObject<ProjectMetricsProps>? CurrentProject { get; set; } public RedbObject<ProjectMetricsProps>[]? PastProjects { get; set; } public Dictionary<int, decimal>? BonusByYear { get; set; } public Dictionary<string, Department>? DepartmentHistory { get; set; }}
Это вся схема. Вложенные классы, массивы, словари, ссылки на другие RedbObject — всё хранится и загружается автоматически.
Сохранить:
var employee = new RedbObject<EmployeeProps>{ name = "Alice Johnson", Props = new EmployeeProps { FirstName = "Alice", LastName = "Johnson", Age = 28, Salary = 85000m, Skills = ["C#", "React", "SQL"] }};await redb.SaveAsync(employee);
Загрузить:
var loaded = await redb.LoadAsync<EmployeeProps>(id);// loaded.Props.FirstName → "Alice"// loaded.Props.CurrentProject.Props — загружен автоматически// loaded.Props.Contacts[0].Value — загружен автоматически
Запросить:
var results = await redb.Query<EmployeeProps>() .Where(e => e.Salary > 75000m && e.Skills.Contains("C#")) .OrderByDescending(e => e.Salary) .Take(100) .ToListAsync();
Забыть Include невозможно — Props всегда загружен целиком. Нужна проекция? Тогда .Select():
var projected = await redb.Query<EmployeeProps>() .Select(x => new { x.Props.FirstName, x.Props.Salary }) .ToListAsync();
Сравнение: тот самый объект из 28 таблиц
В EF Core:
var order = await context.Orders .Include(o => o.Customer) .Include(o => o.Items).ThenInclude(i => i.Product) .ThenInclude(p => p.Category) .Include(o => o.Items).ThenInclude(i => i.Discounts) .Include(o => o.Shipping).ThenInclude(s => s.Address) .Include(o => o.Payment).ThenInclude(p => p.Transactions) // ... ещё 35 строк Include ... .FirstOrDefaultAsync(o => o.Id == orderId);
В RedBase:
var order = await redb.LoadAsync<OrderProps>(orderId);
Одна строка. Все вложенные объекты, массивы, словари — загружены. И быстро: движок собирает Props одним запросом к плоской структуре, без JOIN-каскада по 28 таблицам. В Pro — ещё быстрее: материализация через Parallel.ForEach, каждая ветка графа собирается параллельно.
А что с деревьями?
Встроено из коробки. Не нужен ни closure table вручную, ни рекурсивные CTE в raw SQL:
// Создать деревоawait redb.CreateChildAsync(department, parentDepartment);// Загрузить всё дерево (до 5 уровней)var tree = await redb.LoadTreeAsync<DepartmentProps>(rootId, maxDepth: 5);// LINQ по деревуvar bigDepts = await redb.TreeQuery<DepartmentProps>() .Where(d => d.Budget > 500000m) .WhereLevel(2) .ToListAsync();// Найти всех, у кого предок с бюджетом > 1Mvar rich = await redb.TreeQuery<DepartmentProps>() .WhereHasAncestor<DepartmentProps>(a => a.Budget > 1_000_000m) .ToListAsync();
Оконные функции? GroupBy? Агрегации?
Тоже через LINQ:
// ROW_NUMBER() PARTITION BY Department ORDER BY Salary DESCvar ranked = await redb.Query<EmployeeProps>() .WithWindow(w => w .PartitionBy(x => x.Department) .OrderByDesc(x => x.Salary)) .SelectAsync(x => new { Name = x.Props.FirstName, Department = x.Props.Department, Rank = Win.RowNumber() });// GroupBy + агрегацияvar stats = await redb.Query<EmployeeProps>() .GroupBy(x => x.Department) .SelectAsync(g => new { g.Key, Total = Agg.Count(), AvgSalary = Agg.Average(g, x => x.Salary) });
Что генерирует движок под капотом
Обычно SQL за LINQ-запросами скучный. Но вот пример, который показывает почему здесь нужен специализированный query engine, а не «просто ORM».
Модель:
public class Address{ public string City { get; set; } = string.Empty; public string Street { get; set; } = string.Empty;}[RedbScheme("Employee")]public class EmployeeProps{ // ... public Dictionary<string, Address>? OfficeLocations { get; set; }}
LINQ-запрос — найти всех сотрудников, у кого HQ-офис в Нью-Йорке:
var result = await redb.Query<EmployeeProps>() .Where(e => e.OfficeLocations!["HQ"].City == "New York") .Take(100) .ToListAsync();
Что генерируется для PostgreSQL:
-- PVT CTE (nested-only optimization): OfficeLocations[HQ].CityWITH pvt_cte AS ( WITH nested_dict_0 AS ( SELECT dp._id_object , (array_agg(nv._string) FILTER (WHERE nv._id_structure = $5))[1] AS "OfficeLocations[HQ].City" FROM _values dp LEFT JOIN _values nv ON nv._array_parent_id = dp._id AND nv._id_structure = $5 WHERE dp._id_structure = $3 -- структура словаря OfficeLocations AND dp._array_index = $4 -- ключ "HQ" AND dp._id_object IN ( SELECT _id FROM _objects WHERE _id_scheme = $1 ) GROUP BY dp._id_object ) SELECT nd0._id_object , nd0."OfficeLocations[HQ].City" FROM nested_dict_0 nd0 WHERE nd0."OfficeLocations[HQ].City" = $2 -- "New York")SELECT o.*FROM _objects oJOIN pvt_cte ON pvt_cte._id_object = o._idWHERE o._id_scheme = $1LIMIT 100
Что генерируется для MSSQL:
-- PVT CTE (nested MAX CASE WHEN): OfficeLocations[HQ].City;WITHraw_values AS ( SELECT nv._array_parent_id AS _parent_id , MAX(CASE WHEN nv._id_structure = 1010067 THEN nv._string END) AS [OfficeLocations$LHQ$R$DCity] FROM _values nv WHERE nv._id_structure IN (1010067) AND nv._array_parent_id IS NOT NULL GROUP BY nv._array_parent_id),pvt_cte AS ( SELECT dp._id_object , rv.[OfficeLocations$LHQ$R$DCity] FROM _values dp JOIN raw_values rv ON rv._parent_id = dp._id WHERE dp._id_structure = @p2 -- структура словаря OfficeLocations AND dp._array_index = @p3 -- ключ "HQ" AND dp._id_object IN ( SELECT _id FROM _objects WHERE _id_scheme = @p0 ) AND rv.[OfficeLocations$LHQ$R$DCity] = @p1 -- "New York")SELECT o.*FROM _objects oJOIN pvt_cte ON pvt_cte._id_object = o._idWHERE o._id_scheme = @p0ORDER BY o._idOFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY
Одна строка LINQ — два диалекта, два разных подхода к оптимизации (PostgreSQL использует array_agg FILTER, MSSQL — MAX CASE WHEN). SQL генерируется под конкретный диалект автоматически. Посмотреть что именно сгенерировалось всегда можно через .ToSqlStringAsync().
Настройка — 5 строк
PostgreSQL или MSSQL — выбирается одной строкой:
// PostgreSQL + Pro// jit=off — отключаем JIT-компиляцию PostgreSQL, на коротких запросах она только замедляетbuilder.Services.AddRedbPro(options => options .UsePostgres("Host=localhost;Database=mydb;Username=postgres;Password=pass;Options=-c jit=off") .WithLicense("YOUR-LICENSE-KEY") .Configure(c => { c.EnablePropsCache = true; c.EnableLazyLoadingForProps = false; }));// MSSQL + Probuilder.Services.AddRedbPro(options => options .UseMsSql("Server=localhost;Database=mydb;Trusted_Connection=true") .WithLicense("YOUR-LICENSE-KEY"));// Free (Apache 2.0) — PostgreSQLbuilder.Services.AddRedb(options => options .UsePostgres("Host=localhost;Database=mydb;Username=postgres;Password=pass;Options=-c jit=off"));// Free (Apache 2.0) — MSSQLbuilder.Services.AddRedb(options => options .UseMsSql("Server=localhost;Database=mydb;Trusted_Connection=true"));
Free vs Pro
Ядро — open source, Apache 2.0. PostgreSQL и MSSQL. Полный LINQ, деревья, списки, пользователи, экспорт/импорт.
Оба варианта работают и с PostgreSQL, и с MSSQL. У движка единый SQL-абстрактный слой — переезд между базами это смена одной строки (.UsePostgres() ↔ .UseMsSql()). А redb.Export позволяет экспортировать данные из одной базы и импортировать в другую — PostgreSQL → MSSQL и обратно.
Pro добавляет производительность:
-
Compiled queries — LINQ компилируется в нативный SQL, без JSON-интерпретатора
-
Parallel materialization — загрузка Props через
Parallel.ForEach -
Change tracking — умное сохранение: строятся два дерева
ValueTreeNode(память vs БД), diff с пропуском по хешу, только изменённые узлы → SQL. Никакого delete-all/re-insert -
Window functions, глубокие вложенные запросы, арифметика в WHERE
Без лицензии Pro работает полностью — 1,024 запроса на запуск приложения, счётчик сбрасывается при перезапуске. Для разработки ограничений практически нет.
Raw SQL и свои таблицы — тоже можно
RedBase не закрытый ящик. Если надо — работай с БД напрямую.
Посмотреть сгенерированный SQL — аналог EF Core .ToQueryString():
var sql = await redb.Query<EmployeeProps>() .Where(e => e.Salary > 75000m && e.Department == "Engineering") .ToSqlStringAsync();// вернёт реальный SQL с параметрами — удобно для отладки и оптимизации
Или использовать встроенную SQL-функцию get_object_json напрямую — она есть и в PostgreSQL, и в MSSQL, возвращает объект целиком как JSON, включая все вложенные Props и связанные объекты на заданную глубину:
-- PostgreSQLSELECT get_object_json(42, 3); -- объект 42, глубина 3SELECT get_object_json(o._id, 5)FROM _objects oWHERE o._id_scheme = 123;-- MSSQLSELECT dbo.get_object_json(42, 3);SELECT dbo.get_object_json(o._id, 5)FROM _objects oWHERE o._id_scheme = 123;
Полезно для отладки и диагностики прямо в psql/DataGrip/SSMS, или когда нужен JSON на SQL-стороне — без C# кода.
Выполнить произвольный SQL через redb.Context.Db:
// SELECT — список объектовvar rows = await redb.Context.Db.QueryAsync<MyDto>( "SELECT _id, _name FROM _objects WHERE _id_scheme = $1", schemeId);// SELECT — скалярvar count = await redb.Context.Db.ExecuteScalarAsync<int>( "SELECT COUNT(*) FROM _objects WHERE _id_scheme = $1", schemeId);// INSERT / UPDATE / DELETEawait redb.Context.Db.ExecuteAsync( "UPDATE my_custom_table SET synced = true WHERE object_id = $1", objectId);
Свои таблицы — создавай через тот же ExecuteAsync, хоть при старте приложения:
await redb.Context.Db.ExecuteAsync(""" CREATE TABLE IF NOT EXISTS logistics_routes ( id BIGSERIAL PRIMARY KEY, object_id BIGINT REFERENCES _objects(_id) ON DELETE CASCADE, route_json TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ) """);
FK на _objects(_id) — и твоя таблица привязана к redb-объекту. Cascade delete работает.
Структура _values — если нужно писать raw SQL по хранилищу Props, колонки типизированы:
-- Каждое свойство объекта — одна строка в _values-- _id_structure → какое поле схемы (id структуры из _structures)-- _id_object → какой объект (_objects._id)-- Значение в типизированной колонке по типу свойства:-- _String text-- _Long bigint (int, long, enum)-- _Numeric numeric(38,18) (decimal — без потерь точности)-- _Double float-- _DateTimeOffset timestamptz-- _Boolean boolean-- _Guid uuid-- _Object bigint → FK на _objects (RedbObject<T> reference)-- _ListItem bigint → FK на _list_items (справочник)-- _ByteArray bytea-- Для массивов/словарей: _array_parent_id + _array_index
Строковые значения не varchar(max) — не «всё в строку». Каждый тип в своей колонке с правильным SQL-типом. decimal — это NUMERIC(38,18) без потерь точности. DateTime — timestamptz. Поэтому WHERE по числам, датам, uuid работает через обычные индексы.
Когда RedBase НЕ нужен
RedBase хранит свойства объекта в строках таблицы _values — одна строка на каждое свойство. Это даёт гибкость: вложенные объекты, массивы, словари, изменение схемы без миграций. Но за гибкость есть цена.
Если ваша модель — плоская таблица без связей (Users с 10 колонками, лог событий, очередь задач), то:
-
Dapper +
SELECT * FROM users WHERE id = @idбудет быстрее — один запрос, один маппинг, без overhead -
EF Core тоже справится — для одной таблицы Include не нужны, миграции тривиальны
RedBase выигрывает там, где объект — это граф: 3+ вложенных структур, массивы, словари, ссылки между объектами, частые изменения схемы. Чем сложнее модель — тем больше разница с классическим подходом.
Причём порог ниже, чем кажется. Попробуйте в EF Core добавить в модель string[] Skills — и вот вам уже отдельная таблица UserSkills, FK, индексы, миграция, .Include(u => u.Skills). А в RedBase — просто public string[]? Skills { get; set; } и всё. Объявил — работает.
За рамками статьи
В одну статью не влезло. Кратко что ещё есть:
-
Деревья — полный функционал:
CreateChildAsync,MoveAsync,LoadTreeAsync,WhereLevel,WhereHasAncestor,WhereHasDescendant. Closure table и рекурсивные CTE генерируются автоматически. -
Экспорт / импорт —
redb.Exportвыгружает объекты в JSON-файлы и загружает обратно. Работает между PostgreSQL и MSSQL: переехать с одной БД на другую — одна команда. -
Права доступа — встроенная модель пользователей, ролей и разрешений на уровне объектов.
-
Справочники —
_list_items, типизированные черезRedbListItem, LINQ по справочным значениям. -
Change tracking (Pro) —
PropsSaveStrategy.ChangeTracking: приSaveAsyncстроятся два дереваValueTreeNode(память vs БД), сравниваются с пропуском по хешу — генерируется только минимальный набор SQL-операций. Никакого delete-all/re-insert. -
redb.Identity (в активной разработке, ещё не опубликован на NuGet) — OAuth 2.1 / OIDC Identity Server поверх redb.Core и redb.Route. Ключевая идея: каждый endpoint — это
direct-vm://-маршрут, а не HTTP-middleware. Вызватьtokenиз Worker Service или из соседнего модуля в том же процессе —To("direct-vm://identity-token"), без loopback, без TLS, безWebApplicationFactoryв тестах. HTTP / gRPC / RabbitMQ — подключаемые facade-пакеты. Из коробки: все флоузы OAuth 2.1 (Code+PKCE, Client Credentials, Device Code), PAR (RFC 9126), DPoP (RFC 9449), Dynamic Client Registration (RFC 7591/7592), SCIM 2.0 (RFC 7643/7644), FIDO2/WebAuthn + TOTP + SMS OTP, backchannel logout (RFC 8417), федерация (OIDC / GitHub). Хранение через redb — без миграций. Шарит signing keys и DataProtection key-ring между нодами через redb object store. Деплоится как.tpkgв redb.Tsak. 1751 проходящий тест. Apache 2.0. -
195+ примеров в
redb.Examples— деревья, окна, группировки, экспорт, raw SQL и т.д.
Всё это в архитектурной документации и примерах
redb.Route: интеграции без hand-rolled-кода
redb.Core закрывает хранение. Для интеграций есть redb.Route — .NET-аналог Apache Camel. Маршрут описывается fluent C# DSL:
// HTTP-вход: принять заказ, валидировать, передать в очередьFrom("http:0.0.0.0:5090/api/orders?inOut=true&cors=true&corsOrigins=*") .Validate(e => e.In.Body is not null, "Body required") .Choice() .When(e => e.In.Headers.ContainsKey("redbHttp.ResponseCode")) .To("direct://error-response") .Otherwise() .To("seda://orders-pending?concurrentConsumers=4") .EndChoice();// Фоновая обработка: сохранить через redb, опубликовать событиеFrom("seda://orders-pending?concurrentConsumers=4") .ProcessWithRedb(async (redb, exchange, ct) => { var dto = (OrderDto)exchange.In.Body!; var order = new RedbObject<OrderProps> { Props = Map(dto) }; await redb.SaveAsync(order, ct); exchange.In.Headers["order.id"] = order.id; }) .To("rabbitmq://orders-created");
22 внешних транспорта + 5 встроенных компонентов:
|
Категория |
Транспорты |
|---|---|
|
Очереди сообщений |
RabbitMQ, Kafka, IBM MQ, MQTT, Azure Service Bus |
|
HTTP / WebSocket |
HTTP (in/out), WebSocket, gRPC (client) |
|
Файлы / хранилища |
SFTP, S3, FTP, File |
|
Базы данных |
SQL (polling outbox) |
|
Встроенные |
Direct, SEDA, Timer, Cron, Mock |
30+ EIP-паттернов — Split, Aggregate, Choice, Filter, WireTap, Retry, DeadLetterChannel, CircuitBreaker, IdempotentConsumer, Saga, Multicast, RecipientList, DynamicRouter, Resequence, Throttle, Delay, Loop, Enrich, Validate, Transacted, и другие.
Expression DSL — предикаты компилируются в Func<IExchange, T> через System.Linq.Expressions, без интерпретатора:
// Предикаты в Choice.When, Filter, Retry — все через один DSL.When(Header("priority").isEqualTo("high")).Filter(Header("score").isGreaterThan(50)).Filter(Header("tag").regex(@"^urgent-.*-x\d+$")).When(Header("active").and(Header("role").isEqualTo("admin")))// String templates → компилируются в лямбды.SetHeader("reply", "${header.orderId}-confirmed")
Обработка ошибок:
From("kafka://payments?groupId=billing") .OnException<TimeoutException>() .Retry(3, TimeSpan.FromSeconds(2)) .OnException<ValidationException>() .To("direct://dlq") .End() .Retry(5) .BackOff(TimeSpan.FromSeconds(1), multiplier: 2) .Process(async (e, ct) => { /* обработка */ }) .To("rabbitmq://billing-confirmed");
Транзакционные маршруты — .Transacted() оборачивает pipeline в TransactionScope, SQL-транспорт биндит ADO.NET-транзакцию к каждому шагу.
Apache 2.0, NuGet: dotnet add package redb.Route.
redb.Tsak: runtime-контейнер для маршрутов
redb.Route описывает что делает пайплайн. redb.Tsak — это где, когда и сколько копий его запустить.
Классическая проблема: несколько несвязанных интеграционных пайплайнов живут в одном Program.cs. Добавил новый — пересобрал и передеплоил всё. Нужно остановить один маршрут — перезапускай весь процесс.
Tsak решает это: каждый RouteBuilder упаковывается в модуль (.dll или .tpkg-бандл), Tsak загружает его в изолированный AssemblyLoadContext и управляет жизненным циклом независимо от остальных.
Деплой нового маршрута:
# Скопировать DLL — Tsak подхватит автоматическиcp Orders.dll /tsak/Libs/# Или через CLI:tsak module upload orders --file Orders.tpkgtsak context start orders# Остановить один маршрут без рестарта процесса:tsak route stop orders order-pipeline# Посмотреть что сейчас работает:tsak context listtsak route list orders
Три режима деплоя:
|
Режим |
Когда использовать |
|---|---|
|
|
Разработка, тесты — in-memory, без БД |
|
|
Продакшн на одной машине с персистентным состоянием |
|
|
Несколько нод — leader election + автоперераспределение контекстов |
Кластер без Redis и etcd. В кластерном режиме Tsak не тянет внешний координатор — всё хранится в той же redb-базе, которую уже использует приложение. Leader election, список нод, назначение контекстов по нодам, DataProtection key-ring, JWKS signing keys — всё это redb-объекты в _objects/_values. Маршруты, помеченные как кластерные (cluster=true в URI), идут через тот же координатор: состояние маршрута, partitioning, балансировка между нодами — через redb. Quartz при этом использует свои AdoJobStore-таблицы в той же БД, но создаёт их сам. Добавить ноду в кластер = запустить ещё один экземпляр Tsak с той же строкой подключения к БД. Никакого отдельного ZooKeeper, Consul или Redis.
Что есть из коробки:
-
REST API — 32 endpoint’а: управление контекстами, маршрутами, модулями, кластером, scheduler’ом, логами, пользователями
-
CLI — 30 команд с профилями и JSON-выводом (удобно для CI/CD)
-
Blazor Server dashboard — 10 страниц: метрики CPU/RAM/GC, per-route latency, ring-buffer логи, watchdog-статус
-
Watchdog — детектирует зависшие или упавшие маршруты, опционально перезапускает
-
Quartz scheduler — инжектируется в каждый контекст,
RAMJobStoreдля standalone,AdoJobStoreдля кластера — схема создаётся автоматически -
OpenTelemetry — Activities и Meters на каждый маршрут и шаг, Prometheus scrape
-
API Key + HMAC-SHA256 — роли, expiry, revocation, constant-time comparison
Код подключения Tsak — один метод в InitRoute.cs:
// Единственный Tsak-специфичный файл в проекте с маршрутамиpublic static class InitRoute{ public static IRouteContext main(IRouteContext context) { // Обычный redb.Route — тот же код, что и без Tsak ((RouteContext)context).AddRoutes(new OrderRoutes()); ((RouteContext)context).AddRoutes(new ShipmentRoutes()); return context; }}
RouteBuilder, написанный для обычного IHostedService, работает в Tsak без изменений — тот же Configure(), тот же IExchange, те же OnException и .Transacted().
351 проходящий тест. Apache 2.0.
Итого
|
|
EF Core |
redb |
|---|---|---|
|
Базы данных |
Много провайдеров |
PostgreSQL + MSSQL |
|
Схема |
DbContext + Fluent API + миграции |
C#-класс + |
|
Загрузить граф из 28 сущностей |
40 Include, 200 строк |
|
|
Добавить поле |
Миграция → DBA → staging → deploy |
Добавить свойство → |
|
Деревья |
closure table вручную |
|
|
Оконные функции |
Raw SQL |
|
|
Забыл Include |
Runtime crash |
Невозможно |
|
Переезд между БД |
Ручная переписка |
|
GitHub org (все репозитории)
Репозиторий redb.Core
Документация и примеры (EN)
Документация и примеры (RU)
43 NuGet-пакета
Архитектура (индексы, query engine)
195+ рабочих примеров
ссылка на оригинал статьи https://habr.com/ru/articles/1042058/