Не заводите вторую базу ради объектов: redb против MongoDB и RavenDB
Чтобы хранить объекты, графы и деревья с типами, индексами и полноценными запросами, вам не нужна ещё одна база данных. Нужна та, что у вас уже есть.
redb превращает PostgreSQL, MS SQL или SQLite в типизированное объектное хранилище, не отнимая ни SQL, ни EF Core, ни Dapper. Это принципиально другой разговор, чем «MongoDB против RavenDB»: там вы выбираете отдельный движок и живёте с ним отдельно; здесь объекты ложатся в базу, которая у вас уже крутится в проде. Ниже чем это выигрывает у документных баз, с кодом, и где у redb честные границы.
Чтобы не спорить с чучелами: MongoDB зрелая серверная документная база с горизонтальным масштабированием и огромной экосистемой, и мультидокументные ACID-транзакции у неё есть с версии 4.0 (2018). RavenDB .NET-native документная база, полностью ACID, с типизированным LINQ и автоиндексами. Обе хорошие продукты. redb просто играет на другом поле и на этом поле у него сильные карты.
И учить, по сути, нечего: это тот же LINQ, который вы уже пишете. Код против redb и против Raven почти совпадает (ниже увидите), а порог входа ниже, чем у EF Core, без DbContext, конфигурации и миграций.
Коротко: redb против Mongo и Raven
| redb | MongoDB | RavenDB | |
|---|---|---|---|
| Где живёт | в вашей PostgreSQL / MS SQL / SQLite | свой сервер | свой сервер |
FK и JOIN к вашим таблицам |
да, настоящий внешний ключ | нет | нет |
| Одна транзакция с EF/Dapper | да (TransactionScope) |
нет | нет |
| Апдейт: что летит в БД | только изменённые поля, авто-дифф | $set формируете сами |
документ целиком, авто (поля Patch) |
| Кэш объектов (skip re-материализации) | да, по _hash, прозрачный |
нет (ваш слой) | да (агрессивный, по change-vector) |
| Язык запросов | нативный SQL (LINQ → SQL) | MQL + aggregation | RQL (LINQ → RQL) |
| Типы полей в хранилище | да, RTTI | нет (BSON) | типизирован клиент, на диске документ |
| Деревья | рекурсивные CTE, полиморфные | $graphLookup, ограниченно |
нативно нет |
| Embedded / в браузере (WASM) | да, SQLite Pro (чистый C#) | нет | embedded есть, WASM нет |
| Порог входа | LINQ, ниже чем EF Core | свой драйвер и API | LINQ → RQL |
| ACID | да (транзакции СУБД) | да (с 4.0) | да |
| Индексы | покрыты из коробки, тюнинг = убрать лишнее | объявляете сами | автоиндексы |
| Горизонтальный шардинг | в разработке (доменная модель) | зрелый, налётанный | кластер и шардинг есть |
| Права на объект (record-level) | да, из коробки | руками (app-level) | руками (app-level) |
| Full-text из коробки | средствами СУБД | Atlas Search | встроенный Lucene |
Ваша база знает ваши типы
В документной модели схема живёт в голове приложения. Mongo хранит BSON: тип и обязательность поля держит код, а не база. Raven ближе .NET-клиент типизирован, но на диске это документ, и запрос идёт по индексам, которые вы объявили заранее.
redb хранит и данные, и описание их типов. Внутри связка _types → _schemes → _structures → _values: база в момент запроса знает, что Salary это decimal, а HireDate это DateTime, а не «поле №42». Это runtime type information на уровне хранилища, и практический выхлоп прямой: значения лежат в типизированных колонках с обычными индексами СУБД, поэтому LINQ уходит в нативный SQL по индексу, а не перебором документов.
// MongoDB фильтр по нетипизированному документу
var res = await coll.Find(Builders<BsonDocument>.Filter.Gt("Salary", 100000)).ToListAsync();
// RavenDB LINQ (async-сессия), но транслируется в RQL поверх собственного движка
var res = await asyncSession.Query<Employee>().Where(e => e.Salary > 100000).ToListAsync();
// redb LINQ компилируется в НАТИВНЫЙ SQL по индексированной колонке
var res = await redb.Query<Employee>().Where(e => e.Salary > 100000).ToListAsync();
Employee здесь ваш обычный класс: суффикс Props, который иногда мелькает в наших примерах, лишь соглашение об именовании, redb работает с вашим типом напрямую. А синтаксис у Raven и redb почти совпадает разница в том, куда уходит запрос: у Raven в RQL (собственный SQL-похожий язык поверх Lucene-индексов документного движка), у redb в SQL вашей СУБД, по её родным индексам. Причём схема приходит уже покрытой индексами: на практике вы не добавляете недостающие, а убираете лишние под свою нагрузку обычными средствами СУБД, как с любой таблицей.
Ставится за один пакет и ничего не ломает
Вот главный козырь, и он архитектурно неизбежен: redb это таблицы в вашей базе, а не отдельный сервер.
Добавили redb он завёл _objects, _values, _schemes и служебные таблицы, ваши таблицы не тронул. Можно перевести в объектную модель один агрегат, оставив остальное классической реляционкой в той же базе, в той же транзакции. А раз _objects обычная таблица с первичным ключом _id, ваши таблицы ссылаются на redb-объекты настоящим внешним ключом:
CREATE TABLE trends (
id bigserial PRIMARY KEY,
captured_at timestamptz NOT NULL,
metric numeric NOT NULL,
redb_object_id bigint NOT NULL REFERENCES _objects(_id) ON DELETE CASCADE
);
Тренд физически не повиснет на удалённом объекте за это отвечает constraint базы, а не ваш код. И достаётся всё одним JOIN обычным SQL: один запрос, один план, один бэкап.
Причём стек доступа остаётся вашим redb становится рядом с ним, а не вместо:
using var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
// объект через redb
var order = new RedbObject<Order> { Props = new() { Total = 4200m } };
order.id = await redb.SaveAsync(order);
// тренд через Dapper, тем же соединением к той же базе
await conn.ExecuteAsync(
"INSERT INTO trends(captured_at, metric, redb_object_id) VALUES (@t, @m, @id)",
new { t = DateTime.UtcNow, m = 4200m, id = order.id });
tx.Complete(); // redb и Dapper коммитятся атомарно
Одна СУБД, одно ADO.NET-соединение поэтому TransactionScope охватывает и запись redb, и вставку Dapper: либо оба, либо ни одного. EF Core, Dapper, сырой SQL работают по вашим таблицам без единой правки.
А теперь то же самое с Mongo или Raven: никак. Отдельный движок значит нет кросс-стор внешнего ключа, нет JOIN между вашими таблицами и их документами, ambient-транзакция их не охватит. «Тренд со ссылкой на сущность» превращается в ETL между двумя хранилищами, eventual consistency, два языка запросов, два бэкапа, два стека мониторинга. Для многих команд это и есть настоящая цена документной базы не производительность, а второй стек, который надо эксплуатировать. redb этой цены не берёт: он въезжает в базу, которая уже есть.
Весь SQL ваш, включая деревья
Раз запрос уходит в SQL вашей СУБД, вам доступна вся её мощь, а не подмножество, которое реализовал документный движок.
И _values это обычная таблица: _id_object, _id_structure и колонка на каждый тип (_String, _Long, _Numeric, _DateTimeOffset…). Значит по пространству значений всех свойств всех объектов можно спросить нативным индексируемым SQL напрямую:
-- объекты, у которых ЛЮБОЕ строковое свойство начинается с 'ACME-'
SELECT DISTINCT _id_object FROM _values WHERE _String LIKE 'ACME-%';
-- объекты, у которых ЛЮБОЕ числовое свойство равно 42
SELECT DISTINCT _id_object FROM _values WHERE _Long = 42;
Запрос не привязан к конкретному полю схемы он идёт по значению, поперёк всех типов и свойств. И это не «просто SQL, когда-нибудь проиндексируете»: на _values._String стоит GIN-индекс по триграммам (gin_trgm_ops) из коробки, так что LIKE, ILIKE, префикс, подстрока и даже regex по любому строковому свойству идут по индексу сразу. Числовые и датные значения покрыты композитными и покрывающими индексами при скоупе по свойству; под bare-поиск «хоть где» по одной числовой колонке включается отдельный индекс его строка уже лежит в схеме, по умолчанию выключенная ради стоимости записи. Всего redb ставит 40+ индексов, и управляете ими вы. В документной базе тот же «найди хоть где» требует wildcard-индекса по всему документу; здесь одна строка SQL по обычной таблице, уже покрытой индексами.
И вложенность запрашивается насквозь, одним запросом:
// фильтр по глубоко вложенному полю без .Include()-каскада и без N+1
var res = await redb.Query<Order>()
.Where(o => o.Customer.Address.City == "London")
.ToListAsync();
Ни цепочек .Include(), как в EF, ни N+1: путь Customer.Address.City компилируется в один SQL. Та же история с аналитикой агрегации, GROUP BY и оконные функции уходят в родной SQL, а не в отдельный aggregation-pipeline со своими правилами. А ярче всего мощь SQL видна на деревьях.
Иерархии в redb первый класс: поддерево, листья, корни, уровни компилируются в рекурсивные CTE на живом SQL, одинаково на всех трёх диалектах:
// листья ИМЕННО этого поддерева рекурсивный CTE от корня
var leaves = await redb.TreeQuery<Category>(root).WhereLeaves().ToListAsync();
И деревья полиморфные дети разных типов в одной иерархии, потому что тип каждого узла база знает. У документных баз этого нет: в Mongo обход графа это $graphLookup в aggregation-pipeline, со своими лимитами и в пределах коллекции; в Raven нативного рекурсивного обхода нет вовсе, иерархию тянут ссылками по id.
Насколько это чувствительное место, мы проверили на себе. Пользователь чат-продукта прислал баг: первый ход нового собеседника приезжал в модель с чужой историей. Корень был в query-провайдере ядра WhereLeaves() замещал корневой CTE вместо предиката поверх него, и «листья этого дерева» на деле означали «свежайшие листья схемы во всей базе». Дефект жил во всех шести провайдерах (Postgres, MSSql, SQLite × Free/Pro) и чинился одинаково. Мораль мимо redb: фильтр, который «не сработал», и фильтр, который «заменил собой область поиска», в коде выглядят одинаково, а на данных различаются катастрофически второй не даёт ошибки, он даёт правдоподобный ответ не про то. Такие вещи ловятся именно потому, что под капотом честный SQL, который можно прочитать и объяснить.
Один код от сервера до браузера
Тот же типизированный LINQ без изменений работает на PostgreSQL, MS SQL и SQLite и тянется от боевого сервера до Blazor WebAssembly и мобильного приложения. В embedded-профиле (SQLite Pro) redb чистый C#, materialization в managed-коде, без нативной зависимости: один код едет и на сервер, и в браузер. Mongo не embedded вовсе; у Raven embedded есть, но не «чистый managed внутри WASM».
«Разложить объект в кучу строк _values» звучит дороже, чем работает. SaveAsync принимает и одну сущность, и коллекцию целый батч на вставку уходит одним bulk-load СУБД (SqlBulkCopy у SQL Server, COPY … FROM STDIN у PostgreSQL), а не построчно. А на чтении Pro (он бесплатный) собирает объекты из строк параллельно, по числу ядер. И включённый прозрачный кэш объектов сверяется по _hash если объект в БД не менялся, redb отдаёт его из кэша без повторной материализации вовсе (опцией можно пропустить и сверку). Кэш прозрачный: LoadAsync отдаёт из него сам, без правок в вызывающем коде.
Честно про кэш: у RavenDB есть агрессивное клиентское кэширование по change-vector идея та же (неизменённое не тянуть и не десериализовать заново), так что здесь скорее паритет с Raven. А вот у драйвера MongoDB встроенного объектного кэша и identity-map нет там кэширование это ваш слой. И весь этот разговор про материализацию и кэш существует потому, что redb реконструирует объект из многих строк; документная база хранит его целиком реконструировать нечего. Это не столько преимущество redb, сколько доказательство, что его модель хранения не делает чтение дорогим.
Но интереснее апдейт. Загрузили 1000 объектов, поменяли одно поле у всех, или у сотни, поди разбери, у кого. Отдаёте ту же коллекцию в SaveAsync, и change tracking Pro сам находит, что и у кого изменилось: дифф деревьев состояния (в памяти против БД), параллельно по ядрам, и UPDATE только по изменившимся полям изменившихся объектов. Никаких dirty-флагов и ручного «поди разбери» вы не сообщаете, что поменяли, движок вычисляет сам.
Вот это уже не паритет. У MongoDB автоматического трекинга нет апдейты вы формируете руками ($set по нужным полям, и сначала определите нужные). У RavenDB сессия видит изменённые документы, но по умолчанию пишет документ целиком; дельта по полям это ручной Patch. redb пишет дельту по полям сам, батчем. (Bulk-вставка, для честности, есть у всех троих тут redb не эксклюзивен.)
Полный шардинг сейчас в разработке и доменная с провайдерной модель redb для него и сделана. RTTI-слой (типы, схемы, структуры) маленький и одинаковый: его дёшево держать копией на каждом узле, а объекты и значения разносить по разным базам, каждая со своим доменом. Ключи выдаёт один провайдер поверх мастер-секвенса генерация ключей в redb уже отделена от конкретной БД и батчится, так что мастер дёргается редко. Остаётся честная цена: кросс-шардовый запрос это fan-out по всем базам, реальная инженерия, а не галка «включить».
Батарейки в комплекте
То, что с документной базой пишут руками, в redb уже есть и доступно тем же типизированным API:
- Права на объект. Разрешения на уровне записи (
get_user_permissions_for_object, представлениеv_user_permissions) «это мой объект, только я его вижу» без самодельной ACL-обвязки. - Мгновенное мягкое удаление. Объект с поддеревом уходит в корзину одной пометкой из выборок исчезает сразу, а тяжёлый физический purge строк идёт фоном. Обычное каскадное
DELETEбольшого графа долгая операция; здесь удаление ощущается мгновенным, с корзиной и восстановлением. У Mongo/Raven trash-с-восстановлением пишут руками (флаг + фильтр в каждом запросе). - История изменений. Кто менял объект и когда встроено.
- Массовые операции. Обновить сотни тысяч объектов батчами, без таймаута.
- Полиморфные деревья. Оргструктура, где на уровнях разные типы (
division → team → person), одним деревом, а не «таблица на тип» и не JOIN-каскадами. - Прозрачные кэши, изолированные по домену метаданные, списки, объекты; включаются флагом, без вашего кода (объектный с hash-сверкой, см. выше).
- Тулинг рядом.
redb.Exportстримит схему и данные в JSONL (сжатие опционально) для бэкапа, миграции и репликации;redb.CLI(dotnet tool, командаredb) экспорт, импорт и инициализация схемы из терминала.
Ничего из этого не требует второго стека всё живёт в вашей же базе.
Границы честно
- Экосистема и managed-хостинг. Mongo и Raven облачные сервисы, готовые интеграции, зрелые драйверы под любой язык. Вокруг redb этого рынка пока нет: он моложе. (На порог входа это не влияет LINQ вы уже знаете, речь про инфраструктуру вокруг.)
- Full-text из коробки. У Raven встроен Lucene, у Mongo Atlas Search. У redb полнотекст это средства нижней СУБД (
tsvectorв PostgreSQL, full-text в SQL Server), а не turnkey-фича хранилища. - Проверенный сверхмасштаб. Полный шардинг у redb в разработке; у Mongo горизонтальное масштабирование налётано в проде годами. Если вам нужен доказанный петабайтный масштаб прямо сейчас это к Mongo.
Что выбрать
- MongoDB серверная документная модель с проверенным горизонтальным масштабированием и богатой экосистемой, когда связность с реляционным миром вторична.
- RavenDB .NET-native документная база с ACID, автоиндексами и встроенным полнотекстом, если вы готовы завести и эксплуатировать её как отдельную СУБД.
- redb когда данные типизированы, важны настоящий SQL, деревья и ссылочная целостность с остальной вашей реляционкой, и вы не хотите второй стек: объекты живут в той же базе, что и всё остальное, EF и Dapper остаются при вас, а один код едет от сервера до браузера.
Документная гибкость и реляционная строгость обычно подают как выбор «или-или». redb кладёт типизированные объекты и деревья в базу, которая у вас уже работает, и не просит взамен отдельный движок. Если это ваша ситуация, вторая база вам, скорее всего, не нужна.
Другие мои статьи redb.ru/articles, ещё на Хабре.