Трендовые github проекты в нашем телеграм канале. Подпишись → Геосервис на PostgreSQL без сюрпризов в проде
Карты с пользовательскими отметками кажутся простой задачей: есть объект, координаты, статус и несколько фильтров. Но как только проект выходит за пределы демо, геоданные начинают вести себя иначе, чем обычные строки в таблице. Пользователи двигают карту, фронтенд отправляет много запросов на видимую область, записи обновляются почти в реальном времени, а база внезапно получает не один аккуратный lookup по id, а поток пространственных пересечений.
PostGIS отлично подходит для таких задач, но он не отменяет инженерную дисциплину. Если хранить геометки без понятной модели, строить фильтры поверх тяжёлых функций и забыть про индексы, даже небольшой сервис может уткнуться в CPU, долгие блокировки или слишком дорогие планы выполнения. Ниже — практический чеклист для homelab- и production-проектов, где карта является не украшением, а основным интерфейсом.
Сначала модель, потом карта
Главная ошибка — начинать с визуализации. На экране нужны маркеры, поэтому в таблицу быстро добавляют lat, lon, пару текстовых полей и начинают фильтровать всё подряд. Такой подход работает до первой нагрузки, но плохо масштабируется.
Лучше сразу разделить данные на несколько уровней:
- сырые пользовательские сообщения или события;
- нормализованные объекты, которые реально нужно показывать;
- агрегаты для карты на разных масштабах;
- служебные статусы: актуальность, доверие, время последнего обновления;
- историю изменений, если важна модерация или аудит.
Для PostGIS обычно удобнее хранить точку в колонке geometry(Point, 4326) или geography(Point, 4326), а не держать широту и долготу как независимые числа. geometry чаще выбирают для быстрых операций на карте и bounding box-запросов, geography — когда критична точность расстояний на поверхности Земли. В большинстве веб-карт первым выбором будет geometry плюс явное понимание, где допустима приближённая геометрия.
Индекс — обязательная часть схемы
Геозапрос без пространственного индекса почти гарантированно станет проблемой. Для PostGIS типичный выбор — GiST:
CREATE INDEX places_geom_gix ON places USING GIST (geom);
Но наличие индекса само по себе не спасает. Запрос должен быть написан так, чтобы планировщик мог им воспользоваться. Для выборки объектов в видимой области карты лучше использовать оператор пересечения с envelope, а не вычислять расстояние до каждой точки:
SELECT id, title, status, updated_at, geom
FROM places
WHERE geom && ST_MakeEnvelope(:min_lng, :min_lat, :max_lng, :max_lat, 4326)
AND status = 'active'
ORDER BY updated_at DESC
LIMIT 1000;
Оператор && работает с bounding box и хорошо ложится на GiST-индекс. Если нужно точное уточнение для сложных геометрий, его можно добавить вторым условием через ST_Intersects, но сначала стоит отфильтровать кандидатов дешёвым способом.
Отдельно проверьте обычные фильтры. Если почти каждый запрос добавляет status, city_id или updated_at, нужны дополнительные индексы или пересмотр модели. Часто полезен частичный индекс только по активным объектам:
CREATE INDEX places_active_geom_gix
ON places USING GIST (geom)
WHERE status = 'active';
Ограничивайте результат на уровне API
Карта провоцирует тяжёлые запросы. Пользователь может открыть всю страну одним движением, а фронтенд честно попросит все точки в огромном прямоугольнике. Если API возвращает десятки тысяч объектов, проблема уже не только в базе: растут JSON, сетевой трафик, память браузера и время отрисовки.
Поэтому у endpoint-а должны быть явные правила:
- максимальная площадь bounding box;
- обязательный
LIMIT; - разные ответы для разных zoom level;
- отказ от выдачи точечных объектов на слишком мелком масштабе;
- серверная агрегация вместо попытки нарисовать всё.
На низком zoom полезнее отдавать кластеры или heatmap-агрегаты. На высоком — конкретные объекты. Это не только быстрее, но и честнее с точки зрения UX: пользователь всё равно не сможет осмысленно разобрать 50 тысяч маркеров на экране.
Агрегации лучше готовить заранее
Если данные обновляются часто, возникает соблазн считать всё на лету: группы по тайлам, количество объектов, статусы, свежесть. На небольшом датасете это удобно, но при росте нагрузки такие запросы быстро становятся горячей точкой.
Практичный вариант — хранить предрасчитанные агрегаты для популярных масштабов или тайлов. Например, можно завести таблицу с ключом тайла, zoom level и счётчиками по статусам. Обновлять её можно фоновым воркером, очередью событий или периодическим job-ом. Да, данные будут слегка отставать, зато карта останется отзывчивой.
Для проектов с real-time ожиданиями стоит разделить два потока: свежие изменения показывать отдельным лёгким слоем, а основную массу объектов брать из агрегатов. Так пользователь видит актуальность, но база не пересчитывает весь мир при каждом pan/zoom.
Защита от пользовательского шторма
Геосервис легко перегрузить без злого умысла. Достаточно, чтобы фронтенд отправлял запрос при каждом пикселе движения карты. Поэтому производительность начинается не в SQL, а в контракте клиента и сервера.
Минимальный набор защиты:
- debounce на клиенте при перемещении карты;
- отмена устаревших HTTP-запросов;
- кеширование одинаковых bbox-запросов на короткое время;
- rate limit на пользователя или IP;
- таймауты запросов к базе;
- отдельный read pool для картографических endpoint-ов.
В PostgreSQL полезно выставить statement_timeout для роли приложения. Лучше вернуть пользователю аккуратную ошибку или упрощённый ответ, чем позволить одному запросу держать ресурсы слишком долго.
Наблюдаемость: EXPLAIN должен быть привычкой
PostGIS-запросы нужно смотреть через EXPLAIN (ANALYZE, BUFFERS), особенно после добавления новых фильтров. Важно проверять не только общее время, но и количество прочитанных строк, использование индекса, сортировки, временные файлы и расхождение между оценкой планировщика и реальностью.
Если планировщик сильно ошибается, проверьте ANALYZE, статистику по колонкам и распределение данных. Геометки часто распределены неравномерно: в центре города плотность огромная, за его пределами почти пусто. Из-за этого один и тот же SQL может быть быстрым на тестовом bbox и тяжёлым на популярной области.
В метриках приложения отдельно отслеживайте:
- p95/p99 latency для map endpoint-ов;
- число объектов в ответе;
- размер JSON;
- частоту запросов по zoom level;
- таймауты и отмены;
- самые дорогие SQL через
pg_stat_statements.
Без этих данных оптимизация превращается в угадывание.
План восстановления важнее героической оптимизации
Даже аккуратная схема может упасть из-за неудачного релиза: новый фильтр, забытый индекс, массовая загрузка, изменение поведения фронтенда. Поэтому нужен простой план отката.
Перед изменениями в геозапросах стоит иметь:
- миграции индексов с
CONCURRENTLY, если таблица живая; - feature flag для нового endpoint-а;
- возможность временно отключить тяжёлые слои карты;
- лимиты на максимальный bbox и размер ответа;
- read replica или хотя бы отдельный пул соединений для чтения;
- dashboard, где видно деградацию сразу после релиза.
В homelab это может выглядеть проще: Docker Compose, PostgreSQL, Grafana, pg_stat_statements и скрипт для быстрого отключения экспериментального слоя. Смысл тот же: карта не должна иметь возможность положить всю систему одним неудачным запросом.
Итог
PostGIS — мощный инструмент, но для карты с пользовательскими данными он должен быть частью продуманной архитектуры. Пространственный индекс, ограничение bbox, разные ответы по zoom level, предрасчёт агрегатов и наблюдаемость обычно дают больше пользы, чем попытка «дотюнить» один SQL до идеала.
Если проект начинается как небольшой сервис с отметками на карте, заложите эти правила заранее. Тогда рост аудитории, новые фильтры и всплески активности будут инженерной задачей, а не аварией в базе данных.