Началось всё с того, что эндпоинт /pois/?slim=true&limit=3000 отдавался за 380–510 мс и тянул за собой 811 КБ. На мобильной сети это несколько секунд между запуском карты и тем моментом, когда на ней появляется первая точка. Я был уверен, что причина одна – какой-то тяжёлый запрос. Я разобрался – причин было три, и они лежали одна под другой, то есть пока не убираешь верхнюю, следующую просто не видно.
Зачем вообще этот эндпоинт
Речь идёт про бэкенд travel-планировщика: FastAPI, MongoDB через Beanie ODM, клиент – Flutter. Экран карты это первое, что видит пользователь после выбора города, и именно на нём висит /pois/?slim=true: облегчённый список точек интереса для отрисовки маркеров, без полной информации о каждой точке – она подгружается отдельно, только когда пользователь тапает по конкретному месту.
Коллекция pois на проде – 9281 документ. Для одного города запрос отдаёт до 3000 записей разом, потому что маркеры на карте должны появиться все и сразу, а не подгружаться по мере скролла – это карта, а не лента.
Замерил, вместо того чтобы гадать
Первый порыв – переписать запрос покрасивее и понадеяться, что стало быстрее. Вместо этого включил профилирование и Mongo explain() и посмотрел, что реально происходит на реальных данных, а не на локальной базе из десяти тестовых записей, где любой запрос быстрый по определению.
Первый замер: 380–510 мс на весь запрос, разброс из-за разной загрузки прода в момент теста. 811 КБ полезной нагрузки – само по себе не удивительно для 3000 объектов, но тревожный звонок, раз речь о мобильной сети.
Индексов не было ни одного
explain() в MongoDB показывает план выполнения запроса: какие индексы использовались и сколько документов физически просмотрено против того, сколько реально попало в ответ. В выводе стоял COLLSCAN – collection scan, то есть Mongo буквально перебирал все 9281 документа по очереди, сравнивал с условием и сортировал результат в памяти, потому что сортировать было не по чему – индекса на поле rating не существовало.
У коллекции на тот момент были только два индекса: _id по умолчанию и city, добавленный мною давно просто чтобы фильтр по городу не был совсем безумным. Ни сортировки, ни категории, ни статуса открытости заведения в индексах не было вообще.
Поэтому я добавил четыре индекса под реальные паттерны запросов приложения, а не «на всякий случай»:
db.pois.createIndex({ category: 1 }) db.pois.createIndex({ rating: -1 }) db.pois.createIndex({ category: 1, rating: -1 }) db.pois.createIndex({ is_open: 1, rating: -1 })
Два составных индекса – не дублирование одиночных. {category: 1, rating: -1} покрывает запросы «точки категории X, отсортированные по рейтингу» одним проходом, без отдельной операции сортировки после фильтрации. {is_open: 1, rating: -1} – под отдельный эндпоинт /pois/featured, где важны только открытые сейчас заведения. Порядок полей в составном индексе не случаен: MongoDB эффективно использует префикс индекса для равенства, но не для диапазона или сортировки, если поле неравенства идёт раньше поля точного совпадения – поэтому в обоих индексах поле фильтрации стоит первым, поле сортировки вторым.
После пересборки индексов explain() показал переход на IXSCAN вместо COLLSCAN: 18 мс на выполнение самого запроса к базе, nReturned совпадает с totalDocsExamined – Mongo читает ровно те документы, которые нужны, ни одного лишнего.
На эндпоинте всё ещё было не 18 мс
Можно было закрыть тикет и уйти пить чай. Но повторный замер полного времени ответа API почти не сдвинулся с места. Значит, проблема была не в базе – она уже отвечала за 18 мс, а разница между 18 мс и сотнями миллисекунд пряталась где-то между вызовом к Mongo и отправкой JSON клиенту.
Полез профилировать код построчно и нашёл: Beanie, ODM поверх MongoDB и Pydantic, при каждом запросе гидрирует – то есть превращает сырые BSON-документы из базы в полноценные Python-объекты со всей валидацией типов, вложенными моделями и проверками – все 3000 записей целиком, по всем полям модели PointOfInterest. Это стоило порядка 270 мс само по себе. А функция _to_slim_response, которая формировала итоговый ответ, сразу после этой дорогой гидрации выбрасывала девять полей из десяти – клиенту в списке для карты нужны только координаты, название, рейтинг и категория.
Модель PointOfInterest держит порядка двадцати полей: полное описание, часы работы по дням недели, массив фотографий, координаты входа отдельно от координат здания, связанные отзывы. Гидрировать всё это через Pydantic ради того, чтобы тут же выбросить – дорогая и совершенно бессмысленная работа.
Обошёл ODM для этого конкретного пути
Для slim-варианта убрал Beanie из цепочки для этого конкретного запроса и пошёл через motor (асинхронный низкоуровневый драйвер MongoDB, на котором сам Beanie и построен) напрямую, с проекцией – Mongo сам отдаёт только нужные поля, без лишней гидрации на стороне Python:
cursor = collection.find( {"city": city}, {"name": 1, "lat": 1, "lon": 1, "rating": 1, "category": 1} ).sort("rating", -1).limit(limit) docs = await cursor.to_list(length=limit) return [dict(d) for d in docs]
Полный путь через Beanie-модели остался нетронутым для админки и детальной карточки места – там гидрация оправдана, там реально нужны все поля и вся валидация.
Честно говоря, это не бесплатное решение. Список полей теперь продублирован в двух местах: в самой Pydantic-модели PointOfInterest и в проекции для slim-запроса. Добавишь новое поле в ответ карты – забудешь про проекцию, и оно тихо пропадёт из slim-ответа без единой ошибки в логах, просто не будет данных там, где их ждут. Пометил комментарием в коде рядом с обоими местами, но это ручная дисциплина, не защита компилятором или тестом.
Что рассматривал и отклонил
Прежде чем идти в обход ODM, прикидывал ещё два варианта.
Кэш ответа. Список точек на город меняется редко – раз в день, максимум. Кэшировать slim-ответ в Redis на пару часов было бы проще, чем переписывать путь запроса. Отклонил по одной причине: это лечит симптом, а не причину. Первый запрос после инвалидации кэша всё равно упёрся бы в те же 380 мс, просто реже. А в момент, когда я это чинил, ещё не было понимания, где именно теряется время – кэш замаскировал бы проблему, не решив её, и следующий человек, который полез бы разбираться через полгода, начинал бы с нуля.
Пагинация карты. Отдавать не 3000 точек разом, а порциями по видимой области карты (bounding box). Технически правильнее и в перспективе неизбежно – при росте базы 3000 записей за один ответ не будут масштабироваться бесконечно. Но это отдельная фича с изменением протокола между клиентом и сервером, а не оптимизация существующего пути. Отложил на отдельную задачу: нельзя чинить производительность и переделывать API за один заход, это два разных риска в одном коммите.
В итоге индексы и обход гидрации остались единственным изменением, которое решало проблему на месте, без изменения контракта API и без нового слоя инфраструктуры.
Payload всё ещё был тяжёлым
База – 18 мс, сериализация – без лишней гидрации, а по сети всё ещё уходило 811 КБ голого JSON на 3000 объектов. Проверил конфиг nginx перед проксированием на бэкенд и не поверил: gzip не был включён вообще. Ни разу, ни на одном пути, ни для одного content-type.
gzip on; gzip_min_length 1024; gzip_comp_level 6; gzip_types application/json text/plain text/css;
gzip_min_length 1024 – не сжимать совсем маленькие ответы, там оверхед на сжатие не окупается. gzip_comp_level 6 – компромисс между степенью сжатия и нагрузкой на CPU nginx; уровень 9 жмёт чуть плотнее, но заметно медленнее, а разница на JSON-выдаче такого объёма не стоит того.
Проверил результат сразу через curl -H "Accept-Encoding: gzip" -I <url> – в ответе появился заголовок Content-Encoding: gzip, а размер тела упал с 811 КБ до 110 – в 7.3 раза меньше на том же самом JSON, без единой строчки изменений в коде бэкенда.
Результаты
Этап | Время ответа | Размер ответа |
|---|---|---|
Было | 380–510 мс | 811 КБ |
После индексов | ~360 мс | 811 КБ |
После обхода ODM | ~150 мс | 811 КБ |
После gzip | 150–200 мс | 110 КБ |
Три причины, и каждую следующую было видно только после того, как убрал предыдущую. Индексы не показали бы проблему с ODM – на фоне 380 мс просадка на гидрации была не так заметна, пока сама база не перестала быть узким местом. А ODM не показал бы проблему с gzip: пока ответ формировался за сотни миллисекунд, лишние доли секунды на сжатие терялись в общем шуме и не были поводом лезть в конфиг nginx.
Что осталось за кадром
Slim-путь с ручной проекцией – рабочее, но не элегантное решение. Правильнее было бы держать отдельную Pydantic-модель специально под slim-ответ и валидировать через неё, а не полагаться на комментарий в коде как единственную защиту от рассинхрона полей. Пока не сделал – эндпоинт меняется редко, но если начнёт меняться чаще, это первое, что перепишу.
Документация, на которую опирался по пути: explain() в MongoDB, Beanie ODM, gzip-модуль nginx.

