REDB изнутри, статья 1.1: индексы, или почему 3 таблиц на любую схему — это быстро
В предыдущей части цикла разобрали 13 таблиц REDB: как устроены objects, values, structures, как 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 устроен иначе на двух уровнях. Во-первых, значения уже разложены по типизированным колонкам (_Long, String, Numeric, DateTimeOffset…) — никакого cast. Во-вторых, у каждой строки values есть idstructure — bigint-идентификатор поля схемы. На 401 поле в TSUM это означает, что фильтр WHERE idstructure = X сразу отсекает ~99.75% строк таблицы ещё до того, как посмотрим на значение.
Но это объяснение архитектурное. Вопрос практический: как именно зашиты индексы, чтобы facet-запрос отрабатывал за миллисекунды на 9.7M строк? Разберём по шагам.
Главное архитектурное наблюдение: индексы инвариантны от количества классов
В классической EF-раскладке количество таблиц растёт линейно с количеством бизнес-сущностей. На каждую таблицу в среднем 3-5 индексов: PK, индексы на FK, индексы под бизнес-фильтры. Поэтому общее количество индексов в БД растёт мультипликативно с ростом проекта.
Возьмём TSUM как пример. Вот что лежит в tsum.Domain/Entities:
-
TransportationRoute(маршрут перевозки) -
TransportationPoint(точка маршрута) -
Driver,Vehicle,ShippingPoint,YardPlace -
SliceSettings,SliceSnapshot,TransportSnapshot,TransportNorm -
SpecialRcSettings,TonnageGroupSettings,GarageState -
TsumAdUserRef,UserFilterPreference
15 «корневых» сущностей. Но это только начало. Только в одном TransportationRoute мы видим ссылки на справочники: Drivers, Vehicles, CarMarks, ShippingPoints, BusinessTypes, YardPlaces (×2 — PlaceTo / PlaceFrom), LoadingZones, TransportStatuses, DeliveryStatuses, TripRisks. И в каждой из других сущностей — свой набор лукапов: типы кузова, статусы готовности, причины задержек, классификаторы рейсов.
В классической схеме каждый такой справочник — отдельная таблица: Drivers, Vehicles, CarMarks, …, плюс audit/history-таблицы (TransportSnapshot, SliceSnapshot — это явно 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, бизнес-запросы их не трогают. -
Остальное — инфраструктура:
users,roles,permissions,links,dependencies,functions,schememetadata_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;
Этот индекс обслуживает LIKE, ILIKE, $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;
WhereHasAncestor, WhereHasDescendant, LoadTreeAsync — все идут через эту четвёрку.
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, ...) поглощает их полностью. Лишнее убрано.
Почему так быстро на проде
Архитектурные аргументы — это половина разговора. Вторая половина — реальные цифры. Конкретный лог 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_buffers,pg_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 в памяти — поэтому Vehicle, Driver, ShippingPoint поля заполняются без дополнительных 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:
-
Пять параллельных загрузок справочников из REDB:
Drivers,Vehicles,ShippingPoints,BusinessTypes,DeliveryStatuses— черезredb.ListProvider.GetListByNameWithItemsAsync(...). Подгружаютсяlists+ все ихlist_items. -
Проход по 999 заказам: каждый
DriverId,CarId,ShippingPoint,BusinessTypesсверяется с уже существующими элементами справочника. Если значение новое — формируется upsert. -
Если справочники изменились — обновление
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 уровней. Поиск по такому дереву — логарифмическое замедление, не линейное.
«Нужное не всегда нужно»: эксплуатация индексов после стабилизации
В 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. Это не значит, что эти индексы плохие. Это значит — в этом конкретном профиле нагрузки на эти пути запросов никто не приходил.
Алгоритм работы с такой выборкой:
-
Не удалять сразу. Сначала — обзор: какие из этих индексов поддерживают сценарии, которые «ещё не стрельнули» (квартальная отчётность, годовые сверки, импорт большого объёма)? Их трогать не стоит — на их пробуждении стоимость пересоздания будет выше, чем экономия памяти от удаления.
-
Оставшиеся — кандидаты на
DROP INDEX CONCURRENTLY. Это безопасная операция: индекс удаляется без блокировки таблицы, и его всегда можно вернуть тем жеCREATE INDEX CONCURRENTLYобратно — DDL знает, как этот индекс должен выглядеть. -
После удаления — наблюдение ещё 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_parent,IX__objects__parent_id_descendant_lookup) — без изменений. -
Partial/filtered-индексы под
RedbPrimitive<T>— тоже без изменений.
Что отличается:
-
GIN +
pg_trgmдля substring-поиска по_String. В MSSQL аналог — full-text search или filtered index по computed column. В DDLredbMSSQL.sqlидёт другой подход (LIKEпо обычному btree с partial-условием на длину). -
NULLS NOT DISTINCTв Postgres 15+ — в MSSQL пишется иначе (UNIQUE+ filteredWHERE NOT NULL). -
ON DELETE CASCADEотstructuresкvaluesв Postgres работает напрямую, в MSSQL нельзя из-за multiple cascade paths — заменено наINSTEAD OF DELETEтриггер. План DELETE в MSSQL немного другой, но индексы те же. -
Per-type filtered индексы в MSSQL прописаны более явно (
IX__values__Long_filter,IX__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 — Дерево объектов:
LoadTreeAsync,GetDescendantsAsync,WhereHasAncestor, рекурсивные CTE. -
Статья 6 — Оконные функции:
Win.RowNumber(),Win.Rank(),PartitionBy/OrderByповерх REDB-объектов.
Глубокое погружение в C# — это статьи 3-5 цикла. Здесь, в физической части, остался один сюжет — миграции схем, и именно с него начнётся следующая публикация.
Ссылки
Предыдущие статьи серии (на Хабре)
-
Что я строил последние 6 лет: REDB — собственный движок данных — обзорная статья: LINQ, деревья, redb.Route, redb.Tsak.
-
REDB изнутри, статья 1: 13 таблиц, на которых работает всё — структура БД, RTTI vs EAV,
valuesи коллекции,scheme_metadata_cache. -
redb.Route 3.0 / 3.0.1 / 3.1.0 — отдельный цикл по ESB-фреймворку поверх redb.
ссылка на оригинал статьи https://habr.com/ru/articles/1045208/