Неделю назад прилетел тикет: «Страница заказов грузится вечность». Открыл — действительно, 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 секунд. Таблица всё это время была доступна.

После деплоя страница открывается мгновенно. Менеджеры довольны, тикет закрыт.

Что вынес

  1. EXPLAIN ANALYZE — всегда. Не гадать, а смотреть план.

  2. Составные индексы для запросов с WHERE + ORDER BY. Порядок полей: сначала то, по чему фильтруем, потом то, по чему сортируем.

  3. LIMIT не спасает, если сортировка идёт после фильтрации. База должна сначала найти все подходящие строки.

  4. CREATE INDEX CONCURRENTLY — иначе таблица блокируется на время создания индекса.

Мелочь, одна строчка в миграции. А пользователи ждали по 12 секунд.


Если сталкивались с похожим — пишите в комментариях. Интересно, какие ещё неочевидные случаи бывают с индексами.

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


  1. Djaler
    28.01.2026 21:02

    В следующей серии научитесь делать частичный индекс с условием по статусу, правильно понимаю? Ну серьезно, это не тянет на статью, это максимум заметка в телеграм канале.

    Полный перебор 800 тысяч строк

    А план говорит про другое количество строк: Seq Scan on orders (cost=0.00..45892.00 rows=234521 width=312)


    1. OlegIct
      28.01.2026 21:02

      там дальше

      Rows Removed by Filter: 567842

      567842+234521=802363 строки


      1. Djaler
        28.01.2026 21:02

        Да, тут мой косяк, спасибо


    1. Areso
      28.01.2026 21:02

      Я пригласил человека не потому что это сложная статья, а потому что это частая проблема у наших разработчиков.
      И в следующий раз, после того как я сделаю show create table <> и увижу эту проблему, я просто скину ссылку на эту статью.


      1. Akina
        28.01.2026 21:02

        И в следующий раз, после того как я сделаю show create table <> и увижу эту проблему

        SHOW CREATE TABLE не может показать ЭТУ проблему в принципе.


        1. Areso
          28.01.2026 21:02

          Смотря в каком контексте. Поясню подробно.
          1. Вижу в slow queries запрос. Беру его.
          2. Иду в таблицу (ы), которые он дергает. Здесь всего одна таблица. Смотрю ее описание. Иногда здесь и останавливаюсь. Честно
          3. Если пункта 2 не хватило, то делается EXPLAIN.


          Сам по себе SHOW CREATE TABLE показывает только DDL таблицы, но мы вообще-то шли в контексте обсуждения проблемы и статьи. Если ты видишь отсутствующий индекс в таблицe orders на поле created_at c таким запросом
          SELECT * FROM orders WHERE created_at<$1;
          нужно ли тебе что-то еще? гонять эксплейн? Очевидно же.

          Так и здесь -- если я вижу подобную ситуацию, мне не всегда нужен explain.


  1. mSnus
    28.01.2026 21:02

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


    1. holodoz
      28.01.2026 21:02

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


    1. OlegIct
      28.01.2026 21:02

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


  1. PelmenBlin
    28.01.2026 21:02

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


    1. rSedoy
      28.01.2026 21:02

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


    1. supercat1337
      28.01.2026 21:02

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


  1. pae174
    28.01.2026 21:02

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


  1. Akina
    28.01.2026 21:02

    Actual Time: 0.04..0.08 ms

    40 миллисекунд. Не 12 секунд, не 4 секунды. 40 мс.

    0.04 ms и 40 миллисекунд не одно и то же, они различаются... на 3 порядка.


  1. tester37
    28.01.2026 21:02

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


    1. Akina
      28.01.2026 21:02

      Почему не секционирование?


      1. Areso
        28.01.2026 21:02

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


        1. Akina
          28.01.2026 21:02

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


          1. Areso
            28.01.2026 21:02

            1. Если вы таскали из таблицы в таблицу, то вероятно, вы делали это руками или с автоматизацией. Т.е. это был контролируемый процесс против неконтролируемого процесса, который происходит в движке СУБД, когда он решает перенести данные.

            2. Второе, представьте, вы решили секционировать таблицу по статусам. Как вы будете подчищать данные? У вас есть 100 тысяч заказов в работе, 30 тысяч в статусах оспаривается, возврат, гарантия, и, скажем, за 10 лет работы магазина 20 млн заказов в последней секции со статусом Done. Оп, и вы уже пишите какую-то логику чтобы подчищать Done и устраивать по ней вакуум. Без логики, целиком, нельзя - можно удалить заказ, который выполнен 5 лет назад, но более поздние заказы вам могут пригодиться для гарантии, спорных случаев, бухгалтерской проверки.


              Поэтому я бы сделал вообще по-другому. Сделал бы секционирование по дате создания, и транкейтил бы их спустя 37 (49, 61, подставьте ваше значение) месяцев, предварительно убедившись, что внутри партиции нет заказов, зависших в любых статусах, кроме "завершен".  Если там что-то есть - то перенес бы в отдельную таблицу, и все равно бы затранкейтил бы =)


            1. Akina
              28.01.2026 21:02

              Т.е. это был контролируемый процесс против неконтролируемого процесса, который происходит в движке СУБД, когда он решает перенести данные.

              Не так. Контролируемый кодом против контролируемого движком. И я сторонник как раз того, чтобы контролировала СУБД.

              представьте, вы решили секционировать таблицу по статусам. Как вы будете подчищать данные?

              Я не понял, что имеется в виду под термином "подчищать". Но если вы имеете в виду удаление, то я этого делать не буду в принципе. А ещё я вспомню о субсекционировании.


        1. shirmanov
          28.01.2026 21:02

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


          1. Areso
            28.01.2026 21:02

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

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


  1. mserg86
    28.01.2026 21:02

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


    1. Akina
      28.01.2026 21:02

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


  1. shirmanov
    28.01.2026 21:02

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


    1. pg_expecto
      28.01.2026 21:02

      По личному опыту участия в проектах по импортозамещению.

      Что-то идёт не так. И дело не в индексах.

      Ситуация совершенно стандартная .

      Сейчас - так. Исключения настолько редки, что лишь подтверждают правило - "Х*** , х*** и в продакшн".


  1. SolidSnack
    28.01.2026 21:02

    С...подключением?))


  1. savostin
    28.01.2026 21:02

    800000 строк, 12 секунд. У вас прод на Raspberry PI чтоль?