PostGIS — расширение PostgreSQL для работы с географическими данными. Стандарт индустрии (используется в OpenStreetMap, Uber, многих gov-системах). Если в продукте есть карты, расстояния, поиск «рядом» — PostGIS закрывает 95% задач без отдельной БД для гео.

Эта статья — практический минимум для разработчика. Правила пронумерованы кодами PG-GIS-NNN.

1. Когда нужен PostGIS

PG-GIS-001 — PostGIS оправдан, когда:

  • Поиск в радиусе («магазины в 5 км»).
  • Расстояние между точками.
  • Нахождение в полигоне («в каком районе адрес?»).
  • Маршруты, kNN («10 ближайших ресторанов»).
  • Геокодирование/обратное (через расширения).

PG-GIS-002 — Для двух колонок lat/lon без операций — PostGIS избыточен

Достаточно numeric(9,6) и numeric(10,6).

2. Установка и базовые типы

PG-GIS-010 — Расширение:

CREATE EXTENSION postgis;

PostGIS добавляет три основных типа:

  • geometry — плоская геометрия. Координаты «как есть», расчёты в евклидовой плоскости.
  • geography — сферическая. Координаты в lat/lon, расчёты учитывают кривизну Земли.
  • raster — растровые данные (карты-картинки). Редко.

PG-GIS-011geography vs geometry:

geographygeometry
Точностьсферическая (точно для Земли)плоская (точно только для small-area)
Скоростьмедленнее (тригонометрия)быстрее
Операцииограниченный наборполный набор
SRIDтолько 4326 (WGS84)любой (UTM, локальные системы)

PG-GIS-012 — Default выбор: geography

Точно для глобальных приложений, проще (не надо думать про SRID и проекции). geometry — когда производительность критична и зона работы локальная (один город, регион — можно перевести в UTM).

3. Хранение точек

PG-GIS-020 — Базовая таблица с точками:

CREATE TABLE shop (
    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name      text NOT NULL,
    location  geography(Point, 4326) NOT NULL
);

-- вставка точки (lon, lat) — порядок важен!
INSERT INTO shop (name, location) VALUES (
    'Магазин на Невском',
    ST_GeogFromText('SRID=4326;POINT(30.3358 59.9343)')
);

-- альтернативный синтаксис:
INSERT INTO shop (name, location) VALUES (
    'Магазин на Невском',
    ST_MakePoint(30.3358, 59.9343)::geography
);

PG-GIS-021 — Порядок координат: POINT(lon lat) — долгота сначала, широта потом

Запутаться легко. Многие туториалы делают наоборот, путаница ведёт к багам.

4. Spatial индекс — обязательно

PG-GIS-030 — GiST-индекс на geography/geometry-колонке:

CREATE INDEX ix_shop_location_gist ON shop USING gist (location);

Без spatial-индекса любой ST_DWithin/ST_Contains идёт seq-scan, на 1M точках — секунды. С индексом — миллисекунды.

PG-GIS-031SPGiST для специфических случаев

(точки в равномерной сетке) — редко.

5. Базовые операции

PG-GIS-040 — Поиск в радиусе — ST_DWithin:

-- магазины в 5 км от точки (Дворцовая площадь)
SELECT id, name,
       ST_Distance(location, ST_GeogFromText('SRID=4326;POINT(30.3158 59.9398)')) AS distance_m
FROM shop
WHERE ST_DWithin(
    location,
    ST_GeogFromText('SRID=4326;POINT(30.3158 59.9398)'),
    5000  -- метры
)
ORDER BY distance_m
LIMIT 50;

PG-GIS-041ST_DWithin использует индекс. ST_Distance без WHERE — нет

Всегда оборачивай в ST_DWithin для фильтрации.

PG-GIS-042 — kNN-поиск (ближайшие N) — оператор <->:

-- 10 ближайших магазинов
SELECT id, name
FROM shop
ORDER BY location <-> ST_GeogFromText('SRID=4326;POINT(30.3158 59.9398)')
LIMIT 10;

<-> использует GiST-индекс — быстро даже на миллионах точек.

PG-GIS-043ST_Distance(a, b) для geography возвращает метры

Для geometry — единицы SRID (часто метры в UTM, градусы в WGS84-как-geometry, что почти не используется).

6. Полигоны и попадание в зону

PG-GIS-050 — Хранение полигонов:

CREATE TABLE district (
    id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name  text NOT NULL,
    boundary  geography(Polygon, 4326) NOT NULL
);

CREATE INDEX ix_district_boundary_gist ON district USING gist (boundary);

PG-GIS-051 — Полигон из координат — обычно из GeoJSON:

INSERT INTO district (name, boundary) VALUES (
    'Центральный',
    ST_GeogFromGeoJSON('{"type":"Polygon","coordinates":[[[30.30,59.93],[30.32,59.93],[30.32,59.95],[30.30,59.95],[30.30,59.93]]]}')
);

PG-GIS-052 — «В каком районе адрес?» — ST_Contains/ST_Intersects:

-- найти district для точки
SELECT name FROM district
WHERE ST_Contains(boundary::geometry, ST_MakePoint(30.3158, 59.9398));
-- ::geometry потому что ST_Contains для geography ограничено

PG-GIS-053 — Различие ST_Contains / ST_Within / ST_Intersects:

  • Contains(A, B) — A полностью содержит B.
  • Within(A, B) — A находится внутри B (зеркально).
  • Intersects(A, B) — A и B имеют общие точки (включая касание).

7. Java/Spring + PostGIS

PG-GIS-060 — jOOQ умеет читать PostGIS-типы через расширение jooq-postgres-extensions

(PostgresExtensions.POINT, etc.).

PG-GIS-061 — Без специальных типов — храни как WKT-строку и парси на стороне Java

(org.locationtech.jts):

public record GeoPoint(double lat, double lon) {
    public String toWkt() { return "SRID=4326;POINT(%f %f)".formatted(lon, lat); }
}

// jOOQ:
ctx.insertInto(SHOP)
   .set(SHOP.NAME, "...")
   .set(SHOP.LOCATION, DSL.field("ST_GeogFromText({0})", String.class, point.toWkt()))
   .execute();

PG-GIS-062 — JTS (Java Topology Suite)

— стандартная библиотека Java для геометрии. PostGIS совместим, можно гонять Geometry-объекты туда-обратно.

8. Производительность

PG-GIS-070 — Spatial-операции тяжелее обычных

Профилируй с EXPLAIN ANALYZE обычным способом.

PG-GIS-071 — На 100K+ точек кешируй частые запросы или используй materialized views

(см. Materialized views).

PG-GIS-072 — Для геопоиска с фильтрами по другим полям — composite GiST с btree_gist extension:

CREATE EXTENSION btree_gist;
CREATE INDEX ix_shop_active_loc ON shop USING gist (is_active, location);

SELECT * FROM shop
WHERE is_active = true
  AND ST_DWithin(location, ?, 5000);

9. Обратная совместимость с lat/lon

PG-GIS-080 — Если уже есть lat numeric, lon numeric колонки:

  • Можно жить без PostGIS, считая через формулу haversine, но индекс не поможет — seq-scan.
  • Лучше — добавить generated column geography:
ALTER TABLE shop ADD COLUMN location geography(Point, 4326)
GENERATED ALWAYS AS (ST_MakePoint(lon, lat)::geography) STORED;

CREATE INDEX ix_shop_location_gist ON shop USING gist (location);

Старый код продолжает читать lat/lon, новый — использовать location для spatial.

10. Антипаттерны

PG-GIS-090ST_Distance без ST_DWithin в WHERE

— seq-scan, медленно.

PG-GIS-091POINT(lat lon) (наоборот)

— distance отрицателен или просто врёт. Помни порядок: (lon lat).

PG-GIS-092 — Хранение lat/lon как varchar

— не сравнить, не индексировать. numeric или geography(Point).

PG-GIS-093 — Расчёт расстояния через haversine в коде Java

— медленно, неиндексируемо. PostGIS делает это нативно.

PG-GIS-094geometry для глобальных координат

— ошибка на больших расстояниях (земля не плоская).

PG-GIS-095 — Spatial-индекс отсутствует

— все запросы seq-scan.


Чек-лист

  • [ ] Если есть spatial-операции — установлен CREATE EXTENSION postgis.
  • [ ] Тип колонки — geography(Point, 4326), не lat/lon отдельно.
  • [ ] GiST-индекс на geo-колонке.
  • [ ] Поиск в радиусе через ST_DWithin, не ST_Distance в WHERE.
  • [ ] Поиск ближайших через ORDER BY <->, не вычисление distance в коде.
  • [ ] Координаты в POINT(lon lat) — долгота сначала.
  • [ ] При мульти-фильтре — composite GiST с btree_gist.

Связанные

  • Типы индексов — GiST для PostGIS.
  • Расширения — btree_gist, pg_trgm, другие полезные.