redb 3.7.1: поиск по props быстрее до 100 раз. Альтернатива EF Core или дополнение к нему

redb

Есть класс проблем производительности, который не видно на маленьких данных и невозможно не заметить на больших. Наш выглядел так: запрос искал один редкий номер заказа среди сотни тысяч объектов ровно столько же, сколько поиск, не находящий ничего. Избирательность условия не влияла на время. Совсем.

Причина оказалась в форме сгенерированного SQL. Значения props живут построчно, и запрос сначала сворачивает их в широкую строку через GROUP BY, а уже потом применяет фильтр. Условие стояло над агрегатом, то есть фильтровало результат свёртки, а не колонку. Индексу там зацепиться не за что: к моменту проверки движок уже прочитал и свернул все значения всех объектов схемы. Триграммный индекс по строкам лежал без дела.

В 3.7.1 появился шаг, который сужает набор объектов до того, как запускается агрегат.

Как это устроено

Планировщик разбирает дерево фильтра и пытается выразить его как условие на уровне строки значений. Строка принадлежит ровно одной структуре, поэтому естественно ложится дизъюнкция: «это строка поля Position и она содержит иголку, либо это строка поля Department и она содержит иголку». Такое условие вклеивается прямо в скан значений, и до свёртки доезжает уже суженный набор.

Ключевое свойство: префильтр строится как надмножество. Он может пропустить лишнее, но не имеет права потерять нужное. Полномочный фильтр остаётся там же, где стоял, над GROUP BY. Всё, что планировщик не разобрал, даёт пустой префильтр и ровно прежнее поведение, так что худший исход это отсутствие ускорения, а не изменение результата.

Что уже работало раньше

Без этой оговорки цифры ниже читаются неправильно, поэтому она здесь, а не в примечании.

Отсечение до агрегата в redb было всегда. Просто не для props, а для собственных полей объекта: идентификатор, родитель, даты создания и изменения, имя. Такое условие вклеивалось прямо в скан значений:

AND v._id_object IN (SELECT o_src._id FROM _objects o_src
                     WHERE o_src._id_scheme = $1 AND <ваше условие>)

В коде это WhereRedb, и именно на нём держалась скорость боевых запросов. В нашей проде и в redb.Identity такие фильтры стоят почти везде: выборка внутри поддерева, по диапазону дат, по набору идентификаторов, по владельцу. Пока объекты отсекались по _objects, свёртка получала уже суженный набор и работала быстро.

Дыра была ровно там, где по _objects отсекать нечем. Тогда единственным избирательным условием оставался props, а он стоял над агрегатом и не резал ничего: хоть один редкий номер заказа ищи, хоть пустоту. 3.7.1 закрывает этот случай, и два отсечения теперь складываются: сначала по объектам, затем по строкам значений.

Что именно мерили

Две формы, обе обычный LINQ, никаких специальных вызовов. И обе без WhereRedb, то есть худший случай: сузить по _objects нечем, вся тяжесть ложится на props.

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

var found = await redb.Query<Employee>()
    .Where(e => e.Position.Contains("Design") || e.Department.Contains("Design"))
    .OrderByRedb(o => o.Id)
    .Take(100)
    .ToListAsync();

Одна иголка Design, два поля, «или» между ними. В таблице ниже это строка про иголку. До 3.7.1 такой запрос читал и сворачивал значения всех сотрудников схемы, а «Design» проверял уже после свёртки. Теперь строки, которые заведомо не подходят, отсекаются на входе.

Вторая: диапазон по дате, отбирающий 3 288 объектов из 100 000, то есть 3,3%.

var hired = await redb.Query<Employee>()
    .Where(e => e.HireDate >= new DateTime(2030, 1, 1)
             && e.HireDate <  new DateTime(2031, 1, 1))
    .Take(100)
    .ToListAsync();

Цифры

Все три движка засеяны одинаково: 100 000 объектов, около 8,4 млн строк значений, статистика обновлена. Время серверное, лучшее из трёх прогонов.

запрос PostgreSQL SQLite MS SQL Server
одна иголка по двум строковым полям, с сортировкой 188 → 91 мс 1150 → 311 мс 55 → 17 мс
то же, вся выборка без пагинации 338 → 156 мс 1201 → 314 мс 7037 → 1327 мс
диапазон по дате, 3,3% выборки 154 → 1,5 мс 17 → менее 1 мс 2 → 2 мс

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

Важная оговорка про то, что здесь измерено. Это время самого запроса, без сборки объектов в памяти. В реальном вызове к нему добавляется материализация, и она одинакова в обоих режимах. То есть поиск ускорился ровно на показанные отношения, а доля выигрыша в полном времени вызова зависит от того, сколько объектов вы тянете.

И здесь работает второй рычаг, независимый от первого: проекции. Select тянет только те поля, которые действительно нужны, а не весь объект целиком. Один механизм сокращает работу движка, другой сокращает работу материализатора, и они складываются.

Три движка, три разных механизма

Одна и та же алгебра, а выигрывают все по-своему, и это стоит знать прежде чем предсказывать числа на своих данных.

PostgreSQL включает триграммный GIN и читает кратно меньше строк: строковый индекс отдаёт 40 958 строк там, где индекс по структурам отдаёт 200 000. SQLite задействует покрывающий частичный индекс и перестаёт ходить обратно в таблицу. MS SQL Server читает те же страницы, но экономит процессор, потому что строковая колонка это NVARCHAR(MAX) и сравнение несёт явную коллацию.

Нагляднее всего разброс виден на диапазоне по дате: стократное ускорение на PostgreSQL, шестнадцатикратное на SQLite и ровно ничего на SQL Server.

Последнее не осечка, а признак того, что сокращать было нечего. Условие отбирает 3,3% схемы, запрос просит сто строк, и работа уже ограничена пределом, а не размером схемы: набрать сотню совпадений при такой плотности можно, обойдя порядка трёх тысяч объектов. Две миллисекунды до, две после. Как только предел убрать и заставить пройти всю схему, выигрыш появляется и там: 88 против 65 мс. Это, кстати, хороший критерий и для ваших данных. Префильтр окупается там, где движок иначе вынужден свернуть всю схему целиком, и не даёт ничего там, где предел и без него удерживает работу маленькой.

Что это значит на практике

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

При этом остаётся то, чего у плоской схемы нет по определению. Нет размножения строк на джойнах: значения сворачиваются по объекту за один проход, и связанные коллекции не превращают выборку в декартово произведение, которое потом надо схлопывать. Нет и нужды в Include: граф грузится по глубине, а не перечислением веток руками. На сложных графах это даёт результат, которого от классического ORM с джойнами и Include ждать не приходится.

Отсюда и двойное позиционирование в заголовке. Заменять EF Core целиком никто не заставляет: в одном приложении они спокойно живут рядом, на одной базе. Реляционная часть, где схема стабильна и таблица плоская, остаётся за EF, и это правильное для неё место. redb берёт то, что в плоской модели даётся тяжело: изменчивые наборы полей, разнородные сущности внутри одной схемы, деревья и графы, которые иначе превращаются в цепочку Include и размноженные строки. Выбор тут не идеологический, а по форме данных.

Отдельного бенчмарка против EF в этой статье нет, и выдумывать его я не стану. Речь про свойства самой модели выполнения, а не про замер, которого мы не делали.

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

Границы, о которых честно

Префильтр включается флагом EnablePvtPrefilter и по умолчанию выключен.

Сегодня его получают дизъюнкция по избирательным полям и диапазон или равенство по одному полю. Верхнеуровневое «и» по разным полям не получает: строка значений принадлежит одной структуре, и конъюнкцию разных полей на уровне строки выразить нечем. Фильтры по массивам, словарям, проверки на null, сравнения полей между собой и вычисляемые выражения планировщик честно признаёт неразбираемыми и префильтра не даёт.

Есть и случай, где префильтр пришлось попросить отойти в сторону. На SQLite запрос с пределом и вовсе без сортировки без префильтра работает потоком и обрывается на сотой группе, а с префильтром планировщик переключается на объединение индексов, теряет порядок, требует временного B-дерева и материализует всё. Замер: 8–12 мс против 388–521 мс, диапазоны не пересекаются. Поэтому на SQLite в этой единственной форме префильтр не выпускается. PostgreSQL такого не знает, а SQL Server вредную форму не порождает вовсе, потому что его пагинация обязана нести ORDER BY.

Отдельно про то, как это проверялось. Инвариант «результат обязан совпадать с точностью до строки» закрыт набором дифференциальных тестов: каждый запрос гоняется дважды, с флагом и без, на одних и тех же данных, и наборы идентификаторов сравниваются. Именно этот набор поймал дефект, который прожил бы до релиза: строковая форма отсекает строки, а не объекты, и объект, прошедший по одной ветви дизъюнкции, терял значения колонок остальных ветвей. Набор объектов при этом оставался целым, поэтому полторы тысячи существующих тестов ничего не замечали, а DistinctBy и OrderBy по такому полю врали. Починено до публикации.

Что в итоге

Цена поиска наконец зависит от того, что вы ищете. Редкое условие стоит дёшево, а не столько же, сколько выборка, не находящая ничего.

Три движка, один флаг, ноль правок в коде приложения. Тот же LINQ, та же модель, переписывать нечего: включили и поехали.

  • до 100 раз быстрее на диапазоне по дате в PostgreSQL, 154 мс превратились в полторы;
  • в 3,7 раза на строковом поиске в SQLite и вдвое в PostgreSQL;
  • в 5,3 раза на полной выборке в MS SQL Server, семь секунд превратились в 1,3.

И ни одна строка результата при этом не изменилась. Префильтр по построению надмножество, а инвариант закреплён дифференциальными тестами: каждый запрос гоняется дважды, с флагом и без, наборы идентификаторов сверяются посимвольно. Это не фигура речи, а рабочий инструмент, и именно он поймал дефект, который иначе уехал бы в релиз.

Сложите с проекциями, и получите два независимых рычага: один сокращает работу движка, второй работу материализатора. На простых условиях выборка идёт по индексу так же, как по плоской таблице, а на сложных графах остаётся то, чего у плоской схемы нет: ни размножения строк джойнами, ни ручного Include.

Где живёт

Префильтр живёт внутри redb и работает на всех трёх Pro-провайдерах: PostgreSQL, MS SQL Server, SQLite. Включается одним флагом EnablePvtPrefilter в RedbServiceConfiguration. Это шаг компиляции запроса, а не отдельный режим работы: тот же LINQ, та же выборка, тот же результат, та же наблюдаемость. Разница в том, что до агрегата теперь доезжает не вся схема, а только то, что могло подойти.

Если было полезно, ⭐ на GitHub поможет другим это найти.


Если было полезно, ⭐ на GitHub поможет другим это найти.

Другие мои статьи — redb.ru/articles, ещё — на Хабре.