Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

3000 точек на карте грузились полсекунды. Ускорял не там, где думал

Дата публикации: 21-08-2026 13:41:02

Эндпоинт отдавался за 380–510 мс и 811 КБ. Я думал, что причина одна – тяжёлый запрос. Разобрался: причин было три, и они лежали одна под другой. Пока не убираешь верхнюю, следующую просто не видно. Индексы, обход ORM и внезапно – gzip, который не был включён вообще. Читать далее

Основное содержимое страницы с новостью.

Началось всё с того, что эндпоинт /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.

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1[Перевод] Статистика PostgreSQL: почему запросы выполняются медленно07.7419-08-2026
2Как уронить базу данных06.7217-08-2026
3От 12 часов к 30 минутам: как мы join’им миллиарды товарных движений в ClickHouse010.9310-08-2026
4Стоковый ClickHouse занял 12 ГБ диска при 543 КБ данных: сколько на самом деле ест self-hosted observability-116.5118-08-2026
5Запросы с ANY: когда PostgreSQL дольше планирует, чем выполняет08.810-08-2026
6Apache AGE под нагрузкой: что происходит, когда графы внутри PostgreSQL начинают по-настоящему тестировать011.6724-03-2026
7В погоне за APDEX-ом, или как создать HighLoad на недорогом серверном железе116.3720-05-2026
8Приложение течёт, а утечки нет: как оптимизатор картинок Next.js съел 4-гигабайтный VPS06.9713-08-2026
9The dark side of компрессия в PostgreSQL07.4819-08-2026
10Кэш результатов запросов в Postgres Pro: как ускорить часто выполняющиеся запросы и разгрузить базу07.1515-05-2026

Классификация: Наука. Схожих патентов: 0. Схожих новостей: 10. Тональность: 0. Информативность: 10.75. Источник: habr.com.