Достаточно большое количество проблем производительности в 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
Индекс должен соответствовать запросу
Не использовать OFFSET на огромных таблицах
Избегать " SELECT *", лучше выбирать только нужные поля
Всегда использовать " EXPLAIN ANALYZE" для лучше оптимизации
Комментарии (20)

MishaBucha
20.03.2026 17:13Насчет offset очень точно, реальная проблема на больших данных
keyset pagination спасает и супер прибавляет производительности)

Tzimie
20.03.2026 17:13WHEREuser_id = 54821 и помог индекс по user_id? Да не может быть!
Razor00913 Автор
20.03.2026 17:13К сожалению из-за кривый баз данных приходится страдать такими вещами. А влезать в БД и исправлять, я не могу(

Akina
20.03.2026 17:13Кейс 3. JOIN, который создавал бомбежку всех строк
Что-то вы тут напахали. Исходный и конечный запросы ну даже близко не стояли...
Индекс должен соответствовать запросу
Совет в таком виде - безусловно вредный. Он звучит как предложение на каждый запрос создавать оптимальный для него индекс. Зарастёте индексами! Продумайте получше формулировку.

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

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

IVNSTN
20.03.2026 17:13ctrl+enter почему-то не срабатывает, поэтому отправлю так
У пользователя есть 20 заказов, 20 платежей и 10 отзывов. Рузельтат 20 × 20 × 10 = 4000 строк.
Проблематика понятная, но пример выбран надуманный, а предлагаемое решение по сути представляет собой решение какой-то другой задачи - просто написан совершенно иной запрос.
Если связь между таблицами разумная и, как показано ниже в решении, orders 1:M payments, то и при обсуждении исходной ситуации умножать 20 платежей на 20 заказов некорректно. В такой схеме и при таком запросе, если платежей 20, то и в результате будет всего 20 строк.
Джойн на отзывы действительно исполнен криво и было бы здорово, если бы в целом пример был додуман до осмысленного и в предлагаемом решении такое соединение бы тоже присутствовало, но с устранением проблемы. А про этот джойн в конце почему-то совсем забыли - и стало хорошо! Как говорится, я таких примеров много могу придумать. А обещали делиться реальным опытом...
Ну и вначале мы почему-то хотели получить все строки, какие найдутся, страдали от "4000 строк", а потом нас внезапно устроил агрегат по строке на заказ - это просто неудачно построенный пример, где для поиска способа устранить конкретную проблему совершается ход конём в полностью иное решение, под другую гипотетическую установку. Не надо так. К слову, если изначально платежей было 20, потому что по одному на заказ, то де-факто по данному конкретному пользователю число строк в итоге не изменится (неудачно выбранные вводные).
"Рузельтат ", "для лучше оптимизации" - спелчек.

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

voidstrx
20.03.2026 17:13Create index без concurrently
Analyze после создания индекса запускался?
Какие параметры СУБД использовать для столь мощного железа?

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
Djaler
20.03.2026 17:13Ерунду написали. Дело же не в вычитке лишних колонок, а в том, что нужно сначала найти подходящие под условие строки, а затем просто их выкинуть для offset.
То, что реально работает - это keyset пагинация, про нее уже миллион раз писали, это не новость

stvoid
20.03.2026 17:13Очевидно комментарий человека который не разбирается в теме, но без пруфов пишет что то "ерунда".
Лучше бы предложили свой вариант с вашей реализацией. Пруфы что это ерунда тоже не помешали бы - обоснования в студию с метриками.
Akina
20.03.2026 17:13В упор не понимаю, по какому поводу срач. Во всей статье, от макушки и до пяток, литерал OFFSET встречается ровно ОДИН раз. В "личных правилах автора". И более во всей статье нет вообще ничего про OFFSET.
То есть это "личное правило" автором просто у кого-то списано. При этом списано оно вообще без понимания того, зачем и почему оно, это правило, существует. И когда оно применяется, а когда неуместно.

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

DenisTrunin
20.03.2026 17:13А нельзя было поставить тулзку какую-нибудь чтобы она это все нашла автоматом? Вроде примеры все тривиальные, в век AI все такое должно решаться, гугл сходу находит
pganalyze, правда платный
LokkiDog
Казалось бы «база», но всегда полезно о ней напомнить.
А может есть решение как лучше оптимизировать запросы через ORM Django?
Razor00913 Автор
Спасибо большое за отзыв.
Есть коллега, который занимается чем то похожим, если он поделится, то я напишу об этом.