
Проблема
Возьмём типичный 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(); // Найти всех, у кого предок с бюджетом > 1M var rich = await redb.TreeQuery<DepartmentProps>() .WhereHasAncestor<DepartmentProps>(a => a.Budget > 1_000_000m) .ToListAsync();
Оконные функции? GroupBy? Агрегации?
Тоже через LINQ:
// ROW_NUMBER() PARTITION BY Department ORDER BY Salary DESC var 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].City WITH 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 o JOIN pvt_cte ON pvt_cte._id_object = o._id WHERE o._id_scheme = $1 LIMIT 100
Что генерируется для MSSQL:
-- PVT CTE (nested MAX CASE WHEN): OfficeLocations[HQ].City ;WITH raw_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 o JOIN pvt_cte ON pvt_cte._id_object = o._id WHERE o._id_scheme = @p0 ORDER BY o._id OFFSET 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 + Pro builder.Services.AddRedbPro(options => options .UseMsSql("Server=localhost;Database=mydb;Trusted_Connection=true") .WithLicense("YOUR-LICENSE-KEY")); // Free (Apache 2.0) — PostgreSQL builder.Services.AddRedb(options => options .UsePostgres("Host=localhost;Database=mydb;Username=postgres;Password=pass;Options=-c jit=off")); // Free (Apache 2.0) — MSSQL builder.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.ForEachChange tracking — умное сохранение: строятся два дерева
ValueTreeNode(память vs БД), diff с пропуском по хешу, только изменённые узлы → SQL. Никакого delete-all/re-insertWindow 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 и связанные объекты на заданную глубину:
-- PostgreSQL SELECT get_object_json(42, 3); -- объект 42, глубина 3 SELECT get_object_json(o._id, 5) FROM _objects o WHERE o._id_scheme = 123; -- MSSQL SELECT dbo.get_object_json(42, 3); SELECT dbo.get_object_json(o._id, 5) FROM _objects o WHERE 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 / DELETE await 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будет быстрее — один запрос, один маппинг, без overheadEF 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.tpkg tsak context start orders # Остановить один маршрут без рестарта процесса: tsak route stop orders order-pipeline # Посмотреть что сейчас работает: tsak context list tsak 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+ рабочих примеров
