Ускорил API в 7 раз, оптимизация PostgreSQL-запросов

Я долгое время считал, что если API работает за 300–400 мс, то это вполне нормально. Пользователь нажал кнопку — подождал меньше секунды — получил ответ. На этапе разработки это действительно казалось приемлемым.

Проблемы начались после того, как количество пользователей выросло.

В один момент один из самых популярных endpoint'ов начал отвечать уже за 2–3 секунды, а при высокой нагрузке время ответа доходило до 5–6 секунд. При этом приложение практически не изменилось — просто стало больше данных.

В этой статье расскажу, как я искал причину проблемы, почему первым делом ошибочно пытался оптимизировать Python-код и как в итоге один индекс в PostgreSQL ускорил запрос примерно в 7 раз.

С чего всё началось

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

  • Python;
  • FastAPI;
  • PostgreSQL;
  • SQLAlchemy;
  • Redis для кэширования некоторых данных.

Проблемный endpoint возвращал последние заказы пользователя:

GET /api/users/123/orders

На тестовой базе запрос выполнялся практически мгновенно.

Но в production ситуация была другой.

Таблица orders постепенно выросла примерно до нескольких миллионов записей.

Сам endpoint выглядел примерно так:

@router.get("/users/{user_id}/orders")def get_orders(user_id: int, db: Session): orders = ( db.query(Order) .filter(Order.user_id == user_id) .order_by(Order.created_at.desc()) .limit(20) .all() ) return orders

На первый взгляд здесь не было ничего страшного.

Мы фильтруем по user_id, сортируем по дате и берём 20 последних записей.

Я сначала подумал, что проблема находится в Python.

Первая ошибка — оптимизировать не там

Моя первая идея была уменьшить количество операций внутри приложения.

Я начал смотреть на сериализацию объектов, Pydantic-модели, количество запросов к базе и даже попробовал заменить некоторые конструкции SQLAlchemy.

Однако это практически ничего не изменило.

Тогда я решил посмотреть на сам SQL-запрос.

Получилось примерно следующее:

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

И вот здесь стало интересно.

База данных должна была найти заказы пользователя, отсортировать их по created_at, а затем вернуть только первые 20.

Но индекса на user_id и created_at в подходящей комбинации не было.

Смотрим план выполнения

Я запустил:

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

Для небольшой таблицы это совершенно нормально.

Но когда таблица содержит миллионы строк, последовательный просмотр становится дорогим.

Базе приходится делать слишком много работы ради 20 строк.

Решение

Я создал составной индекс:

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

На production при высокой нагрузке эффект оказался ещё заметнее.

В итоге endpoint стал работать примерно в 7 раз быстрее.

При этом код приложения практически не изменился.

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

Почему индекс настолько помог?

Можно представить таблицу заказов как огромную стопку документов.

Без индекса база фактически говорит:

«Мне нужны заказы пользователя 123. Сейчас просмотрю документы и найду подходящие».

А затем:

«Теперь среди найденных заказов нужно определить самые новые».

Индекс меняет подход.

У базы появляется структура примерно такого вида:

user_id created_at ------------------------------ 123 2026-09-05 123 2026-09-04 123 2026-09-01 123 2026-08-29 ... 124 2026-09-05 124 2026-09-03 ...

Поэтому PostgreSQL может быстро перейти к нужному пользователю и взять первые 20 записей уже в необходимом порядке.

Но есть важный нюанс

Составной индекс нельзя добавлять бездумно.

Например, индекс:

(user_id, created_at)

отлично подходит для запросов, где мы сначала ограничиваем данные по user_id.

Но он не обязательно будет эффективен для запроса:

SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;

Здесь первым полем индекса является user_id, а запрос его вообще не использует.

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

Ещё одна оптимизация

После решения основной проблемы я заметил ещё один момент.

Мы использовали:

SELECT *

То есть возвращали все поля заказа.

Но API на самом деле использовал только:

  • id;
  • status;
  • created_at;
  • total_price.

Поэтому запрос можно было сделать более конкретным:

SELECT id, status, created_at, total_price FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20;

А в SQLAlchemy:

orders = ( db.query( Order.id, Order.status, Order.created_at, Order.total_price, ) .filter(Order.user_id == user_id) .order_by(Order.created_at.desc()) .limit(20) .all())

Это дополнительно уменьшило объём данных, который база должна была прочитать и передать приложению.

Что я вынес из этого кейса

Главный вывод оказался довольно простым:

не стоит оптимизировать код, пока ты не знаешь, где именно находится bottleneck.

В моём случае я мог несколько часов переписывать Python-код, менять ORM и экспериментировать с сериализацией.

Но проблема находилась в одном SQL-запросе.

Сейчас перед оптимизацией производительности я стараюсь придерживаться примерно такого алгоритма:

1. Найти медленный участок ↓ 2. Измерить его время ↓ 3. Найти bottleneck ↓ 4. Посмотреть фактический план выполнения ↓ 5. Внести минимальное изменение ↓ 6. Повторно измерить результат

И только после этого переходить к следующему уровню оптимизации.

Итог

В результате небольшого изменения:

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

мы получили серьёзное ускорение API без переписывания архитектуры приложения.

Для меня этот случай стал хорошим напоминанием о простой вещи: производительность — это не соревнование по количеству оптимизаций. Это прежде всего умение правильно измерять и находить узкое место.