Достаточно большое количество проблем производительности в backend-приложениях на самом деле находятся не в коде. За последние пару лет мне несколько раз приходилось разбирать системы, где:

  • API отвечало слишком долго

  • CPU базы был загружен почти на 100%

При этом всем, инфраструктура мощная: достаточное количество RAM, NVMe-диски ну и конечно же CPU последних поколений. Но проблема почти всегда оказывалась в SQL-запросах.
Я хочу поделиться реальным опытом, как мы оптимизировали PostgreSQL в десятки раз

Кейс 1. Индекс, который помог ускорить запрос

Один из самых типичных запросов в системе:

SELECT *
FROM orders
WHERE user_id = 54821
ORDER BY created_at DESC
LIMIT 20;

Таблица "orders" у нас содержит где-то 1 миллион строк, 25 колонок , ну и конечно же активную запись. Запрос выполнялся примерно 3.2 секунды.
План выполнения

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 54821
ORDER BY created_at DESC
LIMIT 20;

Фрагмент плана:

Seq Scan on orders
Filter: (user_id = 54821)
Rows Removed by Filter: 17984521

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

CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);

После такого уже новый план:

Index Scan using idx_orders_user_created

Примерное время выполнения 25 мс! Это почти в 100 раз ускорение.

Кейс 2. SELECT *, который вечно долго думал

Один из сервисов отдавал список заказов:

SELECT *
FROM orders
WHERE status = 'completed'
ORDER BY created_at DESC
LIMIT 1000;

Тут была проблема не в SQL-времени, а в объёме данных. Таблица содержала JSON-колонку "metadata", текстовые поля и некоторое количество больших колонок. Примерный размер строки 6кб, а 1000 строк примерно 6мб. Запрос выполнялся за 400мс. 

Решением такой проблемы послужило следующее решение:

SELECT id, user_id, total_price, created_at
FROM orders
WHERE status = 'completed'
ORDER BY created_at DESC
LIMIT 1000;

После такого изменения новый размер ответа составил где-то 140кб и время ответа составило 40мс. Ускорилось в 10 раз.

Кейс 3. JOIN, который создавал бомбежку всех строк

Один из аналитических запросов выглядел так:

SELECT *
FROM users
JOIN orders ON users.id = orders.user_id
JOIN payments ON orders.id = payments.order_id
JOIN reviews ON users.id = reviews.user_id;

Проблема заключается в кардинальности.
У пользователя есть 20 заказов, 20 платежей и 10 отзывов. Рузельтат 20 × 20 × 10 = 4000 строк. Это называется join explosion - объём результатов запроса с операцией JOIN значительно больше, чем ожидалось.

Решение следующее. Агрегируем данные до JOIN:

WITH payments_agg AS (
  SELECT order_id, SUM(amount) total
  FROM payments
  GROUP BY order_id
)

SELECT *
FROM orders
JOIN payments_agg
ON orders.id = payments_agg.order_id;

Инструменты, которыми я пользуюсь и которые могу помочь

Мой совет, если вы работайте с PostgreSQL, то обязательно используйте:

  • EXPLAIN ANALYZE которые показывает реальные строки. Вез него оптимизация SQL будет мучением.

  • pg_stat_statements который показывает медленные запросы, частые запросы и время их выполнения.

Мои личные правила для оптимизации SQL

  1. Индекс должен соответствовать запросу

  2. Не использовать OFFSET на огромных таблицах

  3. Избегать " SELECT *", лучше выбирать только нужные поля

  4. Всегда использовать " EXPLAIN ANALYZE" для лучше оптимизации

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


  1. LokkiDog
    20.03.2026 17:13

    Казалось бы «база», но всегда полезно о ней напомнить.

    А может есть решение как лучше оптимизировать запросы через ORM Django?


    1. Razor00913 Автор
      20.03.2026 17:13

      Спасибо большое за отзыв.

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


  1. MishaBucha
    20.03.2026 17:13

    Насчет offset очень точно, реальная проблема на больших данных

    keyset pagination спасает и супер прибавляет производительности)


    1. Razor00913 Автор
      20.03.2026 17:13

      Спасибо большое за отзыв!


  1. Tzimie
    20.03.2026 17:13

    WHERE user_id = 54821 и помог индекс по user_id? Да не может быть!


    1. Razor00913 Автор
      20.03.2026 17:13

      К сожалению из-за кривый баз данных приходится страдать такими вещами. А влезать в БД и исправлять, я не могу(


      1. Djaler
        20.03.2026 17:13

        А в чем кривость?


  1. Akina
    20.03.2026 17:13

    Кейс 3. JOIN, который создавал бомбежку всех строк

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

    1. Индекс должен соответствовать запросу

    Совет в таком виде - безусловно вредный. Он звучит как предложение на каждый запрос создавать оптимальный для него индекс. Зарастёте индексами! Продумайте получше формулировку.


    1. Razor00913 Автор
      20.03.2026 17:13

      Как я указал в другом комментарии, приходится извиваться нестандартными способами, которые работают...


      1. Akina
        20.03.2026 17:13

        Ваш первый запрос в третьем кейсе решает задачу получения детализации со всей связанной информацией. Вместо это вы во втором запросе получаете агрегированные данные, и не получаете часть связанной информации. То есть вы, может, и извивались, но тем не менее начальную задачу вы НЕ РЕШИЛИ, вы вместо неё решили совсем иную задачу. Такой подход не просто странен - его как-то вообще невозможно понять. Программист говорит заказчику "Тебе это не надо, я тебе вместо этого сделаю совсем другое, оно тебе больше надо" - ну смешно же...

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


  1. IVNSTN
    20.03.2026 17:13

    ctrl+enter почему-то не срабатывает, поэтому отправлю так

    У пользователя есть 20 заказов, 20 платежей и 10 отзывов. Рузельтат 20 × 20 × 10 = 4000 строк.

    Проблематика понятная, но пример выбран надуманный, а предлагаемое решение по сути представляет собой решение какой-то другой задачи - просто написан совершенно иной запрос.

    Если связь между таблицами разумная и, как показано ниже в решении, orders 1:M payments, то и при обсуждении исходной ситуации умножать 20 платежей на 20 заказов некорректно. В такой схеме и при таком запросе, если платежей 20, то и в результате будет всего 20 строк.

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

    Ну и вначале мы почему-то хотели получить все строки, какие найдутся, страдали от "4000 строк", а потом нас внезапно устроил агрегат по строке на заказ - это просто неудачно построенный пример, где для поиска способа устранить конкретную проблему совершается ход конём в полностью иное решение, под другую гипотетическую установку. Не надо так. К слову, если изначально платежей было 20, потому что по одному на заказ, то де-факто по данному конкретному пользователю число строк в итоге не изменится (неудачно выбранные вводные).

    "Рузельтат ", "для лучше оптимизации" - спелчек.


    1. Razor00913 Автор
      20.03.2026 17:13

      Согласен с комментарием. Спасибо большое! В следующий раз буду внимательнее!


  1. voidstrx
    20.03.2026 17:13

    Create index без concurrently

    Analyze после создания индекса запускался?

    Какие параметры СУБД использовать для столь мощного железа?


  1. stvoid
    20.03.2026 17:13

    Вы можете использовать offset хоть на несколько миллионов строк, если вам не нужно поддерживать огромное кол-во сортировок.

    SELECT *
    FROM orders
    WHERE user_id IN (
        SELECT user_id
        FROM orders
        WHERE %(any_conditions_for_filters)
        ORDER BY created_at DESC
        OFFSET 120
        LIMIT 20;
    )
    ORDER BY created_at DESC
    

    Мы подразумеваем что user_id - это первичный ключ, либо просто имеет индекс.
    В этом случае OFFSET будет почти незаметен. Вы отфильтровываете уже нужные id, которые хранятся в индексной таблице и не заставляете БД вычитывать все строки данных с диска для OFFSET


    1. Djaler
      20.03.2026 17:13

      Ерунду написали. Дело же не в вычитке лишних колонок, а в том, что нужно сначала найти подходящие под условие строки, а затем просто их выкинуть для offset.

      То, что реально работает - это keyset пагинация, про нее уже миллион раз писали, это не новость


      1. stvoid
        20.03.2026 17:13

        Очевидно комментарий человека который не разбирается в теме, но без пруфов пишет что то "ерунда".
        Лучше бы предложили свой вариант с вашей реализацией. Пруфы что это ерунда тоже не помешали бы - обоснования в студию с метриками.


        1. Akina
          20.03.2026 17:13

          В упор не понимаю, по какому поводу срач. Во всей статье, от макушки и до пяток, литерал OFFSET встречается ровно ОДИН раз. В "личных правилах автора". И более во всей статье нет вообще ничего про OFFSET.

          То есть это "личное правило" автором просто у кого-то списано. При этом списано оно вообще без понимания того, зачем и почему оно, это правило, существует. И когда оно применяется, а когда неуместно.


  1. SunchessD
    20.03.2026 17:13

    Спасибо, Кэп!


  1. isergirud
    20.03.2026 17:13

    Если вы ещё для себя откроете составные индексы - вы откроете Америку! И обязательно напишите об этом статью! Поржем хоть ещё раз )


  1. DenisTrunin
    20.03.2026 17:13

    А нельзя было поставить тулзку какую-нибудь чтобы она это все нашла автоматом? Вроде примеры все тривиальные, в век AI все такое должно решаться, гугл сходу находит pganalyze, правда платный