The Jerusalem PostTrump: US will stop funding to Israel after end of 2026, will 'help Israel if they need it'Daily MaverickLOCAL ELECTIONS 2026: Navigating ballots, candidates, and wards: Your guide to the 2026 local government electionsBBC NewsThird teen arrested after Halloween party stabbingsZDF heuteAktuelle Pressemitteilungen des ZDFBBC News BrasilQuem é Jesus de Miguel, jogador de futebol espanhol que morreu após choque com goleiro em partidaSBS 뉴스"배후는 북한" 미국이 지목하자…김여정 의미심장 발언Global NewsHarper offered Mark Carney government role, new memoir revealsNOSIndiase marine bevrijdt 20 opvarenden van door piraten gekaapte Iraanse vissersbootTagesschauLiveblog zu den Midterms: ++ Republikaner stecken TV-Werbung vor allem in Hochburgen ++The Hollywood ReporterMark Lazarus: MS NOW Not Experiencing the Viewer “Fatigue” Lachlan Murdoch Complained AboutESPN DeportesKnicks sufre ante Nets, pero gana y evita frío sótanoPremium TimesSowore, supporters protest at police headquarters, demand prosecution of alleged attackers in Ekiti
The Daily Newsstand · Free, Always
Tuesday, November 3, 2026

EF Core vs redb: граф объекта против 54 таблиц и Include

Translate

Сотрудник с адресами, контактами и словарями: в EF Core это 54 таблицы и 31 Include, в redb Pro один класс. Замеры на 100 тыс. объектов: где кто быстрее.

Один сотрудник из наших примеров в EF Core читается 62 SQL-запросами и почти две секунды. В redb Pro тот же сотрудник, на тех же данных и в том же виде, читается за 131 мс и девять запросов, а в повторном прогоне за 36 мс и пять. Разница не в том, что один движок «быстрый», а другой «медленный». Разница в том, сколько таблиц приходится собрать, чтобы получить из базы один бизнес-объект.

Ниже разбор: что именно сравнивали, почему граф дорог в нормальной форме, как redb Pro научился читать его без полной свёртки, и где EF Core честно быстрее. Таких мест немало, и они тоже в таблицах.

добавил что бы в статье долго не искать ссылку - страница результатов

Объект, который сравнивали

Все замеры идут на одной модели из проекта примеров. Сокращённо она выглядит так (полный класс в репозитории, в redb.Examples/Models/ExampleModels.cs):

[RedbScheme("Employee")]
public class EmployeeProps
{
    public string FirstName { get; set; } = string.Empty;
    public int Age { get; set; }
    public decimal Salary { get; set; }
    public string Department { get; set; } = string.Empty;

    public string[]? Skills { get; set; }
    public int[]? SkillLevels { get; set; }

    public Address? HomeAddress { get; set; }        // Address -> BuildingInfo -> массивы
    public Address? WorkAddress { get; set; }
    public Contact[]? Contacts { get; set; }          // Contact -> ContactDetail[]

    public RedbObject<ProjectMetricsProps>? CurrentProject { get; set; }
    public Dictionary<string, Address>? OfficeLocations { get; set; }
    public Dictionary<int, decimal>? BonusByYear { get; set; }
    public Dictionary<(int Year, string Quarter), string>? PerformanceReviews { get; set; }
}

Это типичный бизнес-объект: несколько скалярных полей, три адреса со вложенным зданием, массив контактов с метаданными, словари, ссылки на другие объекты. Для redb это один класс с атрибутом. Схема в базе создаётся из него при старте, миграций нет.

Тот же объект для EF Core

Чтобы сравнение было честным, EF Core получил ту же модель в нормальной форме, без JSON-колонок. Строили её не руками, а нашим же инструментом: команда redb flat из redb.CLI разворачивает классы redb в реляционные таблицы.

redb flat -a redb.Examples.dll -tp postgres --ddl-only

Без --ddl-only та же команда ещё и переносит данные из базы redb в плоские таблицы. Это пригодится и вне сравнения: когда аналитикам или отчётам нужна обычная реляционная копия.

На выходе получилось 54 таблицы и 59 внешних ключей: своя таблица у каждого вложенного класса, каждого массива и каждого словаря, а коллекция внутри элемента коллекции уходит на следующий уровень. Классы EF сгенерировал штатный dotnet ef dbcontext scaffold, их мы не трогали.

Дальше то, что разработчик на EF пишет сам. Чтобы прочитать сотрудника целиком, нужен 31 вызов Include (фрагмент):

public static IQueryable<EmployeeProps> WithGraph(this IQueryable<EmployeeProps> query) => query
    .Include(e => e.objects)
    .Include(e => e.EmployeeProps_Skills)
    .Include(e => e.EmployeeProps_HomeAddress!)
        .ThenInclude(a => a.EmployeeProps_HomeAddress_Building!)
        .ThenInclude(b => b.EmployeeProps_HomeAddress_Building_Amenities)
    .Include(e => e.EmployeeProps_Contacts)
        .ThenInclude(c => c.EmployeeProps_Contacts_Metadata)
    // ... ещё 25 цепочек
    .AsSplitQuery();

AsSplitQuery здесь обязателен. Один запрос с JOIN по 30 независимым коллекциям перемножил бы строки: навыки на контакты, контакты на офисы и так далее. С разбиением EF отправляет по запросу на коллекцию, отсюда 31 запрос на каждое чтение графа. Подробнее о том, почему Include в больших моделях становится отдельной профессией, мы писали на странице «Почему redb».

И последнее: EF возвращает сущности 54 таблиц, а приложению нужен бизнес-класс. Значит, нужен маппинг, который собирает EmployeeProps из этих сущностей. redb отдаёт RedbObject<EmployeeProps> сразу, без промежуточного слоя.

Сколько кода понадобилось стороне EF, чтобы вернуть тот же объект:

Что

Строк

Классы-сущности от scaffold, 54 файла

1 124

DbContext

930

Граф Include и маппинг сотрудника в бизнес-класс

около 360

Время маппинга входит в замер EF, потому что сравниваем путь до одного и того же результата: готового бизнес-объекта. Для бизнеса важнее другое. Каждое изменение модели в EF означает миграцию, правку сущностей, цепочек Include и маппинга. В redb меняется только класс.

Как redb хранит граф

В redb нет таблицы на каждый класс. Объекты лежат в одной таблице, значения полей в другой, по строке на поле. Вложенный класс, элемент массива или запись словаря это тоже строки значений, связанные с родителем. Граф любой глубины хранится в тех же двух таблицах.

Отсюда простое следствие для чтения. Чтобы загрузить N объектов целиком, redb Pro читает строки объектов, а затем все значения всех N объектов одним запросом; если значения ссылаются на элементы справочников, добавляется ещё один запрос за ними. Вложенные ссылки на другие объекты добавляют по такому кругу на каждый уровень глубины. Число запросов зависит от глубины ссылок, а не от числа полей и коллекций. Рядом проверяются метаданные схемы и типов, а с кэшем свойств для объектов, которые уже в кэше, вместо чтения значений идёт сверка хешей. Дерево свойств собирается в памяти. Поэтому сотрудник с 30 коллекциями в наших прогонах читается девятью запросами в первом прогоне и пятью в повторе, но не тридцатью одним.

Но загрузить объекты по готовому списку id просто. Сложнее найти этот список: отфильтровать, отсортировать, взять страницу по полям, которые лежат строками.

Как было: свернуть всё, потом фильтровать

Раньше запрос LINQ по полям собирал объект обратно «разворотом». Он читал все строки значений нужных полей по всей схеме, сворачивал их через GROUP BY по объекту и только потом применял фильтр, сортировку и страницу.

Цена такого запроса зависела от размера схемы, а не от ответа. Найти одного сотрудника или ни одного стоило одинаково: база сначала сворачивала всё. В версии 3.7.1 появился префильтр, который сужает набор строк до свёртки (о нём отдельная статья). Он помог там, где условие удавалось выразить на уровне строки значений, но сама свёртка осталась.

Как стало: потоки

В redb Pro 4.3.0 появилась новая форма запроса, внутри мы зовём её «потоками». Запрос теперь ведут объекты схемы, а каждое поле присоединяется своим LEFT JOIN к таблице значений. Ничего не сворачивается. Фильтр, сортировка и страница стоят рядом с соединениями, и сервер может остановиться, когда набрал страницу.

Вот как это выглядит на запросе с тремя условиями, сортировкой по зарплате и страницей со смещением 9000. SQL упрощён: убраны параметры и проверка корзины, форма запроса настоящая.

Было:

WITH pvt_cte AS (
  SELECT v._id_object,
    (array_agg(v._string)  FILTER (WHERE v._id_structure = 1000019 AND v._array_index IS NULL))[1] AS "Position",
    (array_agg(v._long)    FILTER (WHERE v._id_structure = 1000017 AND v._array_index IS NULL))[1] AS "Age",
    (array_agg(v._numeric) FILTER (WHERE v._id_structure = 1000020 AND v._array_index IS NULL))[1] AS "Salary"
  FROM _values v
  WHERE v._id_structure = ANY(ARRAY[1000019,1000017,1000020])
    AND v._id_object IN (SELECT _id FROM _objects WHERE _id_scheme = 1000014)
  GROUP BY v._id_object)
SELECT o.* FROM pvt_cte JOIN _objects o ON o._id = pvt_cte._id_object
WHERE "Position" <> '' AND "Age" >= 30 AND "Salary" > 70000
ORDER BY "Salary" DESC LIMIT 100 OFFSET 9000;

Стало:

WITH pvt_cte AS (
  SELECT pvt._id_object, pvt."Position", pvt."Age", pvt."Salary" FROM (
    SELECT o_src._id AS _id_object, f1._string AS "Position", f2._long AS "Age", f3._numeric AS "Salary"
    FROM _objects o_src
    LEFT JOIN _values f1 ON f1._id_object = o_src._id AND f1._id_structure = 1000019 AND f1._array_index IS NULL
    LEFT JOIN _values f2 ON f2._id_object = o_src._id AND f2._id_structure = 1000017 AND f2._array_index IS NULL
    LEFT JOIN _values f3 ON f3._id_object = o_src._id AND f3._id_structure = 1000020 AND f3._array_index IS NULL
    WHERE o_src._id_scheme = 1000014
    OFFSET 0) pvt
  WHERE pvt."Position" <> '' AND pvt."Age" >= 30 AND pvt."Salary" > 70000
  ORDER BY pvt."Salary" DESC, pvt._id_object DESC LIMIT 100 OFFSET 9000)
SELECT o.* FROM pvt_cte JOIN _objects o ON o._id = pvt_cte._id_object AND o._id_scheme = 1000014
ORDER BY pvt_cte."Salary" DESC, pvt_cte._id_object DESC;

В первом плане хеш-агрегат на 26 МБ, хеш-соединение и около 38 тысяч прочитанных страниц. Во втором три слияния (Merge Left Join) по индексу, где строки поля уже упорядочены по объекту: без хешей, без сортировки, около 7 500 страниц, и расход памяти не растёт с размером схемы. На базе в 200 тыс. объектов эта страница шла 702-740 мс, стала 362 мс; с холодного диска 1,8-2,3 с против 0,6-0,8 с.

Широкая строка при этом получается та же, что раньше, поэтому OR, NOT и арифметика между полями работают без изменений. Конструкция OFFSET 0 не пускает PostgreSQL опускать фильтр внутрь потоков: в таблице значений колонки связаны между собой, а статистика считает их независимыми и ошибается в оценке строк в десятки раз.

Потоками читаются поля, у которых на объект одно значение: обычные поля, записи словаря и поля классов внутри них, элементы справочника. На PostgreSQL и SQL Server это работает и со страницей, и без неё; на SQLite обычному списку пока нужен Take. Массивы, DISTINCT и сортировка, вычисленная выражением над базовым полем, остаются на старой форме.

Индексы: порядок колонок решает, на какой вопрос индекс отвечает

Самая полезная находка этой работы касалась не формы запроса, а индексов. В штатной схеме индексы значений были построены как «структура, объект, значение». Такой индекс отвечает на вопрос «какое значение у этого объекта», но не на вопрос «у каких объектов такое значение». Фильтру нужен второй.

В 4.4.0 на всех трёх базах появились индексы «структура, значение, объект». Они дают две вещи:

  • Узкое условие ведёт запрос. Если в фильтре есть избирательное условие (равенство, IN, закрытый диапазон), запрос начинается с индекса значений, а остальные поля присоединяются к найденным объектам. Поиск одного номера заказа среди 689 тыс. записей на копии рабочей базы: было 2,4 с, стало 30 мс.

  • Ведущая форма для страниц. Страница, отсортированная по полю, идёт по индексу этого поля в порядке страницы, остальные поля читает точечно и останавливается на сотой строке. Первая страница запроса с тремя условиями: было 229 мс, стало 2 мс. Это есть в PostgreSQL Pro и SQLite Pro.

Что дали потоки на трёх базах

Замеры на базе примеров (100 тыс. сотрудников, 8,4 млн строк значений на 12-13 октября 2026), тёплый кэш, время в миллисекундах.

PostgreSQL:

Запрос

Разворот

Потоки

Потоки с новыми индексами

счёт с двумя условиями

162-165

153

61

страница с фильтром и сортировкой

365-382

305

183

страница, только сортировка

335-340

146

84

счёт с Contains по массиву

350-359

110

43

пакет агрегатов

370-389

146

66

Без новых индексов потоки не оказались медленнее разворота ни в одном из одиннадцати замеренных запросов, с индексами быстрее в 2-8 раз.

SQL Server. Здесь разрыв больше: разворот стоил секунды и до 1,2 ГБ памяти на запрос.

Запрос

Разворот

Потоки

Быстрее в

страница по зарплате, широкий фильтр

1 090

66

17

страница по возрасту и фамилии, узкий фильтр

7 327

157

47

счёт, фильтр по отделу

2 917

88

33

группировка по отделу

3 217

116

28

ранг в отделе по зарплате, 5 тыс. строк

2 009

63

32

Без единого нового индекса, на штатной схеме, выигрыш получается в 4-25 раз.

SQLite. Сначала оказалось, что главное здесь не форма запроса, а соединение. Штатный кэш страниц SQLite 2 МБ, файл базы 2,5 ГБ. Отображение файла в память ускорило в 2-3 раза любой запрос, который читает больше нескольких тысяч строк, включая старую форму. С индексами «значение вторым» потоки быстрее разворота в 8-22 раза; запросам, которые читают почти всю схему, индексы не помогают.

Рабочие данные

Синтетика хороша для повторяемости, но настоящие проблемы видны только на живых данных. Отладка шла на копии рабочей базы приложения управления перевозками: 809 тыс. объектов, 38,2 млн строк значений.

Запрос

Было

Стало

один номер заказа, страница (PostgreSQL)

2 464

30

страница по весу, широкий фильтр (PostgreSQL)

3 895

9

месяц маршрутов, счёт (SQL Server)

639

63

месяц маршрутов, страница по водителю и рейсу (SQL Server)

837

165

В строках SQL Server заметную часть выигрыша дала не форма запроса, а передача списка в IN. Раньше список шёл одной строкой через STRING_SPLIT, сервер ждал от неё 50 строк, получал 80 тысяч и строил план под неверную оценку. Отдельные параметры ускорили в 2-5 раз и старую форму, и новую.

Рабочая база показала и то, чего не было в синтетике: запросы без выигрыша и ошибку оценки строк, которую пришлось обходить отдельно. Запросы реального приложения на этой копии дали 157 верных ответов из 157.

Ускорение как проверка правильности

Каждый ответ новой формы мы сверяли не со старой формой, а с подсчётом в памяти по загруженным объектам. Сравнение «с планировщиком и без» ошибок не видит, если обе формы ошибаются одинаково. Такая сверка нашла 15 ошибок результата в старом и общем коде: объект без значения поля, который пропадал из Where(x => x.F == null), агрегаты по дереву, которые считали по всей схеме, счёт страницы, который не учитывал Take и Skip, и другие. Все 15 исправлены в redb Pro на трёх базах; регрессионные тесты проходят с планировщиком и без него.

redb Pro против EF Core: методика

Теперь само сравнение. Цифры ниже взяты из прогона, который опубликован на странице результатов, там же все примеры построчно.

  • Стенд: AMD Ryzen 5 5500U, 4 ядра и 4 ГБ памяти на контейнер; PostgreSQL 18, SQL Server Developer, SQLite.

  • Версии: прогон от 2 ноября 2026, .NET 10; redb Pro собран из исходников после выпуска 4.5.1; EF Core 10.0.13 с Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3.

  • Данные: больше 100 тыс. объектов и 8,5 млн свойств на момент прогона. Обе базы заполняются одним генератором (пример E203), содержимое совпадает, различаются только id. Часть примеров создаёт объекты и запускалась разное число раз, поэтому к моменту прогона в базах было около 100 и 110 тыс. сотрудников. На порядок цифр это не влияет.

  • Сравнивается только redb Pro. Пакеты Pro (redb.Core.Pro, redb.Postgres.Pro, redb.MSSql.Pro, redb.SQLite.Pro) закрыты по исходникам, но бесплатны и не требуют ключа на всей линейке 3.x и 4.x, в том числе в коммерческой эксплуатации; лицензирование вернётся только с версии 5.0, а уже используемые версии останутся бесплатными. Подключаются вызовом AddRedbPro() при настройке. Free читает запросы старой формой: сначала сворачивает значения, потом фильтрует. Потоки и ведущая форма есть только в Pro. Ускорение Free это отдельная работа и отдельный разбор.

  • EF Core только на PostgreSQL. Двойники EF написаны и замерены на PostgreSQL. На SQL Server и SQLite они не запускались. Нормальная форма там та же, те же 54 таблицы и тот же разлёт запросов, поэтому картина по сложным графам будет той же; цифр по ним мы не приводим.

  • Только штатный LINQ с обеих сторон, без сырого SQL. Где EF не может выразить запрос (оконные функции, обход дерева с глубиной, мягкое удаление), в таблице стоит N/A без времени.

  • Индексы получили обе стороны. Для EF это то, что разработчик поставил бы под фильтры сам: триграммный GIN для Contains, text_pattern_ops для StartsWith, B-tree на поля фильтров и сортировок, составные индексы на таблицы коллекций «значение, владелец». redb ставит свои индексы сам: индексы значений с каждым обновлением модуля базы или схемы, индексы потоков Pro при старте, если их нет. Исключение: администратор оставил изменения схемы за собой (AutoApplyDatabaseUpgrades = false).

  • Время до бизнес-объекта с обеих сторон. У redb в замер входит построение RedbObject<EmployeeProps>, у EF маппинг сущностей в тот же класс.

  • Настройки: в PostgreSQL выключен JIT для обеих сторон; EF получает новый контекст из фабрики на каждый пример, отслеживание изменений включено; построение модели и первое соединение до таймера.

  • Три прогона: первый; повтор на прогретых данных, в котором redb работает с включённым кэшем свойств: в начале прогона кэш пуст и наполняется по ходу, поэтому повторное чтение объекта внутри примера уже может идти через кэш; третий, который читает через наполненный кэш. У EF кэша свойств нет: с третьим прогоном сравнивается его повтор. Таблицы по примерам ниже взяты из первого прогона, где кэш не участвует.

Отдельно о шуме. Машина общая, на ней параллельно идёт другая работа. Отдельные цифры от прогона к прогону плавают, иногда в полтора-два раза, поэтому разницу меньше полутора раз мы не считаем ни выигрышем, ни проигрышем. Порядок при этом виден устойчиво.

Цифры

Медиана по примерам, где обе стороны дали результат, PostgreSQL:

Прогон

Примеров

redb Pro

EF Core

EF медленнее в

первый

110

107 мс

816 мс

7,7

повтор

102

68 мс

790 мс

11,6

с кэшем свойств

102

61 мс

790 мс

13,0

Все примеры построчно, по всем трём базам и с переключением прогонов, на странице redb.ru/results.

Если разложить по примерам (первый прогон, 109 сравнимых пар):

Результат

Примеров

redb Pro быстрее в 5 раз и больше

54

из них в 10 раз и больше

24

redb Pro быстрее в 1,25-5 раза

26

примерно наравне

13

EF Core быстрее

16

Где разрыв большой, это запросы, которые возвращают сотрудников целиком. Первый прогон, мс:

Пример

Запрос

EF Core

redb Pro

Запросов EF / redb

E002

загрузка сотрудника по id

1 951

131

62 / 9

E010

Where по зарплате, 100 объектов

927

187

31 / 5

E041

фильтр по полю на третьем уровне вложенности

765

62

31 / 7

E051

Contains по массиву, несколько вариантов через ИЛИ

1 932

76

31 / 4

E060

ContainsKey по словарю

864

76

31 / 5

E080

сортировка по зарплате

2 835

136

58 / 8

E183

FirstOrDefaultAsync

2 919

17

32 / 5

E187

изменение существующего объекта

843

51

54 / 17

Время и число запросов из первого прогона, кэш свойств в нём не участвует. Число запросов redb включает обращения к метаданным схемы. Загрузка E002 в обоих вариантах идёт в два шага, сначала первый сотрудник, потом он же по id, поэтому у EF дважды по 31 запросу.

Почему EF здесь проигрывает, видно из второй колонки «запросов». Каждый запрос, который возвращает сотрудников, у EF превращается в 31 SQL-запрос по таблицам графа плюс сборку сущностей и маппинг в бизнес-класс. Фильтр сам по себе у EF быстрый, индексы на месте. Дорого всё, что после фильтра.

Запись тоже в плюс redb, хотя и скромнее: массовая вставка сложных объектов (E000, первый прогон) 873 мс против 1 515, заполнение базы на 100 тыс. сотрудников (E203) 335 объектов в секунду против 237.

Где EF Core быстрее

Здесь без оговорок: на плоских данных и скалярных операциях плоская таблица выигрывает. Первый прогон, мс:

Пример

Запрос

EF Core

redb Pro

E164

операции с базовыми полями одного объекта

19

50

E089

дерево из 101 узла, по одному сохранению на узел (антипаттерн нарочно)

702

1 480

E150

сумма и среднее по элементам массива всех сотрудников

104

491

E150A

агрегат по элементу массива с заданным индексом

26

165

E193

среднее с фильтром

77

161

E194

минимум и максимум с фильтром

160

268

E128

простая группировка

294

454

E142

группировка по элементам вложенного массива

580

842

E089 в примерах сделан медленным намеренно, с обеих сторон: каждый узел сохраняется отдельным вызовом, у redb CreateChildAsync, у EF SaveChangesAsync. Он показывает, сколько стоит одиночное сохранение, и служит фоном для правильного пути, пакетной вставки (E088). Там redb быстрее: 77 мс против 218.

Ещё восемь примеров в пользу EF: AnyAsync с условием, счёт и простой запрос по дереву, сумма по полю верхнего уровня, среднее по базовому полю, сумма с фильтром, объект без свойств и смешанный пакет. Примерно наравне ещё тринадцать: CountAsync, AnyAsync, AllAsync, проекция, Min и Max, сумма по базовому полю, группировка с фильтром, агрегаты с фильтром, условие по полю и массиву сразу, перенос узла и выборки по дереву.

Механизм у всех один. Плоская таблица держит поля сотрудника в одной строке, а массив в одной дочерней таблице. Создать узел дерева это одна вставка в одну таблицу; посчитать среднее это один проход по узкой колонке. В redb объект это строка объекта плюс строка на каждое поле, массив это строка на каждый элемент, а агрегат сначала отбирает объекты схемы, потом читает значения другого поля и соединяет их по объекту. Когда граф не нужен, эта работа ничего не даёт взамен.

Показательно, как меняется картина, когда в агрегате появляется вложенное поле. Среднее по базовому полю у EF быстрее (E145: 30 мс против 50). Пакет агрегатов, где есть HomeAddress.Building.Floor (E127), у EF требует соединений с таблицами адреса и здания, и результат обратный: 136 мс у redb против 426 у EF.

Кэш свойств: когда он нужен

У redb есть кэш объектов в памяти процесса. Он хранит RedbObject целиком, вместе с Props, по id объекта и отдаёт его, только если хеш строки в базе не изменился. Объект из списка, у которого хеш пришёл вместе со строкой, отдаётся без лишнего запроса; LoadAsync по id делает одну короткую сверку хеша, а граф берёт из памяти. Изменился объект в базе, сменился хеш, и следующее чтение загрузит его заново.

Отсюда понятно, где кэш выгоден: много чтений одних и тех же объектов, которые к тому же меняются. Справочники, карточки, активные заказы, всё, что приложение перечитывает постоянно. Для такой нагрузки не нужно поднимать Redis и самим следить за его согласованностью с базой: проверка по хешу уже встроена. Кэш свой у каждого процесса и между узлами не делится, правильность на кластере держит та же проверка хеша.

Где кэш не выгоден, видно и по прогону на сайте. На PostgreSQL третий прогон, читающий через наполненный кэш, в 22 примерах из 152 быстрее повтора больше чем в 1,25 раза, в 128 разница в пределах шума, в двух медленнее. Запросы, которые каждый раз берут новые объекты, кэш не ускоряет, а на очень коротких запросах сверка хеша может добавить миллисекунды.

Настройки в RedbServiceConfiguration:

Настройка

По умолчанию

Смысл

EnablePropsCache

false

включить кэш

PropsCacheMaxSize

10 000

предел записей; запись это один объект, и объект со вложенными ссылками на другие объекты занимает несколько записей

PropsCacheTtl

60 минут

срок жизни записи от момента, когда её положили в кэш

Вытеснение по давности использования: когда записей становится больше предела, за раз уходит десятая часть самых давно прочитанных. Если вытеснение частое, кэш работает вхолостую: тратит память и почти не попадает.

Как выбрать размер. Считать нужно не изменённые объекты, а прочитанные за время жизни записи, вместе с теми, на которые они ссылаются. Например, если за день меняется около 10 тыс. объектов, а читаются заметно шире, разумная отправная точка 100 тыс. записей, и PropsCacheTtl тогда стоит поднять с часа до рабочего дня. Дальше решают наблюдения: GetStats() у кэша показывает число записей, попадания, промахи и долю попаданий, а в журнале появляется предупреждение «Props cache is full», когда за 10 секунд вытеснено больше пятой части предела. Частые предупреждения означают одно из двух: поднять предел или выключить кэш.

Частые изменения: обслуживание базы

Изменение объекта в redb переписывает строки значений: по умолчанию удалением и вставкой, а стратегия ChangeTracking в Pro пишет только изменившиеся значения. Колонки значений проиндексированы, поэтому PostgreSQL почти не может обновить строку «на месте» (HOT): аудит рабочей базы насчитал 790 тыс. обновлений таблицы значений за 9,5 часа и три HOT среди них. Отсюда мёртвые строки и разбухание индексов. Это не особенность redb, а обычная гигиена PostgreSQL для любой таблицы с частыми изменениями индексированных колонок.

PostgreSQL. Штатный порог автоочистки, 20% строк таблицы (autovacuum_vacuum_scale_factor = 0.2), на таблице значений в 38 млн строк означает миллионы мёртвых строк до первой очистки. Для таблицы значений порог стоит снизить отдельно. Раздутые индексы проверяются через pgstatindex и перестраиваются REINDEX INDEX CONCURRENTLY, без блокировки записи. В одном из рабочих проектов это сделано ночным заданием на стороне приложения: VACUUM (ANALYZE) таблиц значений и объектов, затем перестройка каждого B-tree индекса, у которого пустого места в листьях больше 30%, от меньших к большим, под рекомендательной блокировкой, чтобы два узла не запустили задание одновременно. Прерванный REINDEX CONCURRENTLY оставляет недействительный индекс *_ccnew, задание пишет его в журнал для ручного удаления. Насколько это важно: после такой гигиены рабочая база уменьшилась с 79 до 15 ГБ.

SQL Server. То же по смыслу: регулярное задание, которое по степени фрагментации делает ALTER INDEX ... REORGANIZE или REBUILD, и обновление статистики.

redb не ставит своё расписание очистки и перестройки сознательно: эти решения зависят от объёма данных и принадлежат администратору базы. В исходниках провайдеров на GitHub есть папка sql/maintenance со скриптами для разовых операций с индексами.

Такую гигиену соблюдают в любой базе с частыми изменениями, будь то PostgreSQL, SQL Server или другая, с redb и без него. Подробный разбор выходит за рамки этой статьи, тема много раз описана в документации баз и в статьях администраторов.

Скорость разработки

Цифры выше про время запроса. Для бизнеса не меньше значит время разработчика.

В redb модель это класс C#. При старте схема в базе сверяется с классом: новое поле, удалённое поле, смена типа применяются сами, файлов миграций нет. Удаление поля из класса удаляет его и из схемы вместе со значениями; для выкаток, где старая и новая версии делят базу, это отключается (DefaultStrictDeleteExtra = false). Когда меняется смысл данных, а не только форма (поле разделили на два, значения пересчитали), в Pro для этого есть миграции данных IRedbMigration<T>. Но основная масса правок модели проходит без них.

Сильнее всего это чувствуется на этапе, когда бизнес ещё ищет форму продукта. Модель меняется каждый день: добавили поле в заказ, вынесли адрес в отдельный класс, превратили строку в словарь. В EF каждая такая правка это миграция, её накат и откат, правка сущностей, цепочек Include и маппинга. В нашем примере это 54 сущности, контекст на 930 строк, 31 Include и около 360 строк маппинга, и всё это нужно держать выверенным. В redb правится один класс, и с ним сразу работают как с бизнес-объектом.

Проекции тоже есть: Select берёт поля любой глубины, включая записи словарей, и сервер читает только их (сокращено из примера E075):

var rows = await redb.Query<EmployeeProps>()
    .Where(x => x.Salary > 60000m && x.HomeAddress!.Building!.Floor > 10)
    .OrderByDescending(x => x.Salary)
    .Take(100)
    .Select(x => new
    {
        x.Props.FirstName,
        x.Props.Salary,
        HomeCity = x.Props.HomeAddress!.City,
        HomeFloor = x.Props.HomeAddress!.Building!.Floor,
        HqCity = x.Props.OfficeLocations!["HQ"].City
    })
    .ToListAsync();

Честности ради: на проекции EF не проигрывает, это одна выборка по нескольким колонкам, и в прогоне E075 стороны наравне (271 мс у EF, 292 у redb). Выигрыш redb здесь не в скорости, а в том, что проекция пишется по тому же бизнес-классу, без знания, в какой из 54 таблиц лежит поле.

Что в итоге получает команда:

  • Модель это один класс. Нет DbContext, Fluent API и сущности на каждую коллекцию.

  • Схема без файлов миграций. Новые и удалённые поля, смена типа применяются при старте.

  • Бизнес-объект сразу. LoadAsync и Query<T>().ToListAsync() возвращают RedbObject<T> со всем графом, без Include и маппинга.

  • LINQ по любой глубине. Фильтры, сортировки и проекции по вложенным классам, массивам и словарям пишутся как обычные выражения C#.

  • Деревья из коробки. Иерархия объектов и запросы по ней (TreeQuery, WhereHasAncestor, WhereHasDescendant), глубина задаётся параметром.

  • То, чего нет в штатном LINQ EF Core. 45 двойников EF из 155 остались с N/A: запросы по дереву любой глубины (17 примеров), оконные функции RANK, ROW_NUMBER, LAG/LEAD, NTILE, нарастающие суммы (11 примеров), мягкое удаление в корзину с освобождением уникальных ключей, уникальные ключи по содержимому вложенного класса, поддерева или элементов списка.

  • Без обходных путей. Двойникам EF понадобился OrderBy(_id) в 54 примерах только ради того, чтобы Take с разбиением запросов не терял детей графа; DISTINCT ON пришлось эмулировать группировкой и дозагрузкой. redb этого не требует.

  • Аналитика в LINQ. GroupBy с ключами из вложенных полей и элементов справочника, группировка по элементам массивов любой глубины (GroupByArray), окна WithWindow с Win.RowNumber, Rank, Lag, Lead, Ntile, пакет агрегатов за один проход (AggregateAsync); всё это работает и по дереву.

  • Индексы из коробки. Для EF индексы под фильтры подбирались руками; redb приходит со своими и обновляет их сам при смене версии своего модуля базы, Pro достраивает индексы потоков при старте.

  • SQL виден до выполнения. ToSqlStringAsync() показывает текст запроса, группировки или окна, а в Pro ещё и решение планировщика в комментариях.

  • Наблюдаемость. Каждая команда базы это отдельный span OpenTelemetry из RedbActivitySource.

  • Несколько узлов на старте. Схема создаётся и обновляется под блокировкой, один раз, даже если узлы стартуют одновременно.

  • Одна модель на три базы. PostgreSQL, SQL Server и SQLite, код приложения тот же; redb export и redb import переносят базу целиком между ними.

  • Реляционная копия по требованию. redb flat разворачивает данные в нормальную форму для отчётов и аналитики.

Что выбирать

Из цифр следует простое правило.

redb там, где объект это граф. Заказ с позициями, адресами и историей статусов. Карточка клиента с контактами, документами и словарём атрибутов. Маршрут с точками, грузами и рейсами. Такие объекты в нормальной форме разлетаются на десятки таблиц, и каждое чтение собирает их обратно. redb хранит граф в двух таблицах, читает его пакетом и меняет схему вместе с классом, без миграций.

EF Core и плоская таблица там, где данные плоские. Журналы, события, показания датчиков, временные ряды: много строк с несколькими скалярными полями, массовая запись и агрегаты по колонкам. Хранить такое в redb можно, но это антипаттерн: каждая строка журнала превращается в строку объекта и по строке на поле, запись и место умножаются, а выигрыша от графа нет.

Вместе. redb не требует отдельной базы и не спорит с EF за модель. Графовые объекты живут в redb, журналы и тренды в обычных таблицах рядом, в той же базе. А если граф нужно отдать отчётам или аналитике в реляционном виде, redb flat развернёт его в нормальную форму вместе с данными.

Итог

Сравнение на 100 тыс. сотрудников показало две вещи, и обе честные. На сложных графах redb Pro быстрее EF Core на порядок: медиана в 7,7-13 раз, отдельные запросы в десятки и сотни раз. На плоских данных и скалярных агрегатах быстрее EF Core, иногда в несколько раз.

Скорость redb Pro на графах держится на трёх вещах: граф хранится в двух таблицах и читается пакетом, запрос по полям идёт потоками вместо полной свёртки, а индексы «структура, значение, объект» дают узкому условию и странице начать с нужного места. Все цифры прогона опубликованы на redb.ru/results. Примеры, двойники EF и плоская схема лежат в открытом репозитории, прогон можно повторить на своём железе. redb Pro для этого ничего не стоит: на линейках 3.x и 4.x пакеты бесплатны и ключ не нужен, в том числе в коммерческой эксплуатации.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.