
Проблема
Возьмём типичный 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+ рабочих примеров
Комментарии (4)

grelikt Автор
01.06.2026 12:36Почему сложные графы быстрее — не маркетинг, а механика
Три конкретных причины:
1. Чтение — всегда из двух таблиц, не из N
В EF Core граф из 28 сущностей = 28 таблиц = JOIN-каскад или N+1 запросов. Планировщик PostgreSQL/MSSQL должен соединить 28 источников, построить план, выбрать стратегию join для каждой пары.
В redb — всегда
objects+values, независимо от глубины графа. Планировщик работает с двумя таблицами с предсказуемыми индексами:(id_scheme),(id_object),(id_structure). Один CTE, один проход.2. Запись — bulk COPY, не каскад INSERT/UPDATE
EF Core при сохранении графа генерирует цепочку INSERT/UPDATE по каждой таблице в правильном порядке (из-за FK). 28 сущностей = минимум 28 round-trip'ов или сложный батч с соблюдением порядка зависимостей.
redb при
SaveAsyncсобирает все изменённые_values-строки в один список и пишет их черезCOPY(PostgreSQL) илиSqlBulkCopy(MSSQL) за один вызов. Один network round-trip на весь граф любой глубины.3. Материализация — параллельно по веткам графа
Pro-режим: при
LoadAsyncграф загружается черезParallel.ForEach— каждая ветка объектного дерева материализуется в своём потоке. Веткаorder.Itemsи веткаorder.Shippingчитаются одновременно, не последовательно.В EF Core с
.Include()это в принципе невозможно без ручного разбиения на несколько запросов —DbContextне thread-safe.
Granulex
Вся архитектура _objects/_values/_structures – классический EAV. Он убирает боль миграций, но создаёт другую: база непрозрачна для всего, что живёт вне приложения – BI-инструменты, ad-hoc запросы DBA, pg_stat_statements. В production это обнаруживается в первый инцидент, когда нужно быстро залезть в базу руками.
grelikt Автор
Granulex, спасибо за комментарий, но по существу — это не EAV. Похоже визуально, но архитектурно другое. Давайте на конкретике.
Classic EAV vs то, что под капотом у redb
Classic EAV — это
(entity_id, attribute_name, value), гдеvalue— одна-единственнаяvarchar/textколонка, куда сериализуется всё подряд. Тип «зашит» в строкуattribute_nameлибо вообще нигде не записан. Отсюда боль:WHERE value > '100'сравнивает строки, индексов по типам нет, BI видит мусор.В redb всё иначе. Это типизированный column store + полноценное RTTI на уровне БД + сеть FK. Разбираю по пунктам.
1. Типизированные колонки в
_values— не “всё в строку”В
_valuesдля каждого .NET-типа отдельная колонка с правильным SQL-типом:Каждое свойство пишется в свою типизированную колонку.
WHERELong > 1000идёт по обычному B-tree,WHEREDateTimeOffset >= '2026-01-01'— то же самое. НикакогоCAST(value AS bigint)поверх text-колонки, как в classic EAV.2. FK везде, не только индексы — целостность на уровне БД
Это, наверное, самое важное и редко встречающееся в EAV-системах. Реальный DDL из redbPostgre.sql:
Удалили объект — каскадом удалились все его свойства. Удалили scheme — каскадом всё, что на ней висит. Удалили scheme-родителя — children почистились (
_id_parent →structures.id ON DELETE CASCADE). ПоссылочныйRedbObject<T>—_Objectтоже FK. В classic EAV ничего такого нет, потому что там нет понятия «структура» как first-class сущности.И всё это покрыто индексами:
То есть план запроса с
idscheme +idstructureлочит ровно нужные строки через covering-индекс, а не делает seq scan на гигантский_values.3. Полноценное RTTI прямо в БД — C# не обязателен
Это вторая часть, которой нет в EAV вообще. У redb схема описана как first-class данные:
_types— таблица типов (Long, String, Numeric, DateTimeOffset, Guid, Boolean, Object, ListItem, ByteArray, Array, Dictionary, Class, JsonDocument, XDocument). Это отдельная справочная таблица, не C#-enum.schemes— схемы (имя, тип через FK наtypes, версия, родительская схема для наследования).structures— поля схем (имя, тип через FK наtypes, признак коллекции через FK наtypes, тип ключа словаря через FK наtypes, ссылка на child-scheme для вложенных POCO).То есть БД сама знает, что
EmployeeProps.OfficeLocations— этоDictionary<string, Address>, гдеAddress— отдельная scheme с полямиCity: textиStreet: text. И всё это видно одним SELECT:DBA, BI-инженер, аудитор — открыл psql, получил полное описание схемы без единой строки .NET-кода. C# вообще не обязателен — фасеты и схемы можно строить из Python / Node / Go / Java / raw SQL. Schema-as-data, без хождения в приложение.
4. Теперь про DBA-инструменты — четыре конкретных ответа
4.1.
get_object_json(id, depth)— встроенная SQL-функция в обоих диалектах:Полный граф объекта как JSON: вложенные структуры, массивы, словари, ссылки. «Дай мне этот заказ как есть» — одна строка SQL.
4.2. Полиглот-API. Free-движок строит SQL внутри БД, фильтры можно слать как JSON-фасеты из любого языка:
Python (psycopg), Node (pg), Go (pgx), Java (JDBC), raw psql — .NET не нужен.
4.3.
pg_stat_statementsработает. Pro-движок генерирует параметризованный SQL с$1..$Nplaceholders — стабильная форма, статистика агрегируется нормально. Free-движок строит SQL внутри plpgsql — в статистике видно вызовыpvt_build_query_sql()/get_objects_json()(менее гранулярно, но не ломается).4.4. Свои таблицы рядом — без ограничений.
ExecuteAsyncс произвольным DDL, FK наobjects(id)с cascade delete, материализованные view поверхget_object_jsonдля аналитики. Это типичный паттерн для аналитического слоя.Что не получите
Power BI Direct Query с авто-маппингом всех колонок «прозрачно как реляционная схема» — да, не получите, и не должны. Для аналитического слоя строятся свои витрины (как и в любой системе с EAV-подобным storage, и в большинстве микросервисных архитектур тоже). redb это не запрещает — просто это твой код, а не автогенерация.
Подытог
EAV — это
(entity, attribute, value::text)без типов, без FK, без RTTI. У redb:типизированные колонки под каждый .NET-тип,
FK через всю схему с CASCADE — целостность гарантирует сама БД,
типы как first-class данные в
types/schemes/_structures— схема видна из SQL без C#,встроенные функции для DBA-сценариев
get_object_json/get_objects_json.Два года в проде у двух заказчиков (HoReCa-дистрибьютор + аналитическая платформа, ~672k объектов / ~8M свойств). Инциденты были, лазили руками —
get_object_jsonровно для этого и пишется. Не сказал бы, что больнее, чем дебажить реляционную базу с 30 таблицами через.Include().grelikt Автор
GroupBy + HAVINGтеперь в Pro (был только во Free с 1.2.x).Казалось бы — обычный SQL
HAVING, что тут писать статью. Но в redb это не «прокинуть строку в БД», а склеить три разнородных слоя:Пивот — собрать
Department(ссылка наlistitems) иAgeиз_valuesв плоские колонки CTE.JOIN на справочник —
Department.Valueэто ужеlistitems._value, неlistitem-id, поэтому добавляетсяLEFT JOINlist_items.Группировка + HAVING — поверх плоских колонок CTE, но с агрегатами по объектам, а не по строкам
_values.LINQ:
Что генерируется на PG.Pro:
Заметные детали:
Department в
valuesхранится какlistitem(FK наlistitems) — не строка. ПоэтомуDepartment.Valueтребует JOIN, а не текстового сравнения, и индекс поlistitems._idотрабатывает.GROUP BYидёт поli._value, не поpvt.Department_id— потому что одинаковое значение могут шарить несколько list-item’ов, и пользователь группирует по бизнес-смыслу, а не по id.HAVINGживёт на плоских колонках CTE —AVG(pvt."Age"), а неAVG(_Long). Это позволяет SQL-оптимизатору переставлять предикаты как обычно.Параметризация полная —
$1..$6, форма запроса стабильна → план кэшируется PostgreSQL.То же самое теперь работает на MSSql.Pro (через
MAX(CASE WHEN …)), на PG Free и MSSql Free (с инлайн-литералами черезformat %L/QUOTENAMEсоответственно).GroupByHavingTestsBase: 33/33 HAVING + 6/6 без HAVING на всех четырёх тирах, identical row counts везде.Если LINQ вам нравится больше, чем сырой SQL с CTE — этот один пример хорошо показывает, во что компилятор разворачивает три строчки
.GroupBy(...).Where(...).Select(...)и почему просто «прокинуть в Postgres» тут не получилось бы.