Неделю назад прилетел тикет: «Страница заказов грузится вечность». Открыл — действительно, 12 секунд на первую загрузку. На проде. С реальными пользователями.
Спойлер: проблема была в том месте, куда я смотрел последним.
Что имеем
Типичный интернет-магазин на Django + PostgreSQL. Админка, где менеджеры смотрят список заказов. Таблица orders — примерно 800 тысяч записей, растёт на 2-3 тысячи в день.
Запрос, который дёргает страница:
SELECT * FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 50;
Казалось бы, что тут сложного? LIMIT 50, да ещё и с фильтром по статусу.
Первые подозрения
Открыл EXPLAIN ANALYZE. Результат:
Seq Scan on orders (cost=0.00..45892.00 rows=234521 width=312)
Filter: (status = 'pending')
Rows Removed by Filter: 567842
Ага, Seq Scan. Полный перебор 800 тысяч строк, чтобы выбрать 50.
Ладно, думаю, добавлю индекс на status:
CREATE INDEX idx_orders_status ON orders(status);
Запускаю снова. Время упало до 4 секунд. Лучше, но всё равно много.
Где собака зарыта
Смотрю план ещё раз:
Index Scan using idx_orders_status on orders
Index Cond: (status = 'pending')
Sort: ...created_at DESC
Вот оно. PostgreSQL находит 230 тысяч записей со статусом pending, потом сортирует их все по дате, и только потом берёт первые 50.
Проблема не в фильтрации. Проблема в сортировке.
Решение
Составной индекс. Причём порядок полей — от этого зависит всё:
CREATE INDEX idx_orders_status_created
ON orders(status, created_at DESC);
Почему именно так? PostgreSQL сможет пройти по индексу уже в нужном порядке. Сначала фильтрует по status, потом идёт по created_at — и останавливается, как только набрал 50 строк.
Результат:
Index Scan using idx_orders_status_created on orders
Index Cond: (status = 'pending')
Rows: 50
Actual Time: 0.04..0.08 ms
40 миллисекунд. Не 12 секунд, не 4 секунды. 40 мс.
Почему я не сделал это сразу
Честно говоря, привык думать об индексах как о чём-то для WHERE. Забыл, что ORDER BY + LIMIT — это отдельная история. База может найти миллион подходящих строк за секунду, но если их надо отсортировать в памяти — привет, тормоза.
Второй момент: порядок полей в составном индексе. (created_at, status) работал бы хуже, потому что сначала пришлось бы сканировать по дате, а уже потом фильтровать по статусу.
Проверка на проде
Добавил индекс в миграцию с CONCURRENTLY, чтобы не блокировать таблицу:
CREATE INDEX CONCURRENTLY idx_orders_status_created
ON orders(status, created_at DESC);
На 800 тысяч записей создание заняло около 40 секунд. Таблица всё это время была доступна.
После деплоя страница открывается мгновенно. Менеджеры довольны, тикет закрыт.
Что вынес
EXPLAIN ANALYZE— всегда. Не гадать, а смотреть план.Составные индексы для запросов с WHERE + ORDER BY. Порядок полей: сначала то, по чему фильтруем, потом то, по чему сортируем.
LIMITне спасает, если сортировка идёт после фильтрации. База должна сначала найти все подходящие строки.CREATE INDEX CONCURRENTLY— иначе таблица блокируется на время создания индекса.
Мелочь, одна строчка в миграции. А пользователи ждали по 12 секунд.
Если сталкивались с похожим — пишите в комментариях. Интересно, какие ещё неочевидные случаи бывают с индексами.
Комментарии (28)

mSnus
28.01.2026 21:02Это лучше, чем очередная статья про АИ, но, честно говоря, АИ скорее всего тоже справился бы не хуже

holodoz
28.01.2026 21:02Вот тогда коммент про AI. Я скопировал первый абзац из Что имеем, плюс запрос, плюс "Запрос выполняется очень долго, как можно ускорить?" chatgpt всё четко расписал, первым предложил частичный индекс, второй вариант - решение из статьи. И потом ещё несколько пунктов, тоже полезных

OlegIct
28.01.2026 21:02про AI ладно, главное чтобы не обработанная AI. Эта статья написана человеком, к ней не прикасался AI, приятно читать. Написано по-русски «составной индекс», а не “многоколоночный”, за одно это статья достойна похвалы. Статью можно обновить - дополнить решением с частичным индексом и будет совсем хорошо. Новую статью про частичный индекс писать не нужно, в хабе PostgreSQL уже есть свой «enfant terrible» pg_expecto, который бы так и сделал, хорошо, что ему это удаётся только раз в неделю

PelmenBlin
28.01.2026 21:02А может кто-нибудь обьяснить вообще почему сортировка по дате? Разве нет в таблице id с primarykey и автоинкрементом? Я всегда по нему сортирую. Или есть какой-то подводный камень?

rSedoy
28.01.2026 21:02Да, можно и по нему, но не всегда primary key это автоинкремент, например, там может быть uuid4

supercat1337
28.01.2026 21:02С сортировкой-то все понятно. Я ещё саму дату индексирую, ускоряет фильтрацию.

pae174
28.01.2026 21:02Интересно, а где все это время был мониторинг? В ответ на 12 секунд открытия страницы прилетает тикет от человека а не от мониторинга.

Akina
28.01.2026 21:02Actual Time: 0.04..0.08 ms
40 миллисекунд. Не 12 секунд, не 4 секунды. 40 мс.
0.04 ms и 40 миллисекунд не одно и то же, они различаются... на 3 порядка.

tester37
28.01.2026 21:02По кейсу вообще есть мысль что просто нужны отдельные таблицы по статусам. Выполненные и невыполненные ордера.

Akina
28.01.2026 21:02Почему не секционирование?

Areso
28.01.2026 21:02Потому что таскать записи между партициями по полю, которое меняет значение (в процессе жизни записи), не считается best practices для высоконагруженных таблиц.

Akina
28.01.2026 21:02То есть, по-вашему, таскать из одной таблицы в другую - нормально, а из секции в секцию, которые по сути такие же таблицы (у нас же постгресс) - это уже моветон? Вот совсем не понимаю. Зато понимаю, что, как только потребуется что-то без оглядки на статус, придётся лезть в две отдельные таблицы и объединять полученные субнаборы. То есть от двух таблиц, как по мне, никакого профиту и геморрой на горизонте. Да и хранение одной сущности в нескольких таблицах - это ещё меньший best practices, кмк.

Areso
28.01.2026 21:02Если вы таскали из таблицы в таблицу, то вероятно, вы делали это руками или с автоматизацией. Т.е. это был контролируемый процесс против неконтролируемого процесса, который происходит в движке СУБД, когда он решает перенести данные.
-
Второе, представьте, вы решили секционировать таблицу по статусам. Как вы будете подчищать данные? У вас есть 100 тысяч заказов в работе, 30 тысяч в статусах оспаривается, возврат, гарантия, и, скажем, за 10 лет работы магазина 20 млн заказов в последней секции со статусом Done. Оп, и вы уже пишите какую-то логику чтобы подчищать Done и устраивать по ней вакуум. Без логики, целиком, нельзя - можно удалить заказ, который выполнен 5 лет назад, но более поздние заказы вам могут пригодиться для гарантии, спорных случаев, бухгалтерской проверки.
Поэтому я бы сделал вообще по-другому. Сделал бы секционирование по дате создания, и транкейтил бы их спустя 37 (49, 61, подставьте ваше значение) месяцев, предварительно убедившись, что внутри партиции нет заказов, зависших в любых статусах, кроме "завершен". Если там что-то есть - то перенес бы в отдельную таблицу, и все равно бы затранкейтил бы =)

Akina
28.01.2026 21:02Т.е. это был контролируемый процесс против неконтролируемого процесса, который происходит в движке СУБД, когда он решает перенести данные.
Не так. Контролируемый кодом против контролируемого движком. И я сторонник как раз того, чтобы контролировала СУБД.
представьте, вы решили секционировать таблицу по статусам. Как вы будете подчищать данные?
Я не понял, что имеется в виду под термином "подчищать". Но если вы имеете в виду удаление, то я этого делать не буду в принципе. А ещё я вспомню о субсекционировании.

shirmanov
28.01.2026 21:02Ого! Ничего себе. Давайте вспомним, что Postgres - версионник (mvcc). Это как-то меняет ваше утверждение?

Areso
28.01.2026 21:02Смотря на каком уровне мы оперируем. На логическом - запись имеет тот же ключ, эта та же запись. На физическом - старая будет отмечена как удаленная, а новая будет записана.
Теперь возвращаемся к моему утверждению, что в случае, если запись поменяет "прописку" из одной секции в другую, то это будет несколько дороже по сравнению со случаем, где работа ведется в одной партиции.
Хотя бы потому что вы накладываете 2 блокировки, на 2 партиции. И как я сказал, в пределе, под высокой нагрузкой, это может быть не самым оптимальным решением.

mserg86
28.01.2026 21:02Секционированием можно отрезать прошлые периоды, которые редко запрашиваются. Актуальные и часто запрашиваемые данные держите в куче MAXVALUE. В результате получите сокращение набора данных для выборки, сокращение B-Tree, что должно дать неплохое ускорение. Нужно тестировать на вашей рабочей нагрузке и железе.

Akina
28.01.2026 21:02При явном WHERE прошлые периоды сами обрежутся, на то и partition pruning. Главное, чтобы парсер по тексту мог легко понять, что условие отбора (или какая-то его часть) точно соответствует выражению секционирования.

shirmanov
28.01.2026 21:02У вас на прод попала схема без индексов оптимизированных под запросы из приложения. Вы начинаете делать индексы под запросы сразу на проде, после жалобы, видимо пользователей. Что-то идёт не так. И дело не в индексах.

pg_expecto
28.01.2026 21:02По личному опыту участия в проектах по импортозамещению.
Что-то идёт не так. И дело не в индексах.
Ситуация совершенно стандартная .
Сейчас - так. Исключения настолько редки, что лишь подтверждают правило - "Х*** , х*** и в продакшн".
Djaler
В следующей серии научитесь делать частичный индекс с условием по статусу, правильно понимаю? Ну серьезно, это не тянет на статью, это максимум заметка в телеграм канале.
А план говорит про другое количество строк:
Seq Scan on orders (cost=0.00..45892.00 rows=234521 width=312)OlegIct
там дальше
Rows Removed by Filter: 567842567842+234521=802363 строки
Djaler
Да, тут мой косяк, спасибо
Areso
Я пригласил человека не потому что это сложная статья, а потому что это частая проблема у наших разработчиков.
И в следующий раз, после того как я сделаю show create table <> и увижу эту проблему, я просто скину ссылку на эту статью.
Akina
SHOW CREATE TABLE не может показать ЭТУ проблему в принципе.
Areso
Смотря в каком контексте. Поясню подробно.
1. Вижу в slow queries запрос. Беру его.
2. Иду в таблицу (ы), которые он дергает. Здесь всего одна таблица. Смотрю ее описание. Иногда здесь и останавливаюсь. Честно
3. Если пункта 2 не хватило, то делается EXPLAIN.
Сам по себе SHOW CREATE TABLE показывает только DDL таблицы, но мы вообще-то шли в контексте обсуждения проблемы и статьи. Если ты видишь отсутствующий индекс в таблицe orders на поле created_at c таким запросом
SELECT * FROM orders WHERE created_at<$1;
нужно ли тебе что-то еще? гонять эксплейн? Очевидно же.
Так и здесь -- если я вижу подобную ситуацию, мне не всегда нужен explain.