REDB: индексы, или почему на любую схему — это быстро

от автора

redb index

redb index

REDB изнутри, статья 1.1: индексы, или почему 3 таблиц на любую схему — это быстро


В предыдущей части цикла разобрали 13 таблиц REDB: как устроены objectsvaluesstructures, как RTTI-хранение значений отличается от старого EAV-паттерна, зачем нужен scheme_metadata_cache. Если не читали — начните с неё, без понимания схемы дальше тяжело.

В этой статье — то, что обычно идёт следующим вопросом: «А индексы где? У вас же значения всех полей лежат в одной таблице. Любой WHERE — это Seq Scan по миллионам строк».

Это статья 1.1, а не 2 — потому что она прямое продолжение разговора про физическое хранение. Глубокое погружение в C# — это статьи 3-5 цикла: Code-first схемы (SyncSchemeAsync<T>), CRUD (SaveAsync/LoadAsync), LINQ-транслятор. Здесь разговор остаётся в плоскости БД и DDL.

Цифры, на которые опираемся, — с реального прода: TSUM, логистическая система, обслуживает движение грузовиков и заказов через РЦ.

TL;DR: В классической EF-схеме индексы плодятся вместе с таблицами. В REDB набор индексов задизайнен один раз в DDL и обслуживает любую бизнес-схему — добавление 33-го класса не требует ни одной новой строки в redbPostgre.sql. Активная поверхность под бизнес-данные любого класса — всего 2 таблицы (_objects + _values), плюс 2 под справочники и 3 под RTTI; остальное — инфраструктура, неизменная от количества классов. На проде TSUM это даёт 999 заказов / 991 мс на 2 ядрах без тюнинга PG и без кэша уровня фреймворка: один SELECT на 999 существующих маршрутов (139 мс), bulk-save 32 изменённых через COPY (154 мс), 967 неизменённых отсечены до БД через ComputeHash().


Возражение скептика

Возражение, которое стабильно прилетает после публикации статьи 1, выглядит так:

«Окей, типизированные колонки лучше, чем строковый EAV. Но у вас всё равно 9.7 миллиона строк в _values. Любой осмысленный запрос — WHERE Salary > 80000 или WHERE OrderDate >= '2026-06-01' — это поиск иголки в стоге. Без индексов на каждое отдельное поле каждой схемы вы будете жить в Seq Scan».

Возражение растёт из ассоциации со старыми EAV-схемами вида attribute_name TEXT, value TEXT — там действительно индекс по value бесполезен, потому что значения разнотипные, требуют cast на лету, и селективность по attribute_name низкая.

REDB устроен иначе на двух уровнях. Во-первых, значения уже разложены по типизированным колонкам (_LongStringNumericDateTimeOffset…) — никакого cast. Во-вторых, у каждой строки values есть idstructure — bigint-идентификатор поля схемы. На 401 поле в TSUM это означает, что фильтр WHERE idstructure = X сразу отсекает ~99.75% строк таблицы ещё до того, как посмотрим на значение.

Но это объяснение архитектурное. Вопрос практический: как именно зашиты индексы, чтобы facet-запрос отрабатывал за миллисекунды на 9.7M строк? Разберём по шагам.


Главное архитектурное наблюдение: индексы инвариантны от количества классов

логи (скорость выполнения и материализация запросов из >9m параметров

логи (скорость выполнения и материализация запросов из >9m параметров

В классической EF-раскладке количество таблиц растёт линейно с количеством бизнес-сущностей. На каждую таблицу в среднем 3-5 индексов: PK, индексы на FK, индексы под бизнес-фильтры. Поэтому общее количество индексов в БД растёт мультипликативно с ростом проекта.

Возьмём TSUM как пример. Вот что лежит в tsum.Domain/Entities:

  • TransportationRoute (маршрут перевозки)

  • TransportationPoint (точка маршрута)

  • DriverVehicleShippingPointYardPlace

  • SliceSettingsSliceSnapshotTransportSnapshotTransportNorm

  • SpecialRcSettingsTonnageGroupSettingsGarageState

  • TsumAdUserRefUserFilterPreference

15 «корневых» сущностей. Но это только начало. Только в одном TransportationRoute мы видим ссылки на справочники: DriversVehiclesCarMarksShippingPointsBusinessTypesYardPlaces (×2 — PlaceTo / PlaceFrom), LoadingZonesTransportStatusesDeliveryStatusesTripRisks. И в каждой из других сущностей — свой набор лукапов: типы кузова, статусы готовности, причины задержек, классификаторы рейсов.

В классической схеме каждый такой справочник — отдельная таблица: DriversVehiclesCarMarks, …, плюс audit/history-таблицы (TransportSnapshotSliceSnapshot — это явно snapshot-сущности), плюс M2M-таблицы для коллекционных связей. Реалистичная оценка для TSUM в классической раскладке — 60-80 таблиц.

С 60-80 таблицами и средними 3-4 индексами на каждую — между 200 и 320 индексов, которые надо проектировать, поддерживать, переиндексировать, дропать когда устарело. Каждый релиз в EF-проекте включает миграцию вида AddColumn + CreateIndex. На второй год проекта DBA или старший разработчик обходит схему с фонариком и удаляет дубли (которые накопились из-за того, что разные разработчики накатили похожие индексы под чуть разные запросы).

В REDB этот процесс выглядит по-другому. На те же 32 схемы / 401 свойство в TSUM физически — 2 первичных ключаpk__objects и pk__values. Полный список индексов зашит в redbPostgre.sql и составляет около 50 штук на всю систему. Не на класс. На систему.

И здесь важно сразу снять одно недопонимание. В статье 1 я перечислил 13 таблиц REDB, и читатель легко уносит мысль «значит на каждую схему работают все 13». На самом деле под бизнес-данные конкретного класса в горячем пути активны существенно меньше таблиц. Раскладка такая:

  • Хранение данных — 2 таблицы: objects (заголовок) и values (значения свойств). Любой Query<T>(), любой SaveAsync, любой LINQ-фильтр приземляется именно сюда.

  • Справочники — 2 таблицы: lists и list_items. Это RedbListItem-поля (Driver, Vehicle, ShippingPoint и т.д.), они шарятся между классами и не растут от добавления новой бизнес-сущности.

  • RTTI / метаданные схем — 3 таблицы: types (примитивные типы), schemes (классы), structures (свойства классов). Это код движка читает через scheme_metadata_cache, бизнес-запросы их не трогают.

  • Остальное — инфраструктура: usersrolespermissionslinksdependenciesfunctionsschememetadata_cache, soft-delete на @@__deleted. Эти таблицы не растут от количества бизнес-классов и в горячем пути не участвуют.

Итого: какой бы класс ни писал разработчик в tsum.Domain, его данные физически складываются в 2 таблицы (_objects + _values); ссылочные поля бьют в 2 справочные; метаданные схемы — в 3 RTTI-таблицы. Шесть таблиц инфраструктуры одинаковы для проекта на 5 классов и для проекта на 500. Это и означает «инвариантность от количества классов» — речь не про «все 13 таблиц на схему», а про то, что активный набор таблиц для бизнес-данных = 2, и он не меняется.

Когда разработчик добавляет в tsum.Domain 33-й класс — например, WarehouseSlot — физически в БД появляются:

  • одна строка в _schemes,

  • N строк в _structures (по одной на свойство),

  • никаких миграций индексов. Совсем.

Потому что весь спектр запросов к новому классу обслуживается тем же набором индексов на objects и values, что обслуживал предыдущие 32. Поиск по полю idscheme = ID(WarehouseSlot) использует IX__objects__schemes. Поиск по дате создания — IX__objects__scheme_date_create. Поиск объекта по значению какого-то свойства — IX__values__structure_object_lookup. Дерево родитель-ребёнок — IX__objects__scheme_parent.

Это и есть главное архитектурное преимущество, ради которого статья. Индексационный план зафиксирован на этапе дизайна движка, а не размазан по релизам бизнес-приложения. Скептик возразит: «Но это значит, что универсальные индексы на _values будут хуже, чем специализированные на каждое поле в EF-схеме». Не будут — и ниже мы увидим, почему.

В MSSQL почти всё так же. Это важно сказать вслух: разговор не про специфику Postgres. В redb.MSSql/sql/redbMSSQL.sql лежит зеркальный набор индексов, с минимальными отличиями (которые разберём в отдельном разделе). Архитектурный приём — индексы на физической схеме, а не на бизнес-таблицах — переносится между движками без потерь.


Карта индексов redbPostgre.sql

Если открыть DDL и читать сверху вниз — увидите ~50 индексов вперемешку. Чтобы их понимать, удобнее сгруппировать по сценарию использования, а не по таблице. Что и сделаем.

1. Уникальность и целостность хранения значений (_values)

Три partial-unique индекса, каждый покрывает свой случай:

-- Скалярные поля: один объект — одно значение поляCREATE UNIQUE INDEX UIX__values__structure_objectON _values (_id_structure, _id_object)WHERE _array_index IS NULL AND _array_parent_id IS NULL;-- Маркер вложенного класса/массива внутри родителяCREATE UNIQUE INDEX UIX__values__structure_object_parentON _values (_id_structure, _id_object, _array_parent_id)WHERE _array_index IS NULL AND _array_parent_id IS NOT NULL;-- Элементы коллекции по позиции/ключуCREATE UNIQUE INDEX UIX__values__structure_object_array_indexON _values (_id_structure, _id_object, _array_parent_id, _array_index)WHERE _array_index IS NOT NULL;

Зачем три, а не один. Каждый из этих случаев в _values имеет уникальность по разному набору колонок. Скаляр — по (structure, object). Маркер вложенной структуры — добавляется parent. Элемент массива/словаря — добавляется index. Один общий уникальный индекс невозможен (NULL-ы ломали бы семантику), а три partial — точно ложатся на физическую модель.

Бонус-эффект: дерево индекса меньше, чем у одного полного — каждый partial хранит только подмножество строк. На 9.7M строк это десятки процентов экономии в памяти страниц.

2. Facet search — самое горячее место

Facet-запрос REDB — это «найди объекты, у которых поле X сравнивается с Y». В терминах SQL:

SELECT v._id_object FROM _values vWHERE v._id_structure = @structure_id  AND v._Long > 80000

Этот шаблон обслуживается составным индексом:

CREATE INDEX IX__values__structure_object_lookupON _values (_id_structure, _id_object,            _Long, _DateTimeOffset, _Boolean, _Double, _Guid, _Numeric, _ListItem, _Object);

Все типизированные колонки (кроме _String — про неё ниже) попадают в ключ. Index Only Scan: Postgres находит нужные строки и забирает значение прямо из индекса, без обращения к heap.

Параллельно живут covering-индексы с INCLUDE:

CREATE INDEX IX__values__object_structure_lookupON _values (_id_object, _id_structure, _array_index)INCLUDE (_Long, _Double, _DateTimeOffset, _Boolean, _Guid, _Numeric, _ListItem, _Object);CREATE INDEX IX__values__object_array_nullON _values (_id_object, _id_structure)INCLUDE (_Long, _Double, _DateTimeOffset, _Boolean, _Guid, _Numeric, _ListItem, _Object)WHERE _array_index IS NULL;

Эти два покрывают противоположный сценарий: «забери все значения объекта одним запросом» — для LoadAsync<T>. И снова — Index Only Scan, ноль обращений к таблице.

Почему String исключён. Btree в Postgres имеет ограничение ~2700 байт на строку индекса. Если в String ляжет JWT-payload длиной 3 КБ — индекс упадёт на вставке. Поэтому строки идут через отдельный partial-индекс с guard’ом по длине:

CREATE INDEX IX__values__String_not_nullON _values (_id_structure, _id_object, _String)WHERE _String IS NOT NULL AND length(_String) < 2000;

Длинные строки (CMS-тексты, base64-данные) индекс не покрывает — но их и не фильтруют через =. Для поиска подстроки есть отдельный GIN:

CREATE EXTENSION IF NOT EXISTS pg_trgm;CREATE INDEX IX__values__String_patternON _values USING gin (_String gin_trgm_ops)WHERE _String IS NOT NULL;

Этот индекс обслуживает LIKEILIKE$contains$startsWith$endsWith, regex — всё через pg_trgm. На MSSQL аналог — full-text search или persisted computed columns с filtered index, конкретика в DDL обоих движков.

3. Коллекции (массивы и словари)

В статье 1 разбирали реляционное хранение коллекций — маркер + дочерние строки с arrayparent_id и arrayindex. Под это есть свои индексы:

-- обход элементов коллекции по индексуCREATE INDEX IX__values__array_parent_indexON _values (_array_parent_id, _array_index);-- поиск по ключу словаря (string key)CREATE INDEX IX__values__array_keyON _values (_id_structure, _array_index)WHERE _array_index IS NOT NULL;-- partial: только строки, относящиеся к коллекциямCREATE INDEX IX__values__parent_structureON _values (_array_parent_id, _id_structure)WHERE _array_parent_id IS NOT NULL;

Третий — для PRO PVT CTE при чтении вложенных Class/Dictionary полей вида AddressBook["home"].City.

4. Дерево объектов (_objects._id_parent)

Дерево — основная структура REDB: разделы, категории, оргструктура, всё через idparent. Под это:

-- поиск прямых детей: scheme + parent → idCREATE INDEX IX__objects__scheme_parentON _objects (_id_scheme, _id_parent, _id);-- covering для обхода поддерева (с метаданными в INCLUDE)CREATE INDEX IX__objects__parent_id_descendant_lookupON _objects (_id_parent, _id_scheme)INCLUDE (_id, _id_owner, _date_create, _date_modify)WHERE _id_parent IS NOT NULL;-- partial: корни (объекты без родителя)CREATE INDEX IX__objects__root_objectsON _objects (_id_scheme, _id)WHERE _id_parent IS NULL;-- обратный путь: ребёнок → родитель → схемаCREATE INDEX IX__objects__id_parent_schemeON _objects (_id, _id_parent, _id_scheme)WHERE _id_parent IS NOT NULL;

WhereHasAncestorWhereHasDescendantLoadTreeAsync — все идут через эту четвёрку.

5. Сортировки и метаданные

-- основной индекс под feed "новейшее первым"CREATE INDEX IX__objects__scheme_date_createON _objects (_id_scheme, _date_create DESC, _id);-- сортировка по имени объекта в рамках схемыCREATE INDEX IX__objects__scheme_nameON _objects (_id_scheme, _name, _id);-- глобальные индексы под общий поискCREATE INDEX IX__objects__name ON _objects (_name);CREATE INDEX IX__objects__hash ON _objects (_hash);

Здесь же — индексы под RedbPrimitive<T> (когда объект сам является примитивом — счётчик, токен, единичное значение):

CREATE INDEX IX__objects__value_long ON _objects (_value_long) WHERE _value_long IS NOT NULL;CREATE INDEX IX__objects__value_string ON _objects (_value_string) WHERE _value_string IS NOT NULL;CREATE INDEX IX__objects__value_guid ON _objects (_value_guid) WHERE _value_guid IS NOT NULL;CREATE INDEX IX__objects__value_datetime ON _objects (_value_datetime) WHERE _value_datetime IS NOT NULL;CREATE INDEX IX__objects__value_numeric ON _objects (_value_numeric) WHERE _value_numeric IS NOT NULL;

Все — partial, активны только для строк, у которых соответствующее значение заполнено.

6. Структуры и схемы (_structures, _schemes)

Это «холодные» таблицы — их размер меньше _values на порядки, и обращения идут в основном при чтении кэша метаданных. Но именно они должны быть быстры до миллисекунд:

-- covering под ORDER BY field nameCREATE INDEX IX__structures__nameON _structures (_name)INCLUDE (_id, _id_type, _collection_type, _id_scheme);-- covering для лукапа структуры по IDCREATE INDEX IX__structures__id_lookupON _structures (_id)INCLUDE (_id_type, _name, _collection_type, _id_scheme);-- partial: разделение коллекционных и неколлекционныхCREATE INDEX IX__structures__not_collectionON _structures (_id, _name, _id_scheme)WHERE _collection_type IS NULL;CREATE INDEX IX__structures__collectionON _structures (_id, _id_scheme, _collection_type)WHERE _collection_type IS NOT NULL;

В комментариях DDL зафиксировано: эти индексы убирают Seq Scan на InitPlan и EXISTS-подзапросах в построении hierarchical properties (build_hierarchical_properties_optimized), снижая cost запроса с 6.10 до 4.29 (-30%).

Что было удалено

В redbPostgre.sql есть закомментированный блок:

-- ============================================-- REMOVED REDUNDANT INDEXES (migration_drop_redundant_indexes.sql)-- Reason: Covered by composite index IX__values__structure_object_lookup-- Facet search ALWAYS filters by (_id_structure, _id_object) BEFORE value-- ============================================-- CREATE INDEX "IX__values__String" ON _values (_String) ...;-- CREATE INDEX "IX__values__Long" ON _values (_Long) ...;-- CREATE INDEX "IX__values__Guid" ON _values (_Guid) ...;-- ...

Это история чистки прямо в файле схемы. Ранние версии REDB ставили по индексу на каждую типизированную колонку. На реальной нагрузке оказалось, что эти индексы не активируются: facet-запрос всегда начинается с idstructure, а не с самого значения. Композитный (_id_structure, idobject, _Long, ...) поглощает их полностью. Лишнее убрано.


Почему так быстро на проде

логи (скорость выполнения и материализация запросов из >9m параметров

логи (скорость выполнения и материализация запросов из >9m параметров

Архитектурные аргументы — это половина разговора. Вторая половина — реальные цифры. Конкретный лог TSUM, типовой цикл обработки заказов:

[TSUM] orders=999 routes(+2 ~30 =967) drivers(+0 ~0) vehicles(+0 ~0)       sync=482 query=139 save=154 total=991ms

Расшифровка:

  • 999 заказов пришло одной XML-пачкой из SAP S/4 (хранимая процедура usp_TsUM_MonitoringReport_xml).

  • routes(+2 ~30 =967): из 999 пришедших — 2 новых маршрута, 30 изменилось, 967 не тронули вообще (содержание не изменилось).

  • drivers/vehicles (+0 ~0): справочники водителей и машин в этой пачке без изменений.

  • sync = 482 мс — это синхронизация СПРАВОЧНИКОВ (DictionarySyncService.SyncFromOrdersAsync): пройти по всем 999 заказам, сверить водителей/машины/РЦ/типы бизнеса/статусы доставки с REDB-списками, при необходимости докинуть новые list_items. Это не sync схем.

  • query = 139 мс — один SELECT в REDB: WhereRedb(o => codes.Contains(o.ValueString)) — загрузка всех 999 существующих маршрутов по их кодам одним запросом.

  • save = 154 мс — SaveAsync для 32 объектов (2 created + 30 updated). 967 неизменённых сюда не попали.

  • Окружение: 2 ядра, default shared_bufferspg_prewarm не используется, кэша уровня redb-фреймворка нет.

Важная оговорка: схема-sync (SyncSchemeAsync<T> — то, что сравнивает structurehash и обновляет schememetadata_cache) — отдельный этап, происходит однократно при старте процесса до начала обработки заказов. В этой строке лога его нет вообще. То, что здесь называется sync, — это синхронизация прикладных справочников, не схем.

Разберём, что физически происходит за этими цифрами.

Query: 139 мс на 999 объектов одним запросом

[TSUM] orders=999 routes(+2 ~30 =967) drivers(+0 ~0) vehicles(+0 ~0) sync=482 query=139 save=154 total=991ms

Самое впечатляющее число в логе. Это не 999 отдельных LoadAsync — это один facet-запрос вида:

var existing = await redb.Query<TransportationRoute>()    .WhereRedb(o => codes.Contains(o.ValueString!))    .ToListAsync();

Который транслируется в SQL примерно так (упрощённо):

-- найти все маршруты, у которых _value_string ∈ список из 999 кодовSELECT o.* FROM _objects oWHERE o._id_scheme = @routeSchemeId  AND o._value_string = ANY(@codes);

Дальше — один батч-запрос за values для всех найденных объектов через IX_values__object_array_null (covering INCLUDE) — Index Only Scan, heap не открывается. На выходе — 999 полностью гидратированных RedbObject<TransportationRoute> со всеми ~50 свойствами каждого, включая ListItem-ссылки на справочники.

139 мс / 999 объектов = 0.14 мс на объект, но это бухгалтерская арифметика — реальная стоимость это два SQL round-trip’а (один за objects, один за values) плюс гидратация в C#.

Никаких N+1. Никаких Include-ов. ListItem-ссылки разрешаются через RefDataCache в памяти — поэтому VehicleDriverShippingPoint поля заполняются без дополнительных SQL-запросов.

Save: 154 мс на 32 изменённых объекта

Здесь два момента, и оба критичны.

Первый — change tracking на уровне приложения. Перед SaveAsync каждый существующий маршрут проходит через ComputeHash():

var hashBefore = obj.ComputeHash();EnrichRouteFromOrder(obj.Props, routeProps);if (obj.ComputeHash() != hashBefore){    toSave.Add(obj);  // данные реально изменились — кладём на сохранение    updatedCount++;}else{    skippedCount++;   // тот же байт-в-байт — не трогаем БД вообще}

Из 999 обработанных заказов — 967 пропущены именно так. Они не дошли до objects/values, не сгенерировали ни одной DML-операции. Это не оптимизация ядра REDB — это паттерн прикладного кода, но REDB его поддерживает дешёвым ComputeHash() поверх Props.

Второй — bulk-save в одной транзакции. Оставшиеся 32 объекта уходят одним вызовом:

await redb.SaveAsync(toSave);   // toSave: List<IRedbObject>, 32 элемента

И вот что внутри. Сначала objects нормализуется батчем — для всех 32 объектов одним INSERT/UPDATE-проходом (для новых — один COPY/UNNEST-INSERT, для изменённых — батчевый UPDATE). Потом — values: для каждого изменённого объекта пересоздаётся набор строк значений, и тоже не построчно, а одним bulk-вызовом через COPY protocol Postgres. Всё в одной транзакции — objects и values фиксируются атомарно.

COPY protocol важен. Это поточный бинарный протокол, который вставляет N строк без round-trip на каждую — драйвер пишет в сокет непрерывный поток. На 32 объекта × ~50 свойств = ~1600 строк значений → одна COPY-операция вместо 1600 INSERT’ов. Btree-индексы получают свой батч и обновляются амортизированно.

Поэтому 154 мс на 32 объекта (~4.8 мс на объект полной перезаписи) — это не «движок очень быстрый», это «движок не делает лишнего». Bulk там, где можно bulk; ранний skip там, где данные не изменились.

Sync: 482 мс — что это на самом деле

482 мс — самая «толстая» строка в логе, и именно её обычно неправильно интерпретируют. Это не синхронизация схем — sync схем уже произошёл при старте Worker’а до того, как пошли заказы. 482 мс — это DictionarySyncService.SyncFromOrdersAsync:

  1. Пять параллельных загрузок справочников из REDB: DriversVehiclesShippingPointsBusinessTypesDeliveryStatuses — через redb.ListProvider.GetListByNameWithItemsAsync(...). Подгружаются lists + все их list_items.

  2. Проход по 999 заказам: каждый DriverIdCarIdShippingPointBusinessTypes сверяется с уже существующими элементами справочника. Если значение новое — формируется upsert.

  3. Если справочники изменились — обновление RefDataCache (статический in-memory кэш, чтобы при маппинге Order → RouteProps не ходить в БД повторно).

В этой пачке пришли «нулевые» дельты по водителям и машинам (+0 ~0), но 482 мс ушло на проверку, что они не изменились — это 999 строк сверки против тысяч существующих list_items по нескольким справочникам.

Это прикладной sync — он принадлежит логистическому процессу TSUM, не движку REDB. Но он показывает важное: даже когда «обнаружить, что менять не надо» дороже, чем сам save (482 мс sync vs 154 мс save), общий цикл всё равно укладывается в секунду на тысяче заказов.

Sync схем — отдельная история. На холодном старте Worker’а — десятки-сотни миллисекунд однократно (32 схемы, сравнение хешей). При горячем рестарте без изменений в моделях — единицы миллисекунд. Это происходит до первого [TSUM]-лога и в строке цикла обработки не учитывается.

Ответ скептику про 9.7M строк

Главное возражение, с которого начинали статью: «как может быть быстро при такой таблице». Ответ: движок никогда не сканирует _values целиком. Любой запрос:

  • начинается либо с PK (LoadAsync по ID) — O(log n) по дереву индекса,

  • либо с idscheme + valuestring для facet’а через RedbPrimitive-колонку, как в TSUM-кейсе выше — Index Only Scan по IX__objects__value_string,

  • либо с idstructure (facet search по обычному полю) — отсекает ~99.75% строк сразу: на 401 свойстве каждый idstructure имеет в среднем ~24K строк значений из 9.7M общего объёма,

  • либо с idobject (LoadAsync — все значения объекта) — отсекает ещё агрессивнее, потому что у объекта несколько десятков значений, не тысячи.

Объём таблицы в строках влияет только на размер индекса в страницах. Btree в Postgres имеет фактор ветвления порядка нескольких сотен — на 9.7M строк это глубина 4-5 уровней. Поиск по такому дереву — логарифмическое замедление, не линейное.

логи (скорость выполнения и материализация запросов из >9m параметров

логи (скорость выполнения и материализация запросов из >9m параметров

«Нужное не всегда нужно»: эксплуатация индексов после стабилизации

В DDL redbPostgre.sql зашит полный спектр индексов под все сценарии REDB: facet search, дерево, коллекции, GIN-полнотекст, partial-индексы под NOT NULL, индексы под рекурсивные CTE для прав. Это правильно для движка-библиотеки: вы не знаете заранее, какой профиль нагрузки будет у конкретного приложения.

Но в конкретном вашем приложении часть этих индексов может никогда не активироваться. Это нормально. Логистическая система TSUM не использует, например, WhereHasDescendant (потому что иерархии в её доменной модели плоские) — значит, IX__objects__parent_id_descendant_lookup лежит в страницах БД, но через него никогда не идут сканирования.

После того, как приложение вышло в прод и набрало 2-4 недели стабильной нагрузки — снимите статистику по индексам и аккуратно уберите ненужное:

SELECT schemaname, relname, indexrelname,       idx_scan,       pg_size_pretty(pg_relation_size(indexrelid)) AS szFROM pg_stat_user_indexesWHERE idx_scan = 0  AND pg_relation_size(indexrelid) > 10*1024*1024  -- порог: только индексы > 10 МБORDER BY pg_relation_size(indexrelid) DESC;
статистика индексов на проде

статистика индексов на проде

На TSUM такая выборка показала суммарно ~4423 МБ в индексах с idx_scan = 0. Это не значит, что эти индексы плохие. Это значит — в этом конкретном профиле нагрузки на эти пути запросов никто не приходил.

Алгоритм работы с такой выборкой:

  1. Не удалять сразу. Сначала — обзор: какие из этих индексов поддерживают сценарии, которые «ещё не стрельнули» (квартальная отчётность, годовые сверки, импорт большого объёма)? Их трогать не стоит — на их пробуждении стоимость пересоздания будет выше, чем экономия памяти от удаления.

  2. Оставшиеся — кандидаты на DROP INDEX CONCURRENTLY. Это безопасная операция: индекс удаляется без блокировки таблицы, и его всегда можно вернуть тем же CREATE INDEX CONCURRENTLY обратно — DDL знает, как этот индекс должен выглядеть.

  3. После удаления — наблюдение ещё 2-4 недели. Если в логах не появляется неожиданно медленных запросов, значит решение было правильным. Если что-то «просело» — возвращаем.

Это универсальная практика работы с PostgreSQL, не специфика REDB. Но именно в REDB она работает чище: набор индексов задизайнен один раз и зафиксирован в DDL. Выключение лишних — это конкретное аккуратное решение в одном месте, а не охота за наслоениями миграций трёх лет давности по бизнес-таблицам.

И обратное направление тоже работает. Если вы видите, что у вас один конкретный facet-запрос горячий до уровня «надо ускорить ещё» — никто не запрещает добавить локальный индекс под именно этот запрос:

-- например, "найти маршруты конкретного водителя за последние 7 дней"CREATE INDEX IX_my_app__route_by_driverON _values (_id_structure, _ListItem, _id_object)WHERE _id_structure = <id_поля_Driver>;

Такие индексы — прикладная оптимизация, она лежит в миграции вашего приложения, а не в ядре REDB. И снова — она тоже под pg_stat_user_indexes-аудит через 2-4 недели.


Чего нет в DDL и почему

Скептик второго уровня задаёт следующий вопрос: «А почему нет hash-индекса на String? Почему нет BRIN на date_create? Почему нет expression-индекса на LOWER(_String)

Hash-индексы. В Postgres исторически уступают btree по большинству метрик и не поддерживают partial-условия. У нас почти все индексы partial — поэтому hash не подходит структурно, а не из-за случайного выбора.

BRIN на datecreate. Обоснованный кандидат на больших архивных таблицах, но для текущего профиля REDB он избыточен. IX__objects__scheme_date_create (DESC, _id) уже идёт по композитному ключу и обслуживает ровно тот сценарий, ради которого BRIN был бы альтернативой. BRIN дал бы выигрыш только в cold-storage сценарии (>10M строк на одну схему) — это уже отдельная фаза «архивные классы», и в DDL текущего ядра она пока не входит.

Expression-индексы (LOWER(_String)DateTimeOffset::date и т.п.). Сознательно не зашиты в DDL. Это прикладной выбор: case-insensitive поиск по конкретному текстовому полю, агрегация по дню/месяцу — это опции, которые правильно решать в миграции конкретного приложения, а не в ядре. GIN с pgtrgm уже даёт case-insensitive substring-поиск через ILIKE — для большинства задач этого достаточно.

Bloom, BRIN-multi, GIN на jsonb. Все — кандидаты на узкие сценарии. Если у вас один очень специфический запрос, который доказанно горячий и доказанно не покрывается стандартным набором — добавляете локальный индекс под него, отдельной миграцией. Не трогая redbPostgre.sql.

Принцип здесь общий: DDL ядра покрывает 95% сценариев, оставшиеся 5% — прикладной слой. Это противоположно EF-подходу, где DDL сразу принадлежит приложению и любой индекс — часть его миграционной истории.


MSSQL: почти всё так же

Раз уж пошла речь про DDL, важно сразу сказать: статья не про специфику Postgres. Файл redb.MSSql/sql/redbMSSQL.sql содержит зеркальный набор индексов, и архитектурный приём «индексы инвариантны от количества классов» работает на обоих движках одинаково.

Что переносится один-в-один:

  • Все три partial-unique на _values (в MSSQL — filtered indexes с WHERE, синтаксис другой, семантика та же).

  • Composite-индексы под facet search (IX__values__structure_object_lookup существует в обоих DDL с идентичной структурой ключа).

  • Covering-индексы с INCLUDE — нативно поддерживаются в MSSQL начиная с 2005, переносятся буквально.

  • Дерево объектов (IX__objects__scheme_parentIX__objects__parent_id_descendant_lookup) — без изменений.

  • Partial/filtered-индексы под RedbPrimitive<T> — тоже без изменений.

Что отличается:

  • GIN + pg_trgm для substring-поиска по _String. В MSSQL аналог — full-text search или filtered index по computed column. В DDL redbMSSQL.sql идёт другой подход (LIKE по обычному btree с partial-условием на длину).

  • NULLS NOT DISTINCT в Postgres 15+ — в MSSQL пишется иначе (UNIQUE + filtered WHERE NOT NULL).

  • ON DELETE CASCADE от structures к values в Postgres работает напрямую, в MSSQL нельзя из-за multiple cascade paths — заменено на INSTEAD OF DELETE триггер. План DELETE в MSSQL немного другой, но индексы те же.

  • Per-type filtered индексы в MSSQL прописаны более явно (IX__values__Long_filterIX__values__Guid_filter, …) — в Postgres-DDL они закомментированы как «covered by composite». Это разница в стратегии: оптимизатор MSSQL чуть больше любит узкие filtered-индексы под конкретный тип.

Но главное — архитектурная инвариантность сохраняется. Та же раскладка 2+2+3+инфраструктура, та же facet-схема, та же неизменность набора индексов от добавления класса. Разработчику, который пишет C#-модели, разница между Postgres и MSSQL не видна вообще.


TSUM как проверочный кейс

Возвращаюсь к цифрам в начале статьи и собираю их в одну точку.

Что REDB обслуживает в TSUM:

  • 32 класса в tsum.Domain (от TransportationRoute до UserFilterPreference),

  • 401 свойство в этих классах суммарно,

  • 227 896 объектов на момент снятия метрик,

  • 9 773 174 строки значений в _values.

Вся эта схема физически ложится не в 13 таблиц на каждую сущность, а в 2 таблицы данных (_objects + values) + 2 справочных (lists + listitems) + 3 RTTI (_types + schemes + structures). Остальные шесть — инфраструктура (права, пользователи, роли, links, dependencies, functions, soft-delete, metadata cache), они не зависят от количества бизнес-классов. Полный набор индексов — тот же redbPostgre.sql, что и для проекта с пятью классами. В классической EF-раскладке этих 32 классов с их справочниками и snapshot’ами получилось бы порядка 60-80 таблиц и 200+ индексов — с миграционной историей за всё время жизни проекта.

Прод-метрики типового цикла обработки заказов:

  • 999 заказов в одной XML-пачке из SAP, обработка — 991 мс total:

    • sync = 482 мс — сверка справочников (Drivers, Vehicles, ShippingPoints, BusinessTypes, DeliveryStatuses) с REDB-списками,

    • query = 139 мс — один SELECT, загрузил все 999 существующих маршрутов по их кодам,

    • save = 154 мс — SaveAsync для 32 изменённых объектов (2 created + 30 updated). Остальные 967 пропущены через ComputeHash() — не дошли до БД вообще.

  • Инфраструктура — 2 ядраdefault-настройки PostgreSQL (без тюнинга shared_buffers, без pg_prewarm, без увеличенного work_mem).

  • Кэша уровня REDB-фреймворка нет — каждый Query<T>() идёт в БД.

Это не «специально подобранный бенчмарк» — это реальный production-цикл, в котором обычный разработчик пишет обычные C#-модели через [RedbScheme] атрибут, и логистический процесс просто работает. Bulk на загрузке (один SELECT на 999 объектов), bulk на сохранении (_objects и _values — обе таблицы одной транзакцией через COPY protocol), ранний skip там, где данные не изменились.

Главный технический вывод: REDB укладывается в эти цифры не благодаря чудесному стечению обстоятельств, а потому что движок не делает лишнего. Индексационный план зашит в схеме хранения; bulk там, где можно bulk; change tracking на уровне приложения экономит самое дорогое — DML-операции на изменения, которых не было.


Заключение и дальше по циклу

Итог короткий. У REDB один набор индексов в DDL — около 50 штук на всю систему. Этот набор обслуживает любую бизнес-схему: 32 класса, 100 классов, 500 классов — структура индексов та же. Это противоположно EF-подходу, где индексы плодятся вместе с таблицами и со временем превращаются в технический долг, требующий регулярной чистки.

Эксплуатационный паттерн — после стабилизации приложения снимите pg_stat_user_indexes, увидите кандидатов с idx_scan = 0, и аккуратно (через DROP INDEX CONCURRENTLY с возможностью отката) уберите лишнее. Прибавите свои локальные индексы под горячие прикладные запросы — отдельной миграцией, не трогая ядро.

В MSSQL почти всё так же. Минимальные отличия — в способе индексации текста и в filtered-индексах per-type, но архитектурная инвариантность сохраняется.

Дальше по циклу:

  • Статья 2 — Code-first схемы: как SyncSchemeAsync<T> преобразует C#-класс в строки schemes + structures, что такое structurehash, как работает автоматический онбординг при добавлении нового свойства.

  • Статья 3 — CRUD: SaveAsync и LoadAsync изнутри: change tracking через TreeDiff, bulk insert через COPY protocol, lazy loading.

  • Статья 4 — LINQ-запросы: как Where(x => x.Salary > 80000) превращается в SQL с CASE WHEN idstructure = X THEN _Long END > 80000, как работают OrderBy и оконные функции.

  • Статья 5 — Дерево объектов: LoadTreeAsyncGetDescendantsAsyncWhereHasAncestor, рекурсивные CTE.

  • Статья 6 — Оконные функции: Win.RowNumber()Win.Rank()PartitionBy/OrderBy поверх REDB-объектов.

Глубокое погружение в C# — это статьи 3-5 цикла. Здесь, в физической части, остался один сюжет — миграции схем, и именно с него начнётся следующая публикация.


Ссылки


Предыдущие статьи серии (на Хабре)

ссылка на оригинал статьи https://habr.com/ru/articles/1045208/