Ускорил 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-запрос.
Получилось примерно следующее:
И вот здесь стало интересно.
База данных должна была найти заказы пользователя, отсортировать их по created_at, а затем вернуть только первые 20.
Но индекса на user_id и created_at в подходящей комбинации не было.
Смотрим план выполнения
Я запустил:
Для небольшой таблицы это совершенно нормально.
Но когда таблица содержит миллионы строк, последовательный просмотр становится дорогим.
Базе приходится делать слишком много работы ради 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 без переписывания архитектуры приложения.
Для меня этот случай стал хорошим напоминанием о простой вещи: производительность — это не соревнование по количеству оптимизаций. Это прежде всего умение правильно измерять и находить узкое место.