Один запрос выполняется 100 мс, другой — меньше 1 мс. Оба делают одно и то же, но второй написан на странном, почти алхимическом SQL. В чём подвох? Этот перевод — расследование того, почему условие OR так дорого обходится вашей базе данных, и практическое руководство по тому, как проектировать схемы, чтобы избежать этой ловушки производительности.

Планировать запросы очень непросто. Как правило, они включают несколько условий фильтрации, связанных AND. Но зачастую разработчикам приходится использовать и условие OR:

select count(*)
from application
where submitter_id = :user_id
or reviewer_id = :user_id;

Такой запрос работает медленно. Для миллиона заявок и тысячи пользователей при равномерном распределении в обоих столбцах выполнение занимает более 100 мс при непрогретом кэше (исходный код примеров).

Если переписать запрос с одними AND:

SELECT (
   SELECT COUNT(*)
   FROM application a
   WHERE a.reviewer_id = :user_id
) + (
   SELECT COUNT(*)
   FROM application a
   WHERE a.submitter_id = :user_id
) - (
   SELECT COUNT(*)
   FROM application a
   WHERE a.submitter_id = :user_id
     AND a.reviewer_id = :user_id
);

то он выполнится меньше чем за 1 мс — в 100 с лишним раз быстрее! (Возможно, этот случай уже оптимизирован в PostgreSQL 18 благодаря патчу, но автор ещё не проверял).

Прим. ред.

В PostgreSQL 18 ничего не изменилось. Патч Александра Короткова и Андрея Лепихова, на который ссылается автор, оптимизирует другую ситуацию, когда в запросе есть несколько условий OR для одного и того же столбца

Удивительно, ведь у нас есть индексы по обоим фильтруемым столбцам.

Так в чём же дело? Для начала нужно разобраться, как и когда используются индексы.

Планирование запросов на интуитивном уровне

Представим таблицу с двумя столбцами и отдельным индексом по каждому из них:

CREATE TABLE example_table (
 text_column VARCHAR(2) NOT NULL,
 num_column  INT8       NOT NULL
);

По временнóй сложности поиск по одному столбцу схож с поиском при помощи BTreeMap<String, ExampleTable> или BTreeMap<i64, ExampleTable>.

Если же использовать составной индекс, например (text_column, num_column), поиск будет схож с BTreeMap<String, BTreeMap<i64, ExampleTable>>. Таким образом, запросы с AND (то есть поиск сначала по первому, а затем по второму столбцу), естественно, ложатся на составные индексы.

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

Найти строки, где text_column = 'ES' AND num_column = 1, можно двумя способами:

  • найти все строки с 'ES', а затем отфильтровать их по num_column;

  • найти все строки с 1, а затем отфильтровать по text_column.

Здесь возникают две проблемы:

  • Если в строках с 1 много значений 'ES' (или наоборот) и мы выберем не тот план, то прочитаем с диска гораздо больше данных, чем нужно. Помните: индексы хранятся на диске блоками (страницами), а ввод-вывод обычно является узким местом.

  • CPU и память тоже важны! Загрузка в память больших объёмов данных и их обработка могут дорого стоить, особенно при соединении с другими таблицами.

Статистика как раз помогает прояснить порядок: она показывает, насколько часто встречаются те или иные значения, и помогает решить, по какому столбцу искать в первую очередь.

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

Например, если значений 1 очень много, а 'ES' — мало, статистика покажет, что 1 входит в список наиболее частых значений своего столбца (низкая селективность), а 'ES' — нет (высокая селективность). Остаётся создать индекс по более селективному столбцу.

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

Почему OR — это дорого

С условием OR всё сложнее. В запросе с условием text_column = 'ES' OR num_column = 1 можно:

  • применить каждый фильтр отдельно, а затем объединить результаты, как делает union distinct;

  • пройтись по всей таблице последовательно (sequential scan), фильтруя строки в памяти.

Статистика всё равно может помочь. Если 'ES' и 1 — редкие значения, можно выполнить индексный поиск и прочесть только подходящие строки — это лучше, чем сканировать всю таблицу. Выполнив такой поиск дважды для каждого фильтра и объединив результаты с помощью побитового OR (bitwise OR), мы избегаем последовательного сканирования. Но и такой вариант обходится дорого, как показал наш первый пример.

Кроме того, подход с побитовым OR нестабилен. Если к запросу добавляется ещё одно условие (например, дополнительный AND или сортировка по третьему столбцу), то без составных индексов (включающих этот третий столбец) придётся снова делать полный перебор.

Попробуем переписать запрос как union: (x or y) and z = (x and z) or (y and z).

Пересечения и объединения соответствуют операциям AND и OR, а также сложению и умножению. Все эти операции обладают свойствами дистрибутивности, ассоциативности и коммутативности. 

Операция AND (пересечение) уменьшает объём данных, а OR (объединение) увеличивает его, а с ним и стоимость операции. Эта идея лежит в основе алгоритмов соединений с оптимальной сложностью в худшем случае (Worst-case optimal join algorithms). 

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

Практические советы

В проектировании схем встречаются две типичные проблемы:

  1. Таблица содержит несколько столбцов одинакового типа с одинаковыми ограничениями — обычно внешними ключами, ссылающимися на одну и ту же таблицу.

  2. Представление типов-сумм (type sum) в виде отдельных таблиц.

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

Просто добавьте ещё одну таблицу

Вернёмся к первому примеру:

CREATE TABLE application (
 application_id INT8 NOT NULL,
 submitter_id   INT8 NOT NULL,
 reviewer_id    INT8 NOT NULL
);

Чтобы найти все заявки, связанные с конкретным пользователем, можно создать вспомогательную таблицу, связанную отношением «один ко многим»:

create table application_user (
 user_id int8 not null,
 application_id int8 not null,
 user_type enum ('submitter', 'reviewer') not null
);

Можно переписать запрос так, чтобы сначала использовался индекс по user_id, а затем — по application_id:

select *
from application a
join application_user au using (application_id)
where au.user_id = :user_id

Этот подход линеен и предсказуем, что сильно упрощает работу планировщика. В результате он оказывается быстрее, чем исходный запрос с побитовым OR из первого примера.

Наследование и пагинация по ключу (keyset pagination)

Предположим, у нас есть две таблицы с общим набором столбцов:

create table post (
 user_id int8 not null,
 title text not null,
 body text not null
);
create table comment (
 user_id int8 not null,
 body text not null,
 parent_id int8 not null
);

Здесь лучший способ найти всё, что написал пользователь, — это написать запрос с union. 

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

create table writing (
 writing_id int8 not null,
 user_id int8 not null,
 body text not null
);
create table post (
 writing_id int8 not null,
 title text not null,
 foreign key writing_id references writing
);
create table comment (
 writing_id int8 not null,
 parent_id int8 not null,
 foreign key writing_id references writing
);

Можно обойтись и без этого: чтобы получить все совпадения, достаточно использовать union:

select *
from (
   select p.post_id, null comment_id, p.title, p.body, null parent_id
   from post p
   where p.user_id = :user_id
   and p.post_id > :post_id_start
   order by p.post_id
   limit :page_size
   union
   select null post_id, c.comment_id, null, c.body, c.parent_id
   from comment c
   where c.user_id = :user_id
   and c.comment_id > :comment_id_start
   order by c.comment_id
   limit :page_size
) keyset
order by keyset.post_id, keyset.comment_id
limit :page_size

Однако этот код гораздо сложнее, и в нём легко допустить ошибку, поэтому лучше использовать показанный приём с расширением таблиц.

Вывод

Проектирование схемы в первую очередь должно учитывать, как мы собираемся обращаться к данным:

  • По каким условиям выбираются отдельные строки или множества строк?

  • Преобладают чтения или записи?

    • Возможен ли одновременный доступ к одним и тем же таблицам?

    • ....

Если вы не используете OR, то и беспокоиться не о чем. Но кто знает, ведь всё может измениться...

Вопросы для размышления:

  • Как обычным инвертированным индексам удаётся эффективно обрабатывать условия OR и отрицания?

  • Есть ли материалы, глубоко разбирающие планирование запросов в PostgreSQL в контексте OR? Особенно при соединениях, а не только при фильтрации по одному столбцу в одной таблице.

  • Публиковались ли научные исследования по запросам с операцией OR?

  • Есть ли у расширения таблиц официальное название? В статье Джейми Брандона Against SQL упоминается, что это общеизвестный приём, но я не встречал прямого обсуждения этого подхода в контексте проектирования схем или производительности.

  • Были ли случаи, когда составной индекс на таблице помог ускорить запросы с операцией OR к этой таблице?

Комментарии (7)


  1. Akina
    24.10.2025 15:51

    Чтобы найти все заявки, связанные с конкретным пользователем, можно создать вспомогательную таблицу, связанную отношением «один ко многим»

    Во-первых, как вы намерены поддерживать целостность? Триггерами? Нет, на показанном материале это нормально, но есть области, где такой оверхед будет неоправданно наклАдным. особенно если рассматриваемый запрос - нечастый/некритичный.

    Во-вторых, зачем создавать ещё одну, дополнительную, таблицу, если исходную можно этой дополнительной таблицей заместить? А представление данных в формате, который предлагала исходная таблица, можно сделать несложным (извините за туфтологию) представлением.


  1. Kerman
    24.10.2025 15:51

    Я, как древний программист, больше люблю читаемость. А вы предлагаете заменить неплохо читаемый OR на селект с тремя подзапросами. Под предлогом того, что у вас это сработало. Так это может и сработало, только на ВАШЕЙ базе, с вашей версией БД и с вашим тюнингом. И с вашими сценариями. Может внезапно так оказаться, что это database-specific поведение и в качестве общего совета ваш совет - так себе. То, что совет так себе с точки зрения поддержки кода - я уже не сомневаюсь.


  1. php7
    24.10.2025 15:51

    А если добавить LIMIT 100 и сортировку по дате?
    Хотя это не тот случай.
    А вообще интересное решение.


  1. Spiritschaser
    24.10.2025 15:51

    Ну так оптимизатор PostgreSQL не совершенен. В MS-SQL всё проще.


    1. stanukih
      24.10.2025 15:51

      Тоже хотел спросить. Название OR в SQL — это дорого. Речь про конкретно postgresql. Было бы интересно сравнить, что с MySQL, MS SQL, Oracle etc.


      1. mentin
        24.10.2025 15:51

        На аналитических запросах многие базы умудряются даже JOIN с OR нормально исполнять используя Hash join.

        Запросы вроде
        FROM a JOIN b ON a.p = b.q OR a.r = b.s.


      1. astentx
        24.10.2025 15:51

        Кстати, да. Некоторые СУБД сами понимают, что можно сделать UNION или сперва скан по одному столбцу, а потом фильтр. А тут под конкретный запрос менять схему хранения: выглядит ужасными костылями