Когда приложению нужно хранить координаты и искать по ним — «пункты выдачи рядом», «попадает ли адрес в зону доставки», «три ближайших» — первый импульс завести два поля lat и lon. Хранить так можно, искать — нет: запрос «все точки в 5 км» превращается в перебор таблицы, потому что обычный индекс умеет упорядочивать одно число, а не пары чисел на сфере.
PostGIS — расширение PostgreSQL, которое добавляет типы для точек и полигонов, индекс, умеющий отвечать «что рядом», и функции расстояния в метрах. Ниже — как это устроено, где ломается и что стоит каждый выбор.
Поиск в радиусе идёт в два шага. Пространственный индекс умеет сравнивать только прямоугольные рамки: он быстро отбрасывает всё, что заведомо далеко, и отдаёт кандидатов — точки внутри квадрата вокруг центра поиска. Точное расстояние в метрах считает уже функция ST_DWithin, и на этом шаге отпадают кандидаты из углов рамки: по прямой до них дальше 5 км. Поэтому без индекса запрос честно перебирает миллион строк, а с индексом считает расстояние несколько раз.
Когда PostGIS нужен, а когда достаточно чисел
Расширение тянет за собой полсотни типов и сотни функций, поэтому ставить его «на всякий случай» не стоит. Оно окупается, когда координаты участвуют в запросах:
- поиск объектов в радиусе («кафе в 2 км»);
- поиск N ближайших объектов;
- проверка, попадает ли точка в полигон (район, зона доставки);
- пересечения границ, буферы вокруг маршрутов, площади.
Если координаты только хранятся и рисуются на карте, а фильтруют по городу или улице — пары numeric(9,6) достаточно. Промежуточный случай — «только точки и только радиус» — закрывает лёгкое расширение earthdistance, о нём ниже в разделе про радиус.
Подключается PostGIS одной командой CREATE EXTENSION postgis; — в управляемых базах (RDS, Cloud SQL, Яндекс Managed PostgreSQL) пакет уже стоит.
Почему нельзя считать расстояние по Пифагору: geography и geometry
Координаты в градусах выглядят как обычные x и y, и рука тянется к sqrt((x1 - x2)² + (y1 - y2)²). Но градус долготы — не постоянная длина: на экваторе это 111 км, на широте Москвы — 62 км, у полюса — ноль. Расстояние «по Пифагору в градусах» врёт в разы, причём по-разному вдоль и поперёк карты. Именно эту разницу и прячут два типа PostGIS.
geometry считает координаты точками на плоскости. Это честно, если координаты уже в метрах на плоскости — то есть спроецированы. Если положить в geometry градусы, ST_Distance вернёт градусы: Москва — Новосибирск получится 45,5, а не 2 838 км.
geography трактует координаты как точки на эллипсоиде WGS84 — той же модели Земли, что у GPS, — и возвращает метры. Это выбор по умолчанию для любых координат из GPS, карт и API: колонка geography(Point, 4326), где 4326 — номер системы координат WGS84.
Цена geography — вычисления. Миллион расстояний на эллипсоиде PostGIS 3.5 считает 1,5 с, тот же миллион по шару — 0,44 с, на плоскости — 0,17 с: эллипсоид в девять раз дороже плоскости. Для поиска в радиусе это почти незаметно, потому что точное расстояние считается только для кандидатов после индекса — в примере ниже 1 451 точка из миллиона. Бьёт эта цена там, где расстояние считают для всех строк: сортировка без индекса, отчёт «суммарный пробег за месяц», соединение двух таблиц по близости.
Быстрый компромисс — считать по шару вместо эллипсоида: четвёртый аргумент false у ST_Distance и ST_DWithin. Втрое дешевле, ошибка около 0,3 %: Москва — Петербург по эллипсоиду 632 653 м, по шару 631 077 — разница 1,6 км на 630. Для «ближайший пункт выдачи» это незаметно, для тарификации доставки по километрам — вопрос к бизнесу.
Второй путь к скорости — geometry в метрической проекции через ST_Transform. Здесь главная ловушка — взять 3857, проекцию веб-карт: она растягивает расстояния в 1 / cos(широты) раз, и на широте Москвы это 1,78. Правильная проекция для города или области — зона UTM (для Москвы 32637): расхождение с эллипсоидом 0,08 %, то есть полкилометра на 630. Зона UTM шириной шесть градусов долготы, поэтому данные на всю страну в неё не помещаются — тогда остаётся geography.
живой пример
SELECT round(ST_Distance(a::geography, b::geography)) AS spheroid_m,
round(ST_Distance(a::geography, b::geography, false)) AS sphere_m,
round(ST_Distance(ST_Transform(a, 3857), ST_Transform(b, 3857))) AS mercator_m,
round(ST_Distance(ST_Transform(a, 32637), ST_Transform(b, 32637))) AS utm_m
FROM (SELECT ST_Point(37.6055, 55.7649, 4326) AS a,
ST_Point(30.348, 59.932, 4326) AS b) AS moscow_spb;
-- 632653 | 631077 | 1189364 | 633131
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Хранение точек и ловушка с порядком координат
CREATE TABLE pickup_points (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
city text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
location geography(Point, 4326) NOT NULL
);
INSERT INTO pickup_points (title, city, location) VALUES
('Пункт выдачи на Тверской', 'Москва', ST_Point(37.6055, 55.7649, 4326)::geography),
('Пункт выдачи в Хамовниках', 'Москва', ST_GeogFromText('SRID=4326;POINT(37.58 55.73)'));
Точку строят либо ST_Point(x, y, srid) — третий аргумент появился в PostGIS 3.2, раньше писали ST_SetSRID(ST_MakePoint(x, y), 4326), — либо из текста WKT: SRID=4326;POINT(x y). Текстовый вариант принимает любой драйвер без дополнительных типов, поэтому именно он идёт из кода приложения.
В обоих случаях первой идёт долгота, потом широта: POINT(37.60 55.76), а не POINT(55.76 37.60) — сначала x, потом y, как во всей геометрии, а долгота и есть x. Вслух и в адресных API порядок обратный, поэтому перепутать легко, а заметить трудно: обе координаты в допустимых пределах, PostGIS ничего не проверяет, вставка проходит, индекс строится. Ошибка всплывает как пустой ответ на «пункты в 5 км».
Одни и те же два числа, записанные в обратном порядке, дают точку, которая для базы ничем не подозрительна: широта 37,6 и долгота 55,8 существуют. Все пункты выдачи, вставленные так, переезжают в Иран разом, и радиусный поиск вокруг любого московского адреса честно возвращает пустой список.
Если таблица уже хранит lat и lon и их пишет старый код, колонку заводят вычисляемой — база заполняет её сама из двух чисел, и приложение переписывать не нужно:
ALTER TABLE pickup_points ADD COLUMN location geography(Point, 4326)
GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography) STORED;
Обратная сторона: писать в такую колонку нельзя вообще. Любой INSERT или UPDATE, где она упомянута, падает с cannot insert a non-DEFAULT value into column "location", даже если значение правильное. Координаты кладут по-прежнему в lat и lon, а location появляется сама.
Пространственный индекс: рамки вместо расстояний
B-tree упорядочивает значения по одной оси, а «рядом» на карте — это близость сразу по двум. Поэтому для гео-колонок строят GiST:
CREATE INDEX ix_pickup_points_location_gist ON pickup_points USING gist (location);
GiST хранит не фигуру, а её прямоугольную рамку, и умеет одно — быстро отвечать, пересекаются ли две рамки; точную функцию база вызывает только для кандидатов, чьи рамки задели рамку поиска. На миллионе точек и радиусе 50 км индекс отобрал 1 451 кандидата, ST_DWithin оставила 912 — угловые 37 % отпали на втором шаге, запрос занял 9 мс. Без индекса тот же запрос перебирает миллион строк за 1,4 с.
Стоит индекс дороже обычного. На миллионе точек GiST занял 69 МБ против 30 МБ у B-tree по паре (lat, lon), а строился 6,5 с против 0,8 — рамки тяжелее чисел, и дерево балансируется медленнее. После массовой загрузки нужен ANALYZE, иначе планировщик не знает, сколько строк отдаст индекс, и может уйти в перебор. Если координаты часто обновляются — курьеры каждые несколько секунд, — индекс растёт: каждое обновление добавляет запись, а освобождённое место страницы обратно не отдают. За размером следят через pg_relation_size и раз в какое-то время перестраивают REINDEX INDEX CONCURRENTLY.
Поиск в радиусе
Условие «ближе 5 км» пишут через ST_DWithin: она принимает две фигуры и расстояние в метрах и умеет пользоваться индексом. ST_Distance в списке колонок считает точные метры уже для отобранных строк:
живой пример
SELECT id, title,
round(ST_Distance(location, ST_Point(37.6208, 55.7539, 4326)::geography)) AS distance_m
FROM pickup_points
WHERE ST_DWithin(location, ST_Point(37.6208, 55.7539, 4326)::geography, 5000)
ORDER BY distance_m
LIMIT 50;
-- Тверская 1556 м, Хамовники 3694 м; Сокольники с их 5349 м не прошли
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Так не надо — то же условие через WHERE ST_Distance(location, :p) < 5000. Ответ тот же, план другой: условие стоит на посчитанном значении, индексу не за что зацепиться, и база считает расстояние до каждой строки — на миллионе точек 1,4 с против 9 мс. По той же причине не фильтруют расстояние в коде приложения: чтобы отобрать десять точек, придётся вытащить всю таблицу.
Что именно делает индекс, видно на модели без базы. Формула гаверсинуса — это расстояние между двумя точками на шаре по их широтам и долготам, та самая математика «по шару», которую PostGIS включает флагом false:
живой пример
import java.util.List;
public class RadiusSearch {
record Shop(String name, double lon, double lat) {}
static final double EARTH_M = 6371008.8;
static double haversineMeters(double lon1, double lat1, double lon2, double lat2) {
double f1 = Math.toRadians(lat1), f2 = Math.toRadians(lat2);
double a = Math.pow(Math.sin((f2 - f1) / 2), 2) + Math.cos(f1) * Math.cos(f2)
* Math.pow(Math.sin(Math.toRadians(lon2 - lon1) / 2), 2);
return 2 * EARTH_M * Math.asin(Math.sqrt(a));
}
public static void main(String[] args) {
double lon = 37.6208, lat = 55.7539, radius = 5000;
List<Shop> shops = List.of(
new Shop("Тверская", 37.6055, 55.7649),
new Shop("Сокольники", 37.679, 55.789),
new Shop("Митино", 37.362, 55.845));
double boxLat = Math.toDegrees(radius / EARTH_M);
double boxLon = boxLat / Math.cos(Math.toRadians(lat));
for (Shop s : shops) {
if (Math.abs(s.lat() - lat) > boxLat || Math.abs(s.lon() - lon) > boxLon) {
System.out.printf("%-10s рамка отсекла сразу%n", s.name());
continue;
}
double d = haversineMeters(lon, lat, s.lon(), s.lat());
System.out.printf("%-10s %5.0f м — %s%n", s.name(), d,
d <= radius ? "в радиусе" : "дальше 5 км");
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Сокольники прошли рамку, а по расстоянию не прошли — это и есть угол квадрата. Митино отсекла рамка, и до гаверсинуса дело не дошло.
Когда хватает earthdistance
Если полигонов не будет никогда, а нужен только радиус между точками, PostGIS избыточен: earthdistance поверх cube работает с обычными колонками широты и долготы, весит на два порядка меньше и считает по шару, в метрах.
Так не надо — оператор <@> между двумя точками: WHERE point(lon, lat) <@> point(37.6208, 55.7539) < 5. Читается коротко, но у него две беды: считает он в милях, так что < 5 — это 8 км, а не 5, и индекса у него нет — point(lon, lat) база собирает для каждой строки заново.
Так надо — индекс по выражению ll_to_earth и куб-рамка earth_box, а точное расстояние вторым условием, потому что куб, как и рамка GiST, захватывает углы:
CREATE INDEX ix_pickup_points_earth ON pickup_points USING gist (ll_to_earth(lat, lon));
живой пример
SELECT id, title
FROM pickup_points
WHERE earth_box(ll_to_earth(55.7539, 37.6208), 5000) @> ll_to_earth(lat, lon)
AND earth_distance(ll_to_earth(55.7539, 37.6208), ll_to_earth(lat, lon)) < 5000;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Ловушка здесь своя: у ll_to_earth порядок аргументов обратный PostGIS — сначала широта, потом долгота. Запрос с перепутанными аргументами отработает и вернёт точки, просто не те.
Ближайшие N точек
«Пять ближайших» — не «все в радиусе»: радиуса здесь нет, а значит, нет и рамки, которой индекс отсёк бы лишнее. Для этого случая у GiST есть второй режим: оператор <-> в ORDER BY заставляет индекс обходить точки в порядке удаления от центра и остановиться, как только набрался LIMIT. На миллионе точек пять ближайших находятся за 0,5 мс:
живой пример
SELECT id, title
FROM pickup_points
ORDER BY location <-> ST_Point(37.6208, 55.7539, 4326)::geography
LIMIT 3;
-- Тверская, Хамовники, Сокольники — третья точка стоит за пределами 5 км
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Радиус задаёт границу заранее, и всё за ней индекс отбрасывает рамкой. У ближайших N границы нет: индекс идёт от центра наружу и останавливается на третьей найденной, поэтому Сокольники попадают в ответ, хотя стоят дальше 5 км. Митино в обоих случаях не читается — слева его отсекла рамка, справа обход до него не добрался.
Три оговорки, без которых <-> перестаёт быть дешёвым. Первая: сортировать нужно по самому оператору, а не по посчитанной колонке. ORDER BY distance_m, где distance_m — ST_Distance из SELECT, индекс использовать не может: база считает расстояние до всех строк и сортирует — на миллионе точек 1,4 с вместо 0,5 мс. Точные метры для найденных строк досчитывают той же ST_Distance в списке колонок: три вызова, а не миллион; сам <-> для geography считает по шару, так что порядок и точное расстояние — два разных вычисления.
Вторая: фильтр в WHERE индекс не отменяет, но меняет цену. Обход по-прежнему идёт от центра наружу, и каждую найденную точку база проверяет на фильтр; если условие редкое, идти придётся далеко. На миллионе точек фильтр, под который подходят 3 % строк, заставил индекс просмотреть и выбросить 308 тысяч точек — 1,7 с, медленнее полного перебора с сортировкой. Лечится частичным индексом с тем же условием или ограничением ST_DWithin рядом, чтобы обходу было где остановиться.
Третья: без LIMIT обход идёт до конца таблицы — «отсортировать всё по расстоянию» индексом не ускоряется.
Полигоны: зоны, районы, границы
Вопрос «в какую зону доставки попадает адрес» — это точка против набора контуров. Контур хранится в той же geography, но тип фигуры другой, и здесь первая же настоящая выгрузка ломает колонку, объявленную как geography(Polygon, 4326): у района есть остров или анклав, это уже MultiPolygon, и вставка падает с Geometry type (MultiPolygon) does not match column type (Polygon). Поэтому зоны сразу объявляют MultiPolygon. В PostGIS 3.5 одиночный полигон в такую колонку кладётся без обёртки — база сама делает из него MultiPolygon из одной части; чтобы не зависеть от версии, оборачивают в ST_Multi.
Вторая беда — контур с самопересечением. GeoJSON из редактора или чужой выгрузки может содержать «бантик»: две вершины перепутаны, и границы зоны пересекают сами себя. PostGIS такой полигон принимает молча, а потом тихо врёт: ST_Area возвращает ноль, ST_Intersects отвечает «да» для точек в обеих половинах бантика. Проверка — ST_IsValid, причина — ST_IsValidReason, починка — ST_MakeValid, которая режет бантик на два честных треугольника:
живой пример
SELECT ST_IsValidReason(bad) AS reason,
ST_AsText(ST_MakeValid(bad)) AS fixed
FROM (SELECT ST_GeomFromText(
'POLYGON((37.50 55.70, 37.70 55.80, 37.70 55.70, 37.50 55.80, 37.50 55.70))', 4326) AS bad) AS t;
-- Self-intersection[37.6 55.75] | MULTIPOLYGON(((37.5 55.8, 37.6 55.75, 37.5 55.7, 37.5 55.8)), ((37.7 55.7, 37.6 55.75, 37.7 55.8, 37.7 55.7)))
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Чтобы невалидный контур не попал в таблицу вообще, проверку вешают ограничением — ST_IsValid объявлена для geometry, поэтому с приведением:
CREATE TABLE delivery_zones (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
boundary geography(MultiPolygon, 4326) NOT NULL,
CONSTRAINT delivery_zones_boundary_valid CHECK (ST_IsValid(boundary::geometry))
);
CREATE INDEX ix_delivery_zones_boundary_gist ON delivery_zones USING gist (boundary);
INSERT INTO delivery_zones (title, boundary) VALUES (
'Замоскворечье',
ST_Multi(ST_GeomFromGeoJSON(
'{"type":"Polygon","coordinates":[[[37.60,55.73],[37.65,55.73],[37.65,55.75],[37.60,55.75],[37.60,55.73]]]}'
))::geography
);
Для geography отдельного разбора GeoJSON нет, поэтому контур читают как geometry и приводят: у GeoJSON по стандарту всегда WGS84, и приведение к geography само проставит 4326.
Проверка попадания — ST_Intersects: для точки и полигона «имеют общую точку» и значит «внутри или на границе». У точки обязательно указывают систему координат, иначе PostGIS откажет с ошибкой про несовпадение SRID:
живой пример
SELECT id, title
FROM delivery_zones
WHERE ST_Intersects(boundary, ST_Point(37.6055, 55.7649, 4326)::geography);
-- Центр Москвы
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
В документации на первом месте стоит ST_Contains, и рука тянется к ней. Для geography её нет: запрос падает с function st_contains(geography, geography) does not exist. Из функций взаимного расположения у geography есть три: ST_Intersects, ST_Covers («A накрывает B целиком, граница считается») и обратная ей ST_CoveredBy. ST_Contains отличается от ST_Covers только тем, что точку ровно на границе не считает внутренней, — для зон доставки разницы нет. Обходной путь ST_Contains(boundary::geometry, ...) работает, но отключает индекс: он построен по колонке geography, а не по выражению с приведением, и база перебирает все зоны.
У точек рамка совпадает с самой точкой, у полигона — нет: рамка района с изгибом реки вдвое больше района, а рамки соседних зон накладываются друг на друга. Поэтому по полигонам индекс отдаёт больше лишних кандидатов, а второй шаг дороже: проверить точку против контура из десяти тысяч вершин — это пройти все десять тысяч.
Отсюда приём для тяжёлых границ: контур режут ST_Subdivide(boundary::geometry, 256) на куски не больше 256 вершин и кладут в отдельную таблицу со ссылкой на зону. У каждого куска своя маленькая рамка, кандидатов становится меньше, а проверка контура — короче.
Откуда берут границы
Административные границы лежат в OpenStreetMap: отношения с boundary=administrative и уровнем admin_level, вытащить их можно запросом к Overpass или из готовой выгрузки региона. Зоны доставки рисуют руками в любом редакторе карт и сохраняют GeoJSON. Загрузить файл в таблицу целиком умеет ogr2ogr из GDAL — он читает GeoJSON и Shapefile, при необходимости перепроецирует в 4326 и сам оборачивает одиночные полигоны в MultiPolygon:
ogr2ogr -f PostgreSQL PG:"dbname=shop user=app" zones.geojson \
-nln delivery_zones -nlt PROMOTE_TO_MULTI -t_srs EPSG:4326 \
-lco GEOMETRY_NAME=boundary -lco GEOM_TYPE=geography
Для выгрузок OSM целиком есть osm2pgsql, но он приносит собственную схему таблиц, из которой границы потом переливают в свою. Shapefile от ведомств часто в местной проекции — без -t_srs координаты лягут в градусы как есть, и все зоны окажутся в океане.
Глубже: Вставка из кода приложениярасширенное
Самый переносимый способ передать координаты из приложения — WKT-строка SRID=4326;POINT(lon lat): её принимают все драйверы без дополнительных зависимостей, а разбирает уже база.
import org.jooq.DSLContext;
import org.jooq.impl.DSL;
import java.util.Locale;
import static com.example.db.Tables.PICKUP_POINTS;
public record GeoPoint(double lat, double lon) {
public String toWkt() {
return String.format(Locale.ROOT, "SRID=4326;POINT(%f %f)", lon, lat);
}
}
void insertPoint(DSLContext ctx, String title, String city, GeoPoint pt) {
ctx.insertInto(PICKUP_POINTS)
.set(PICKUP_POINTS.TITLE, title)
.set(PICKUP_POINTS.CITY, city)
.set(PICKUP_POINTS.LOCATION,
DSL.field("ST_GeogFromText({0})", Object.class, pt.toWkt()))
.execute();
}
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
type GeoPoint struct{ Lat, Lon float64 }
func (p GeoPoint) WKT() string {
return fmt.Sprintf("SRID=4326;POINT(%f %f)", p.Lon, p.Lat)
}
func insertPoint(ctx context.Context, pool *pgxpool.Pool, title, city string, pt GeoPoint) error {
_, err := pool.Exec(ctx,
`INSERT INTO pickup_points (title, city, location) VALUES ($1, $2, ST_GeogFromText($3))`,
title, city, pt.WKT(),
)
return err
}
import { Pool } from 'pg';
interface GeoPoint { lat: number; lon: number }
function toWkt(pt: GeoPoint): string {
return `SRID=4326;POINT(${pt.lon} ${pt.lat})`;
}
async function insertPoint(pool: Pool, title: string, city: string, pt: GeoPoint): Promise<void> {
await pool.query(
`INSERT INTO pickup_points (title, city, location) VALUES ($1, $2, ST_GeogFromText($3))`,
[title, city, toWkt(pt)],
);
}
import psycopg
from dataclasses import dataclass
@dataclass
class GeoPoint:
lat: float
lon: float
def wkt(self) -> str:
return f"SRID=4326;POINT({self.lon} {self.lat})"
async def insert_point(conn: psycopg.AsyncConnection, title: str, city: str, pt: GeoPoint) -> None:
await conn.execute(
"INSERT INTO pickup_points (title, city, location) VALUES (%s, %s, ST_GeogFromText(%s))",
(title, city, pt.wkt()),
)
В Java-варианте Locale.ROOT обязателен: без него %f возьмёт локаль машины, на русской системе выйдет POINT(30,335800 59,934300), и PostGIS её не разберёт — а на ноутбуке с английской локалью всё работает до самого прода. В Go, Node и Python форматирование чисел от локали не зависит.
Если геометрию нужно считать в приложении — пересечение маршрута с зоной до запроса в базу, упрощение контура перед отрисовкой, — есть библиотеки с теми же WKT и GeoJSON: JTS (Java), Shapely (Python), turf.js (Node).
Глубже: Фильтр по нескольким полям: активные пункты в радиусерасширенное
Почти всегда радиус идёт в паре с обычным условием: только активные пункты, только своей сети. GiST по location про is_active не знает: индекс отдаёт всех кандидатов в рамке, флаг проверяется уже на строках. Пока активных большинство, это дёшево; когда активных 5 %, индекс тащит в двадцать раз больше строк, чем нужно.
Проще всего частичный индекс: он хранит только активные точки, меньше основного и работает на любой версии PostgreSQL. Условие запроса должно повторять условие индекса дословно:
CREATE INDEX ix_pickup_points_active_location ON pickup_points USING gist (location) WHERE is_active;
SELECT id, title
FROM pickup_points
WHERE is_active
AND ST_DWithin(location, ST_Point(37.6208, 55.7539, 4326)::geography, 5000);
Если условий несколько и они меняются — сеть, город, тип пункта, — частичный индекс под каждое сочетание не заведёшь. Тогда ставят расширение btree_gist, которое учит GiST хранить обычные колонки рядом с рамкой, и строят один составной индекс USING gist (network_id, location); для boolean это работает с PostgreSQL 15.
Коротко
geography(Point, 4326)по умолчанию: метры на любой территории.geometry— в проекции UTM для одного города ради скорости или функций, которых уgeographyнет; 3857 растягивает расстояния, на широте Москвы в 1,78 раза.- Эллипсоид в девять раз дороже плоскости, но платят за него только кандидаты после индекса; где расстояние считают для всех строк, флаг
falseпереводит счёт на шар: втрое дешевле ценой 0,3 %. - GiST хранит рамки, а не фигуры: любой запрос — отбор по рамке плюс точная функция. Индекс вдвое тяжелее B-tree, растёт под частыми обновлениями, после загрузки нужен
ANALYZE. - Радиус —
ST_DWithinвWHERE, ближайшие N —<->вORDER BYсLIMIT. Сортировка по посчитанному расстоянию или редкий фильтр рядом с<->возвращают перебор. POINT(долгота широта)в PostGIS,ll_to_earth(широта, долгота)вearthdistance; перепутанный порядок для базы не ошибка, для пользователя — пустой ответ.- Зоны —
geography(MultiPolygon, 4326)сCHECK (ST_IsValid(boundary::geometry)); попадание —ST_IntersectsилиST_Covers;ST_Containsуgeographyнет, а приведение кgeometryотключает индекс.
Что почитать дальше
- Типы индексов в PostgreSQL — почему GiST устроен иначе, чем B-tree и GIN, и что ещё он умеет кроме рамок.
- EXPLAIN ANALYZE — как отличить
Index Condпо рамке отFilterпо расстоянию и убедиться, что запрос пошёл по GiST. - Расширения PostgreSQL — что происходит при
CREATE EXTENSIONи как ставятpostgisиbtree_gistтам, где нет прав суперпользователя. - Materialized views — заранее посчитать «какой пункт в какой зоне», если зоны меняются раз в месяц, а спрашивают тысячу раз в секунду.