Поделиться
Поделиться

PostGIS — расширение PostgreSQL, которое превращает реляционную базу данных в полноценную геопространственную систему. Если ваше приложение работает с координатами, геозонами или пространственным поиском — PostGIS нужно знать. В этой статье разберём типы данных, ключевые функции, индексирование и интеграцию с Node.js и Python.

Установка и включение

-- В PostgreSQL после установки расширения (apt/brew/docker)
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;

-- Проверка версии
SELECT PostGIS_Version();
-- 3.4.0 USE_GEOS=1 USE_PROJ=1 USE_STATS=1

Для Docker рекомендуем образ postgis/postgis:16-3.4.

geometry vs geography

Это самый важный выбор при проектировании схемы.

| Параметр | geometry | geography | |---|---|---| | Система координат | Плоская (проекция) | Сфера (WGS84) | | Единицы по умолчанию | Единицы проекции (градусы для SRID 4326) | Метры | | Точность на больших расстояниях | Ошибка растёт с расстоянием | Точная | | Производительность | Быстрее | Медленнее (~10-20%) | | Поддержка функций | Полная | Подмножество | | Когда использовать | Локальные данные (город, регион), карты в проекции | Глобальные данные, расчёты расстояний в метрах |

Практическое правило:

  • Храните координаты как geometry(Point, 4326) (SRID 4326 = WGS84 — это широта/долгота GPS).
  • Для расчётов расстояний кастуйте в geography прямо в запросе через ::geography.
-- Создание таблицы с пространственной колонкой
CREATE TABLE places (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,
    category    TEXT,
    location    geometry(Point, 4326) NOT NULL,
    created_at  TIMESTAMPTZ DEFAULT now()
);

-- Вставка точки (долгота, широта — обратите внимание на порядок!)
INSERT INTO places (name, category, location) VALUES
    ('Красная площадь', 'landmark',
     ST_SetSRID(ST_MakePoint(37.6173, 55.7558), 4326)),
    ('Эрмитаж', 'museum',
     ST_SetSRID(ST_MakePoint(30.3141, 59.9400), 4326));

Важно: PostGIS использует порядок (longitude, latitude) = (X, Y), а не привычный (lat, lng). Частая причина ошибок.

Ключевые функции

ST_DWithin — поиск в радиусе

Самый частый запрос в геоприложениях:

-- Найти все кафе в радиусе 1 км от точки
-- geography::geography обеспечивает расчёт в метрах
SELECT
    name,
    category,
    ST_Distance(
        location::geography,
        ST_SetSRID(ST_MakePoint(37.6173, 55.7558), 4326)::geography
    ) AS distance_m
FROM places
WHERE
    category = 'cafe'
    AND ST_DWithin(
        location::geography,
        ST_SetSRID(ST_MakePoint(37.6173, 55.7558), 4326)::geography,
        1000  -- метры (работает только с geography)
    )
ORDER BY distance_m
LIMIT 20;

ST_Contains и ST_Within — точка в полигоне

-- Хранение геозон (полигонов)
CREATE TABLE zones (
    id       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name     TEXT NOT NULL,
    type     TEXT,  -- 'delivery', 'event', 'restricted'
    boundary geometry(Polygon, 4326) NOT NULL
);

-- Создание полигона из координат
INSERT INTO zones (name, type, boundary) VALUES (
    'Зона доставки центр', 'delivery',
    ST_SetSRID(
        ST_MakePolygon(ST_GeomFromText(
            'LINESTRING(37.60 55.76, 37.65 55.76, 37.65 55.73, 37.60 55.73, 37.60 55.76)'
        )),
        4326
    )
);

-- Проверка: в какой зоне находится пользователь?
SELECT z.name, z.type
FROM zones z
WHERE ST_Contains(z.boundary,
    ST_SetSRID(ST_MakePoint($1, $2), 4326)
);
-- ST_Within(point, polygon) эквивалентно ST_Contains(polygon, point)

ST_Intersection и ST_Overlaps — пересечение геометрий

-- Площадь пересечения двух зон доставки (в кв. метрах)
SELECT
    a.name AS zone_a,
    b.name AS zone_b,
    ST_Area(ST_Intersection(a.boundary, b.boundary)::geography) AS intersection_m2
FROM zones a
JOIN zones b ON a.id < b.id
WHERE ST_Overlaps(a.boundary, b.boundary);

Работа с треками и линиями

-- Хранение маршрутов
CREATE TABLE routes (
    id    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name  TEXT,
    path  geometry(LineString, 4326)
);

-- Длина маршрута в метрах
SELECT name, ST_Length(path::geography) AS length_m
FROM routes;

-- Ближайшая точка на маршруте к пользователю
SELECT
    name,
    ST_AsText(ST_ClosestPoint(path,
        ST_SetSRID(ST_MakePoint($1, $2), 4326))) AS closest_point,
    ST_Distance(path::geography,
        ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) AS distance_m
FROM routes
ORDER BY distance_m
LIMIT 1;

GiST-индекс: как он работает

Обычный B-tree индекс не работает для пространственных данных — он умеет сравнивать только по одному измерению. GiST (Generalized Search Tree) использует R-tree структуру с ограничивающими прямоугольниками (MBR — Minimum Bounding Rectangle).

-- Создание пространственного индекса
CREATE INDEX places_location_gist ON places USING GIST (location);

-- Для geography можно индексировать напрямую
CREATE INDEX zones_boundary_gist ON zones USING GIST (boundary);

-- Проверка использования индекса
EXPLAIN ANALYZE
SELECT * FROM places
WHERE ST_DWithin(location::geography,
    ST_SetSRID(ST_MakePoint(37.6173, 55.7558), 4326)::geography, 1000);

-- В плане должно быть: Index Scan using places_location_gist

Принцип работы R-tree

Индекс строит иерархию ограничивающих прямоугольников:

  1. Каждая геометрия оборачивается в MBR.
  2. MBR объединяются в группы, каждая группа — тоже MBR.
  3. При запросе PostgreSQL проверяет пересечение с MBR на каждом уровне и отсекает ветки дерева, которые не могут содержать результаты.

Это объясняет, почему функции ST_DWithin, ST_Contains, ST_Overlaps используют индекс, а ST_Distance > N — нет (для последнего нужно переписать как NOT ST_DWithin).

Партиционирование по регионам

При большом объёме геоданных (десятки миллионов объектов) партиционирование по регионам улучшает производительность запросов:

-- Родительская таблица
CREATE TABLE points_of_interest (
    id       UUID NOT NULL DEFAULT gen_random_uuid(),
    name     TEXT NOT NULL,
    category TEXT,
    location geometry(Point, 4326) NOT NULL,
    region   TEXT NOT NULL  -- 'europe', 'asia', 'americas'
) PARTITION BY LIST (region);

-- Партиции для регионов
CREATE TABLE poi_europe   PARTITION OF points_of_interest FOR VALUES IN ('europe');
CREATE TABLE poi_asia     PARTITION OF points_of_interest FOR VALUES IN ('asia');
CREATE TABLE poi_americas PARTITION OF points_of_interest FOR VALUES IN ('americas');

-- Индекс создаётся на каждой партиции
CREATE INDEX ON poi_europe   USING GIST (location);
CREATE INDEX ON poi_asia     USING GIST (location);
CREATE INDEX ON poi_americas USING GIST (location);

-- Запросы с фильтром по region автоматически используют только нужную партицию
SELECT * FROM points_of_interest
WHERE region = 'europe'
  AND ST_DWithin(location::geography,
    ST_SetSRID(ST_MakePoint(37.6173, 55.7558), 4326)::geography, 5000);

Для более гранулярного партиционирования можно использовать геохеш (H3 или Geohash) как ключ партиции.

Интеграция с Node.js

const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

// Поиск ближайших объектов
async function findNearby(lng, lat, radiusMeters, category, limit = 20) {
  const query = `
    SELECT
      id,
      name,
      category,
      ST_AsGeoJSON(location)::json AS geojson,
      ST_Distance(
        location::geography,
        ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography
      )::int AS distance_m
    FROM places
    WHERE
      ($3::text IS NULL OR category = $3)
      AND ST_DWithin(
        location::geography,
        ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
        $4
      )
    ORDER BY distance_m
    LIMIT $5
  `;

  const { rows } = await pool.query(query, [lng, lat, category, radiusMeters, limit]);
  return rows;
}

// Создание объекта (с конвертацией GeoJSON → PostGIS)
async function createPlace(name, category, lng, lat) {
  const { rows } = await pool.query(
    `INSERT INTO places (name, category, location)
     VALUES ($1, $2, ST_SetSRID(ST_MakePoint($3, $4), 4326))
     RETURNING id, name, category,
       ST_AsGeoJSON(location)::json AS geojson`,
    [name, category, lng, lat]
  );
  return rows[0];
}

// Экспорт в GeoJSON FeatureCollection
async function exportZonesAsGeoJSON() {
  const { rows } = await pool.query(`
    SELECT json_build_object(
      'type', 'FeatureCollection',
      'features', json_agg(
        json_build_object(
          'type', 'Feature',
          'geometry', ST_AsGeoJSON(boundary)::json,
          'properties', json_build_object('id', id, 'name', name, 'type', type)
        )
      )
    ) AS geojson
    FROM zones
  `);
  return rows[0].geojson;
}

Интеграция с Python (SQLAlchemy + GeoAlchemy2)

from geoalchemy2 import Geometry
from geoalchemy2.functions import ST_DWithin, ST_Distance, ST_MakePoint, ST_SetSRID
from sqlalchemy import Column, String, func
from sqlalchemy.dialects.postgresql import UUID
import uuid

class Place(Base):
    __tablename__ = 'places'
    id       = Column(UUID, primary_key=True, default=uuid.uuid4)
    name     = Column(String, nullable=False)
    category = Column(String)
    location = Column(Geometry(geometry_type='POINT', srid=4326), nullable=False)

# Поиск ближайших
def find_nearby(session, lng: float, lat: float, radius_m: float):
    user_point = func.ST_SetSRID(func.ST_MakePoint(lng, lat), 4326)
    
    return (
        session.query(
            Place,
            func.ST_Distance(
                Place.location.cast(Geometry(srid=4326)),
                func.cast(user_point, 'geography')
            ).label('distance_m')
        )
        .filter(
            func.ST_DWithin(
                Place.location.cast('geography'),
                func.cast(user_point, 'geography'),
                radius_m
            )
        )
        .order_by('distance_m')
        .limit(20)
        .all()
    )

# Используется в location-based приложениях
# Подробнее: /blog/location-based-igry-i-geoprilozhenia

Советы по производительности

| Проблема | Решение | |---|---| | Индекс не используется | Убедитесь, что функция использует операторы &&, ~=, @> (они триггерят индекс). ST_DWithin использует индекс, ST_Distance в WHERE — нет | | Медленный кастинг geography | Хранить дублирующую колонку geography или материализованное представление | | VACUUM не поспевает | Для часто обновляемых таблиц уменьшить autovacuum_vacuum_scale_factor | | Большой ST_Union медленный | Использовать ST_Collect (не объединяет геометрии, просто группирует) | | Cluster scan вместо index scan | CLUSTER таблицу по GiST-индексу: CLUSTER places USING places_location_gist |

Итог

PostGIS — мощный инструмент, который закрывает большинство задач геопространственной обработки данных на уровне базы данных. Выбор между geometry и geography, правильное создание GiST-индексов и умение писать запросы с ST_DWithin вместо вычисления расстояния в WHERE — это основа производительного геоприложения. Для практического применения в реальном проекте смотрите статью Location-based приложения и геоигры.