18 апр. 2025 г.·6 мин чтения

Ошибки отсечения разделов PostgreSQL, из-за которых запросы тормозят

Отсечение разделов в PostgreSQL часто не срабатывает из-за приведения типов, функций, слишком широких фильтров и параметров на стороне приложения. Узнайте, что ломает планы и что исправить в первую очередь.

Ошибки отсечения разделов PostgreSQL, из-за которых запросы тормозят

Почему разделённые таблицы всё равно сканируют слишком много

Отсечение разделов должно быть одним из главных преимуществ разделённых таблиц. Разбейте таблицу orders по месяцам, запросите апрель — и PostgreSQL должен прочитать апрель и пропустить всё остальное.

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

Сначала план может выглядеть безобидно. Вы запрашиваете данные за одну неделю, а EXPLAIN показывает Append или Parallel Append по 12, 24 или 36 разделам. У каждого дочернего скана тот же фильтр, поэтому PostgreSQL проверяет раздел за разделом вместо того, чтобы сразу отбросить большую их часть.

На маленькой таблице вы можете этого даже не заметить. На загруженной системе лишняя работа быстро накапливается. Чем больше разделов, тем больше времени уходит на планирование, тем больше индексов приходится открывать, тем больше страниц heap затрагивается и тем больше I/O возникает, если данные ещё не в памяти. Даже короткие сканы вредят, когда их десятки.

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

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

Та же проблема возникает из-за схемы данных. Если ключ раздела не совпадает с тем, как люди реально ищут данные, PostgreSQL просто не может пропустить достаточно много. Разделённые таблицы у вас остаются, а ожидаемого ускорения уже нет.

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

Как PostgreSQL решает, какие разделы читать

PostgreSQL отсеивает разделы, сравнивая ваш WHERE с границами разделов. Если таблица разбита по order_date, планировщик сначала ищет условия по order_date, которые он может понять до чтения данных. Если это сравнение ясное, он может пропустить целые разделы.

Простые константы сильно упрощают задачу. Фильтр вроде order_date >= DATE '2025-01-01' AND order_date < DATE '2025-02-01' точно показывает PostgreSQL, какие месячные разделы могут подойти. Часто планировщик может сузить скан до одного раздела ещё до начала выполнения.

Это отсечение на этапе планирования. Оно происходит, когда PostgreSQL уже знает значения фильтра во время построения плана. Литеральные даты, числа и другие значения, которые сворачиваются в константы, хорошо работают в таком сценарии.

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

Именно здесь люди часто ошибаются. Если вы пишете date_trunc('month', order_date) = DATE '2025-01-01', PostgreSQL приходится рассуждать о функции, применённой к колонке раздела. Это намного сложнее, чем проверить саму колонку простым диапазоном. То же самое происходит с приведением типов и арифметикой на стороне колонки.

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

WHERE order_date >= DATE '2025-01-01'
  AND order_date < DATE '2025-02-01'

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

Выбор схемы, который мешает отсечению

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

Начните с типа данных. Выберите один тип для колонки раздела и используйте его везде. Если таблица разделена по date, передавайте из приложения значения date. Если она разделена по timestamptz, держите фильтры тоже в timestamptz. Проблемы начинаются, когда в колонке хранится один тип, а приложение отправляет текст, смешанные типы параметров или приведение вроде created_at::date.

Последний пример очень распространён и дорог. Когда вы приводите колонку в WHERE, вы скрываете от планировщика исходное значение раздела. Приводите тип у константы или параметра, а не у колонки раздела.

Обработка часовых поясов даёт ту же проблему. Выберите одно правило и придерживайтесь его. Храните UTC в timestamptz или храните локальное время в timestamp, если вам действительно это нужно. Смешение обоих вариантов добавляет неявные преобразования, а они могут заставить PostgreSQL читать больше разделов, чем вы ожидаете.

Даже небольшого несоответствия достаточно. Если приложение запрашивает «1 марта» по локальному времени, а разделы делят данные по UTC-месяцам, фильтр может пересечь границу раздела, хотя пользователю кажется, что он охватывает один день.

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

Границы диапазонов должны быть аккуратными. Обычно лучше всего работают полуоткрытые диапазоны: >= start и < end. Они не перекрываются, совпадают с границами разделов и уменьшают риск ошибок на стыке дат.

Ещё одна ловушка — схемы с большим количеством выражений. Фильтры вроде date_trunc('month', created_at), timezone('UTC', created_at) или coalesce(created_at, now()) заставляют планировщик обходить функцию вместо того, чтобы отсекать по сырому значению. Если пользователи всегда ищут по производной бизнес-дате, храните это значение напрямую и делите таблицу по нему, а не оборачивайте каждый фильтр логикой.

Хороший ключ раздела — скучный. Один тип, одно правило времени и прямые сравнения обычно лучше, чем хитрый дизайн.

SQL-шаблоны, которые тихо ломают план

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

Одна из частых ошибок — приводить колонку раздела внутри WHERE. Если таблица разделена по created_at, это может мешать отсечению: created_at::date = DATE '2025-01-15'. Теперь PostgreSQL должен преобразовать значение каждой строки до сравнения. Обычно лучше работает диапазон: created_at >= TIMESTAMP '2025-01-15 00:00:00' AND created_at < TIMESTAMP '2025-01-16 00:00:00'.

Функции создают ту же проблему. Запросы вроде date_trunc('month', created_at) = DATE '2025-01-01' или extract(year from created_at) = 2025 выглядят аккуратно, но скрывают исходную колонку от планировщика.

OR тоже быстро всё усложняет. Один чистый диапазон по колонке раздела легко отсечь. Условие вроде customer_id = 42 OR created_at >= '2025-01-01' часто подталкивает PostgreSQL к более широкому скану, потому что одна из сторон OR плохо сужает разделы. Если возможно, разбейте это на отдельные запросы с UNION ALL или перепишите в более простые предикаты.

Несовпадение типов из кода приложения мешает чаще, чем люди ожидают. Если приложение отправляет '2025-01-15' как текст, а PostgreSQL должен угадывать тип, вы можете получить лишние приведения или менее точные оценки. То же самое касается числовых идентификаторов, переданных как строки. Связывайте параметры с правильным типом с самого начала.

Большие списки IN (...) легко не заметить. Список IN по колонке раздела всё ещё может помочь с отсечением, но только до тех разделов, которые есть в списке. Если список охватывает 18 месяцев, PostgreSQL всё равно может открыть 18 месячных разделов. Это лучше, чем сканировать все, но работы всё равно много. Во многих случаях узкий диапазон даёт и лучший план, и более простой запрос.

Полезна простая мысленная проверка: сравнивайте колонку раздела напрямую, используйте типизированные даты и timestamp, не заворачивайте колонку в функции и с подозрением относитесь к широким OR-условиям.

Как пошагово проверить отсечение

Проверьте запросы, которые генерирует приложение
Проверьте SQL вашего приложения, фильтры ORM и вывод плана с опытным CTO.

Если запрос должен попасть в один месячный раздел, а PostgreSQL читает двенадцать, начните с плана. EXPLAIN (ANALYZE, VERBOSE) показывает, какие дочерние таблицы он затронул. Посчитайте их. Если под Append или Merge Append вы видите много разделов, отсечение сработало не так, как вы ожидали.

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

Тест с литералом важнее, чем кажется. Если WHERE created_at >= DATE '2025-03-01' AND created_at < DATE '2025-04-01' читает один раздел, а исходный запрос с параметром или обёрнутым выражением читает много, значит, вероятнее всего, с самой таблицей всё в порядке. Проблема в форме фильтра.

Прямые предикаты обычно работают лучше, чем выражения по колонке раздела. Проверка по created_at часто отсекáет разделы чисто. А проверка вроде date_trunc('month', created_at) = DATE '2025-03-01' часто нет. Для вас эти два запроса почти одно и то же, а для планировщика — нет.

Подготовленные запросы заслуживают отдельной проверки. Некоторые драйверы после нескольких запусков переходят на обобщённый план. Этот план может перестать использовать конкретное значение параметра настолько хорошо, чтобы сразу сузить скан. Если запрос быстрый в SQL-консоли и медленный в приложении, сравните эти два случая ещё до того, как трогать схему.

Измеряйте после каждого небольшого изменения. Падение с 24 сканируемых разделов до 2 говорит больше, чем любая теория.

Простой пример таблицы заказов по месяцам

Небольшая таблица orders хорошо показывает проблему. Для неё не нужны специальные инструменты. Достаточно обычного psql и EXPLAIN.

CREATE TABLE orders (
  id bigint NOT NULL,
  customer_id bigint NOT NULL,
  order_date timestamp NOT NULL,
  total numeric(10,2) NOT NULL
) PARTITION BY RANGE (order_date);

CREATE TABLE orders_2025_01 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE orders_2025_02 PARTITION OF orders
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

CREATE TABLE orders_2025_03 PARTITION OF orders
FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');

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

EXPLAIN
SELECT count(*)
FROM orders
WHERE order_date >= TIMESTAMP '2025-02-01'
  AND order_date < TIMESTAMP '2025-03-01';

Это чистый случай. Фильтр использует колонку раздела напрямую, а диапазон дат совпадает с одним месяцем. PostgreSQL может удержать план в рамках orders_2025_02.

Небольшая перепись может всё испортить.

EXPLAIN
SELECT count(*)
FROM orders
WHERE date_trunc('month', order_date) = TIMESTAMP '2025-02-01 00:00:00';

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

Код приложения добавляет ещё одну ловушку. Многие приложения передают месяц вроде "2025-02" как текст и строят SQL вокруг этой строки.

PREPARE bad_month(text) AS
SELECT count(*)
FROM orders
WHERE to_char(order_date, 'YYYY-MM') = $1;

EXPLAIN EXECUTE bad_month('2025-02');

Текстовый параметр переводит запрос в строковое сравнение. Теперь PostgreSQL должен запускать to_char для строк в каждом проверяемом разделе. Ответ по-прежнему будет правильным, но план становится шире и медленнее.

Если вам нужен один месяц, держите значение как date или timestamp и превращайте его в полуоткрытый диапазон. Так планировщик получает честный шанс прочитать один раздел вместо трёх.

Ошибки, которые начинаются в коде приложения и ORM

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

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

Одна из частых проблем — несовпадение типа параметра. Колонка хранит дату, timestamp или число, а приложение связывает значение как текст. Тогда PostgreSQL должен приводить тип где-то по пути, и эта мелочь может подтолкнуть планировщик к более слабому плану. Если ключ раздела — order_date, а приложение передаёт "2025-01-01" как нетипизированную строку, вы можете потерять прямое сравнение, которое нужно для отсечения.

Необязательные фильтры тоже создают проблемы. Многие части кода строят один запрос и делают условия необязательными по шаблону вроде order_date >= $1 OR $1 IS NULL. Это выглядит аккуратно, но скрывает настоящий предикат. Планировщику приходится учитывать обе ветки, и отсечение часто становится менее точным. Два простых запроса обычно лучше, чем один хитрый.

Методы-помощники внутри ORM могут нанести такой же вред. Метод, который оборачивает колонку раздела в DATE(), COALESCE() или какую-то собственную абстракцию, может выглядеть удобно в коде приложения, но он превращает прямой фильтр в выражение. Как только настоящая проверка прячется за этой обёрткой, отсечение может перестать работать.

Подготовленные запросы — ещё один тихий источник проблем с планом. Если приложение повторно использует один подготовленный запрос и для «последних 3 дней», и для «последних 3 лет», PostgreSQL может выбрать обобщённый план, который приемлемо работает для обоих случаев, но не идеально подходит ни для одного. Для разделённых таблиц это часто означает сканирование большего числа разделов, чем нужно по реальному значению.

Когда вы отлаживаете это, не останавливайтесь на шаблоне SQL. Посмотрите на финальный SQL, который сформировал ORM, на подставленные значения, на типы параметров и на то, использовался ли подготовленный запрос. Обычно это и объясняет разницу между запросом, который вы думаете, что отправили, и тем, который PostgreSQL на самом деле спланировал.

Быстрые проверки перед тем, как менять схему

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

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

Хорошая первая проверка простая: прочитайте WHERE, как будто PostgreSQL должен сам принять решение. Если фильтр напрямую касается сырой колонки раздела, отсечение обычно имеет хорошие шансы. Если запрос оборачивает эту колонку в date(), timezone(), coalesce() или другое выражение, планировщик может перестать отсекать разделы и читать гораздо больше, чем вы ожидали.

Следующие проверки тоже несложные. Точно сопоставьте типы параметров с типом колонки, включая date, timestamp и timestamptz. Запустите EXPLAIN и проверьте, читает ли план только те разделы, которые вы ожидаете. Сравните границы ваших разделов с реальным поведением поиска, а не с тем, что выглядело аккуратно при проектировании. Потом проверьте вывод ORM на скрытые приведения типов, лишние OR-условия и необязательные фильтры, которые меняют план.

Несовпадение типов создаёт больше проблем, чем ожидают многие команды. Разделённая таблица на created_at timestamptz может вести себя совсем иначе, если приложение отправляет timestamp или текстовый параметр и оставляет PostgreSQL приводить его позже. В коде приложения такое приведение выглядит безобидно. В плане оно может обернуться лишними чтениями.

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

ORM часто усугубляют это, добавляя обёртки вроде ($1 is null OR created_at >= $1) или приводя значения к общим текстовым типам. Эти упрощения помогают повторно использовать код, но могут вредить отсечению.

Если после этих проверок EXPLAIN всё ещё показывает слишком много разделов, тогда уже есть смысл пересматривать схему. До этого момента менять дизайн таблицы — часто просто дорогое предположение.

Что исправить первым

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

Хорошая первая цель — запрос, который люди запускают часто, например свежие заказы за один месяц или диапазон по одному клиенту. Запустите план, отметьте, сколько разделов читает PostgreSQL, и сохраняйте этот же запрос, пока тестируете изменения. Если план сканирует в десять раз больше разделов, чем ожидалось, вы уже знаете, где искать.

Сначала приведите в порядок предикат, а уже потом переделывайте разделы. Многие плохие планы появляются из-за небольших решений в SQL, а не из-за самой структуры разделов. Приведение типа у колонки раздела, функция вокруг даты, смешанные типы данных или OR, который объединяет несвязанные условия, могут помешать отсечению выполнять свою работу.

Проверьте тип фильтрующей колонки относительно типа границы раздела. Уберите обёртки вроде date(created_at), если вместо них можно использовать обычный диапазон. Посмотрите на точный SQL из приложения, а не на написанную вручную версию. Протестируйте подготовленный запрос и версию с литералами рядом. Затем убедитесь, что границы разделов совпадают с тем, как приложение на самом деле фильтрует данные.

Подготовленные запросы и вывод ORM заслуживают особого внимания. Команды часто смотрят на аккуратный запрос из консоли, а в production приходит параметризованный SQL с приведениями типов или обобщёнными планами. Эта разница важна. Запрос, который хорошо отсеивает разделы с фиксированными значениями, может сканировать намного больше разделов, когда приложение отправляет его через ORM.

Смотрите на определение таблицы, сгенерированный SQL и план выполнения вместе. По отдельности этого обычно недостаточно. Если таблица делится по месяцам, а приложение фильтрует по выражениям со сдвигом часового пояса, планировщику приходится делать больше работы, чем кажется.

Если после очистки предикатов план всё ещё слишком много сканирует, поможет вторичный разбор. Олег Sotnikov на oleg.is работает со стартапами и небольшими командами над производительностью PostgreSQL, дизайном схем и шаблонами запросов приложения. Иногда короткий архитектурный разбор быстрее и дешевле, чем ещё одна перепись.

Часто задаваемые вопросы

Почему PostgreSQL читает много разделов, если я задал только один диапазон дат?

PostgreSQL может пропустить разделы только тогда, когда он может напрямую сопоставить ваш WHERE с границами разделов. Если вы оборачиваете колонку раздела в функцию, приводите её к типу по стороне колонки или смешиваете типы, планировщик часто оставляет больше разделов в плане.

Какой вид WHERE лучше всего помогает отсечению разделов?

Используйте обычный полуоткрытый диапазон по самой колонке раздела. Например, created_at >= ... AND created_at < ... обычно даёт планировщику самый понятный путь для отсечения разделов.

Ломают ли pruning функции вроде date_trunc или extract?

Да, часто ломают. Фильтр вроде date_trunc('month', created_at) = ... скрывает исходную колонку, поэтому PostgreSQL обычно вынужден рассматривать больше разделов, чем при простом диапазоне.

Проблема ли приводить колонку раздела в WHERE?

Могут. created_at::date = ... выглядит безобидно, но заставляет PostgreSQL преобразовывать колонку перед сравнением. Лучше приводить параметр или константу, а колонку раздела оставлять как есть.

Могут ли часовые пояса заставить PostgreSQL сканировать лишние разделы?

Да, преобразования часовых поясов могут сдвигать диапазон через границы разделов. Если вы храните timestamptz, держите значения приложения в том же типе и используйте одно понятное правило времени, обычно UTC.

Мешают ли подготовленные запросы pruning?

Могут. После нескольких запусков некоторые драйверы переходят на обобщённый план, и он может уже не использовать конкретное значение параметра достаточно хорошо, чтобы отсекать разделы заранее. Сравните запрос из приложения с версией с литералами в psql, чтобы это проверить.

Почему мой запрос из ORM отсеивает разделы хуже, чем SQL, который я проверяю вручную?

ORM часто добавляют обёртки вроде DATE(created_at), необязательные OR-фильтры или текстовые параметры. Эти приёмы делают SQL красивее в коде приложения, но часто размывают прямое сравнение, которое нужно для отсечения разделов.

Подходят ли месячные разделы для коротких диапазонов дат?

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

Как проверить, действительно ли pruning работает?

Начните с EXPLAIN (ANALYZE, VERBOSE) и посчитайте дочерние разделы под Append или Merge Append. Затем перепишите фильтр с типизированными литералами по сырой колонке раздела и посмотрите, уменьшилось ли число разделов.

Сначала переписывать запрос или менять дизайн разделов?

Сначала исправьте запрос. Во многих случаях более чистый предикат, правильные типы параметров и меньшее число OR решают проблему без перестройки таблицы. Меняйте схему только после того, как простой SQL всё ещё сканирует слишком много.