Геолокаційні запити в PostgreSQL без важкої артилерії

Привіт, мене звуть Павло Дмитрієв. Я працюю iOS-розробником у компанії Postindustria. Багаторічний Python-досвід подекуди дається взнаки, тож я часто беру участь і в «бекендних» завданнях, адже в нас таке заохочується. Саме тому сьогодні я хотів би розповісти про випадок, що допоміг мені ще раз переконатися в перевагах PostgreSQL і дізнатися, як за допомогою СКБД найлегше виконувати операції з геокоординатами. Звичайно, «зубри» DBA усе це знають, але, сподіваюся, стаття буде цікава для тих, хто ще не з’їв на цьому кількох собак.

У повсякденній рутині програмування часто виникають завдання на кшталт «знайти всіх користувачів додатку поруч з містом X» або «відсортувати щось за віддаленістю від чогось». В одному старому проєкті це взагалі робили на рівні коду, тобто для пошуку вибирали точки, що потрапляють у заданий квадрат запитом на кшталт:

WHERE lat > 10 AND lat < 20 AND lng > 30 AND lng < 45

А потім усе, що опинялося в цій «мережі», додатково фільтрувалося в Python-скриптi. Гадаю, зрозуміло, що продуктивність цього підходу швидко сповзала десь на рівень плінтуса, до того ж повсякчас лізли баги з обробленням граничних умов. Не те щоб їх неможливо було розв’язати, але ліпше подивімося, як це можна зробити на рівні БД. І ні, це не для того, щоб перекласти все зі своїх плечей на DBA.

Востаннє подібне завдання цілком природно виникло під час створення проєкту Zin (dating-стартап з ухилом в безпеку), а для застосунків такого типу локацію користувача можна сміливо вважати за наріжний камінь. Коротко кажучи, нам треба було знаходити найближчі до користувача оголошення в нашій базі й сортувати їх за відстанню.

Окремо зазначу, що нас цікавила відстань за дугою «великого кола», без поправок на рельєф, до того ж припустима похибка була доволі велика, тож головним критерієм стала саме простота реалізації. Забігаючи трохи наперед, скажу, що точність вийшла кращою, ніж ми сподівалися, і я це продемонструю.

Тестові дані

БД проєкту містить приватні дані, тож для прикладів я використовуватиму два інших датасети. Перший — велика таблиця міст і їхніх координат, узята звідси. Вона трохи надлишкова, бо містить не тільки «міста» в їхньому традиційному розумінні, а й райони міст, деякі туристичні принади й таке інше; але раз нам потрібна насамперед кількість, то це не стане проблемою. Тож ось структура нашої першої таблиці.

CREATE TABLE cities (
    id integer DEFAULT nextval('public.cities_seq'::regclass) NOT NULL,
    name character varying NOT NULL,
    region character varying NOT NULL,
    lat double precision NOT NULL,
    lng double precision NOT NULL
);

ALTER TABLE ONLY cities
    ADD CONSTRAINT cities_pkey PRIMARY KEY (id);

CREATE INDEX cities_name_low_idx ON cities USING btree (lower((name)::text));

CREATE INDEX cities_location_idx ON cities USING gist (ll_to_earth(lat, lng));

Усе дуже просто: ім’я місця, код країни, до якої воно належить, і кілька атрибутів для зберігання широти й довготи. Додатково будуємо два індекси: BTREE для пошуку за назвою і той, що нам знадобиться для запитів по координатах. Останній використовує тип GIST (хвала розробникам PostgreSQL за цей чудовий механізм) і функцію ll_to_earth, про яку поговоримо пізніше.

Зазвичай, у вас буде два основні способи зберегти локації: використовувати пару чисел в окремих стовпчиках для ширини та довжини або ж використати той чи інший специфічний тип з-поміж тих, про які ми поговоримо нижче (point, earth, cube, etc.). Великої різниці з погляду швидкості жоден з варіантів не дає, тому зазвичай використовують саме окремі стовпчики. Але тут уже треба аналізувати варіанти, виходячи з конкретики вашого завдання.

Не наводитиму допоміжні Python-скрипти, які я використовував для збору даних і їх перевантаження в БД; якщо вони комусь цікаві, то ось посилання на GitHub з ними.

Переносимо записи в БД і переконуємося, що цього досить, щоб показати всі особливості, пов’язані з продуктивністю.

SELECT COUNT(*), pg_size_pretty(pg_relation_size('cities')) AS data, pg_size_pretty(pg_indexes_size('cities')) AS idxs
FROM cities;

count | data | idxs
----------+--------+---------
12005938 | 848 MB | 1836 MB
(1 row)

Time: 2073.919 ms (00:02.074)

Щоб наочніше продемонструвати складні запити, я також створив дві невеликі таблички: перша містить найбільші міста Чорногорії (15), а друга — найближчі до них піцерії (172); останні я брав з Foursquare, скрипт для цього також є на GitHub.

Фух, це був довгий вступ, тож перейдімо до наявних опцій.

PostGIS

Це саме та «важка артилерія», без якої я обіцяв обійтися в назві цієї статті. Але ж не згадати його було б нелогічно, хоча б для того, щоб зрозуміти, чому саме ми вирішили його не використовувати.

Так, звичайно, цей метод має величезні переваги: найвища точність і гнучкість, відсутність граничних умов, можливість змінити просторову прив’язку.

Але все це дається досить великою ціною, починаючи з прірви залежностей (проста установка через homebrew має більше ніж 30 залежностей, зокрема GCC, Qt, Boost і таке інше) і закінчуючи підвищеною складністю (нестандартні типи даних, 5000+ рядків додаткових таблиць із довідниками).

Тож цілком очевидно, що ми хотіли б використати щось простіше, і, на щастя, основна команда PostgreSQL розробила додаток earthdistance, що містить усе потрібне для нас. Установити його просто:

CREATE EXTENSION earthdistance CASCADE;

Цей додаток дає два способи роботи з координатами.

Оператор <@>

Не знаю, як саме називати цю синтаксичну химеру (може, «око»?), та суть дуже проста: цей оператор приблизно розраховує ту саму big circle distance між двома точками на земній кулі. Для точок використовується вбудований тип даних point, але слід пам’ятати, що спочатку маємо вказувати довготу, а вже потім широту; це суперечить «звичному» порядку, але саме довгота відповідає координаті X, тому й іде першою. Друга особливість, про яку не слід забувати, це те, що результат повертається в статутних милях (тих, що мають 5280 футів або приблизно 1609 метрів), тож завжди треба ділити/множити на цю константу.

Переваги цього методу очевидні: він легкий і використовує найпростіші типи даних. Але є в нього й недоліки: окрім точності (Земля вважається ідеальною сферою), граничних умов на полюсах і 180-му меридіані, він також не дуже добре працює з індексами. Хоча на невеликих обсягах даних його можна використовувати без проблем, звичайно, крім edge cases.

Наведу приклад, як знайти місця, ближчі за 10 км до якоїсь точки (скажімо, до міста з поетичною назвою Бар), що має приблизно такі координати: 42.1° пн. ш., 19.1° сх. д.

SELECT *, (point(lng, lat) <@> point(19.1, 42.1)) * 1609.344 AS distance
FROM
	cities
WHERE
    (point(lng, lat) <@> point(19.1, 42.1)) < (10000 / 1609.344)
ORDER BY
	distance;
      id  |            name             |   lat    |   lng    | region |    distance
----------+-----------------------------+----------+----------+--------+--------------------
 27696013 | Bjelisi                     | 42.10083 | 19.10417 | ME     |  356.2025188423806
 27699128 | Topolica                    | 42.09833 | 19.09417 | ME     |  515.6034584176577
 27696061 | Bar                         |  42.0937 | 19.09841 | ME     |  712.7044816072382
 27708312 | King Nikola's Palace        | 42.10062 | 19.09129 | ME     |   721.903811067934
 27708268 | Princess                    | 42.10158 | 19.09132 | ME     |  737.3598549548324
 27708234 | Bar Port                    | 42.09701 | 19.09132 | ME     |  789.5619694327141
 27708184 | Castello                    | 42.10701 | 19.09623 | ME     |  839.2352057529451
 27699126 | Popovici                    | 42.09194 | 19.10528 | ME     |  996.5017600203865
 27708313 | Antivari Pier (historical)  | 42.09708 | 19.08834 | ME     | 1015.3313902853366
 27695933 | Rikavac                     | 42.09278 | 19.08972 | ME     | 1167.8829329656053
…
(232 rows)
Time: 2844.305 ms (00:02.844)

Як бачите, сам запит — простий, але час виконання не дуже тішить око, навіть без EXPLAIN зрозуміло, що працює звичайний SCAN.

Здебільшого, щоб зоптимізувати такі запити й допомогти їм використовувати індекс, долучають оператор обчислення відстані між точками «за прямою» <->, додатково потім уточнюючи результат за допомогою <@>, але проблеми з полюсами та 180-м меридіаном це все одно не розв’язує.

Вам знайоме це почуття, коли автор книжки чи статті силоміць підводить вас до того рішення, яке він заздалегідь визначив для себе як правильне? Саме час його відчути. Ми переходимо до третього чи, точніше сказати, до другого з половиною (бо він входить у той самий застосунок earthdistance) методу.

Функція earth_distance та її помічники

Може, дехто буде зі мною не згоден, але я вважаю, що команда розробників PostgreSQL — одні з найкмітливіших людей на планеті (а те, що вони створили свій проєкт безплатним і відкритим, робить їх ще й найщирішими). До чого це я веду?

Оператор <@> можна вважати «2D-шляхом» до розв’язання завдання, і якщо він працює не дуже добре, треба переходити в 3D, що й зроблено в earth_distance. Цей модуль дозволяє перетворити будь-які координати на планеті на тривимірну точку в просторі й потім працювати з нею. Оскільки вбудованого типу даних для 3D-точки в Postgres немає, використовується тип cube, а точніше його «нащадок» earth, в якому додаються деякі перевірки на «здоровий глузд» на кшталт «точка не може бути далеко віддалена від поверхні». Звичайно, куб вважається «нульовим», тобто його протилежні вершини збігаються.

Спочатку кілька корисних функцій:

  • ll_to_earth(latitude, longitude) — перетворює пару «широта — довжина» на той самий тип earth, обчислюючи тривимірну позицію точки в просторі.
  • earth_distance(point1, point2) — обчислює відстань між двома точками у форматі earth.
  • earth_box(point, radius) — створює куб такого розміру, щоб у нього потрапляли всі точки, розміщені на відстані не більше ніж radius від point. Цей куб є приблизним критерієм пошуку, але завдяки операторові <@> (входження кубу в куб) дозволяє ефективно використовувати GIST-індекси, проводячи потім «доуточнення» запиту.

Розглянемо приклад.

SELECT *, ROUND(earth_distance(ll_to_earth(42.1, 19.1), ll_to_earth(lat, lng))::NUMERIC, 2) AS distance
FROM
	cities
WHERE
	earth_box(ll_to_earth (42.1, 19.1), 10000) @> ll_to_earth (lat, lng)
	AND earth_distance(ll_to_earth (42.1, 19.1), ll_to_earth (lat, lng)) < 10000
ORDER BY
	distance;
    id    |            name             |   lat    |   lng    | region | distance
----------+-----------------------------+----------+----------+--------+----------
 27696013 | Bjelisi                     | 42.10083 | 19.10417 | ME     |   356.60
 27699128 | Topolica                    | 42.09833 | 19.09417 | ME     |   516.18
 27696061 | Bar                         |  42.0937 | 19.09841 | ME     |   713.51
 27708312 | King Nikola's Palace        | 42.10062 | 19.09129 | ME     |   722.72
 27708268 | Princess                    | 42.10158 | 19.09132 | ME     |   738.19
 27708234 | Bar Port                    | 42.09701 | 19.09132 | ME     |   790.45
 27708184 | Castello                    | 42.10701 | 19.09623 | ME     |   840.18
 27699126 | Popovici                    | 42.09194 | 19.10528 | ME     |   997.62
 27708313 | Antivari Pier (historical)  | 42.09708 | 19.08834 | ME     |  1016.48
 27695933 | Rikavac                     | 42.09278 | 19.08972 | ME     |  1169.20
…
(232 rows)
Time: 102.084 ms

Як бачите, результат куди ліпший за продуктивністю, навіть на звичайнісінькому MacBook Pro 2016, оброблення 12М+ записів майже вклалася в 100 мс. Поглянемо на EXPLAIN-запитові.

Gather Merge  (cost=45002.12..45441.98 rows=3770 width=69)
   Workers Planned: 2
   ->  Sort  (cost=44002.10..44006.81 rows=1885 width=69)
         Sort Key: (round((sec_to_gc(cube_distance('(4471920.234086516, 1548541.2222539173, 4276093.607391206)'::cube, (ll_to_earth(lat, lng))::cube)))::numeric, 2))
         ->  Parallel Bitmap Heap Scan on cities  (cost=651.34..43899.55 rows=1885 width=69)
               Recheck Cond: ('(4461920.235110745, 1538541.2232781458, 4266093.608415434),(4481920.233062288, 1558541.2212296887, 4286093.606366977)'::cube @> (ll_to_earth(lat, lng))::cube)
               Filter: (sec_to_gc(cube_distance('(4471920.234086516, 1548541.2222539173, 4276093.607391206)'::cube, (ll_to_earth(lat, lng))::cube)) < '10000'::double precision)
               ->  Bitmap Index Scan on cities_location_idx  (cost=0.00..650.21 rows=13572 width=0)
                     Index Cond: ((ll_to_earth(lat, lng))::cube <@ '(4461920.235110745, 1538541.2232781458, 4266093.608415434),(4481920.233062288, 1558541.2212296887, 4286093.606366977)'::cube)

Сканування індексу + додаткове уточнення дають нам дуже пристойну швидкість. Тож якщо ваша база велика, зменшити «охоплення» запиту — завжди гарна ідея, бо ж ситуація, коли треба знаходити щось по всьому світові, зустрічається нечасто; набагато частіше ми обмежуємо пошук якимось регіоном.

Мабуть, вас цікавить питання, наскільки розбіжні результати обчислень різними методами (принаймні мені було дуже цікаво). Це легко дізнатися.

SELECT name,
    ST_Distance(ST_MakePoint(lng, lat)::geography, ST_MakePoint(19.1, 42.1)::geography) AS postgis_distance,
    earth_distance(ll_to_earth(42.1, 19.1), ll_to_earth (lat, lng)) AS earth_distance,
    (point(lng, lat) <@> point(19.1, 42.1)) * 1609.344 AS point_distance
FROM
	cities
WHERE
	earth_box(ll_to_earth (42.1, 19.1), 10000) @> ll_to_earth (lat, lng)
	AND earth_distance(ll_to_earth (42.1, 19.1), ll_to_earth (lat, lng)) < 10000
ORDER BY
	earth_distance;
            name             | postgis_distance |   earth_distance   |   point_distance
-----------------------------+------------------+--------------------+--------------------
 Bjelisi                     |     357.05152825 |  356.6040157475758 |  356.2025188423806
 Topolica                    |     516.71294939 |  516.1846255398445 |  515.6034584176577
 Bar                         |     712.02799946 |  713.5078129379874 |  712.7044816072382
 King Nikola's Palace        |     723.77941534 |   722.717511506365 |   721.903811067934
 Princess                    |     739.14564214 |  738.1909768134576 |  737.3598549548324
 Bar Port                    |     791.12180279 |  790.4519313791742 |  789.5619694327141
 Castello                    |     838.76185601 |  840.1811573389859 |  839.2352057529451
 Popovici                    |     996.13740888 |  997.6249760322966 |  996.5017600203865
 Antivari Pier (historical)  |    1017.61930422 | 1016.4758302850378 | 1015.3313902853366
 Rikavac                     |    1168.91272962 |  1169.199322822384 | 1167.8829329656053
…
 Knjezdovo                   |    9751.02688274 |  9743.710964250973 |  9732.740617250734
 Rt Sapavica                 |    9776.31845857 |  9772.941912657994 |  9761.938654824216
 Livrsanik                   |    9790.74772202 |  9781.953343427898 |  9770.939939713666
 Kabeta                      |    9803.27894257 |  9791.177885397401 |  9780.154095863507
 Pecurice                    |    9825.29031086 |   9833.21849793169 |  9822.147375291843
 Dedici                      |    9825.38102702 |  9836.570130079455 |  9825.495233870668
 Kozjak                      |    9860.53074801 |  9882.053049767033 |  9870.926944792083
 Livari                      |    9899.30877216 |  9888.084255774567 |   9876.95136032431
 Ocas                        |    9877.66304883 |  9893.775540351273 |  9882.636237141522
 Kamisora                    |    9932.59217935 |  9926.356311450303 |  9915.180325874655
 Lunje                       |    9955.46356913 |  9951.533852256645 |  9940.329519539344
 Pepici                      |    9950.93625773 |  9971.099451151647 |  9959.873089720966
 Bajraktari                  |    9993.82232318 |  9992.844754025287 |  9981.593909774985
(228 rows)
Time: 500.176 ms

Тож, як бачите, розбіжність складає десь до 30 метрів на 10 км у найгірших випадках, що для більшості сценаріїв є доволі прийнятною похибкою. До речі, ми чекали значно більшої похибки й ця нас цілком влаштовувала.

Підсумуємо інформацію про «метод кубів». Його переваги: швидкість, простота, можливість «переоцінити» розмір планети, змінивши таким чином одиниці виміру. З недоліків залишається сама точність, бо планета, як і раніше, вважається сферою.

Цікавіші запити

Тепер, коли ми знаємо основи, поговоримо про складніші запити. Наведу кілька прикладів, використовуючи для наочності «зменшений датасет» з піцеріями.

Приміром, нам треба знайти найвіддаленіші від нас піцерії, а після цього визначити, до яких міст вони належать (вважатимемо, що піцерія належить до міста, центр якого розміщений найближче до неї). Звичайно, зробити це можна безліччю методів (бо ж це SQL); наведу той, що, на мій погляд, найточніше висловлює «алгоритм» пошуку.

WITH pizzerias AS (
    SELECT
        name,
        ll_to_earth(lat, lng) as point,
        earth_distance(ll_to_earth(lat, lng), ll_to_earth(42.1, 19.1)) AS distance
    FROM pizzerias
    ORDER BY
        distance
        DESC
    LIMIT 25
)
SELECT c.name AS city, p.name, p.distance
    FROM pizzerias p,
         LATERAL (SELECT name FROM cities_min c
                  ORDER BY earth_distance(ll_to_earth(c.lat, c.lng), p.point) LIMIT 1) c;
     city     |           name            |      distance
--------------+---------------------------+--------------------
 Pljevlja     | Brut                      | 141584.33308274313
 Zabljak      | Balkan                    |  117451.9785288297
 Zabljak      | Caffe pizzeria Balkan     | 117435.32995569201
 Zabljak      | Lazar                     |  117308.9999099511
 Bijelo Polje | cezar                     | 116663.29228130652
 Bijelo Polje | Orao                      | 116642.62285195319
 Bijelo Polje | Pierre                    | 116580.71776744149
 Bijelo Polje | Padrino                   | 104133.00996894344
 Kolasin      | Padrino Pizzerua          | 103643.11705954213
 Kolasin      | Pizzeria Kapri            |   87709.3205690596
 Niksic       | Chaplin                   |  76339.67053537675
 Niksic       | Pizzeria Garfield         |  76139.35825221038
 Niksic       | Stari Nikšić              |  76118.70681853226
 Niksic       | Piazza Restaurant & Caffe |  76110.42052261815
 Niksic       | K2                        |  75899.71658365549
 Niksic       | Šef                       |  75850.41193677587
 Herceg Novi  | Grifonee                  | 62681.009366742284
 Herceg Novi  | Nautilus                  | 62597.163060010935
 Herceg Novi  | Caffe Pizzeria Biblioteka |  62586.73859525146
 Herceg Novi  | Forte                     |  61781.28207372379
 Herceg Novi  | Atelier                   |  61018.18974275154
 Herceg Novi  | Popaj                     | 60869.123535469735
 Herceg Novi  | Cafe Pizzeria Trg         |  60721.08839248005
 Herceg Novi  | Caffe Pizzeria 5          |  60702.71868706536
 Herceg Novi  | Pic Nic                   |  60417.41134618965
(25 rows)

Time: 8.487 ms

Спочатку ми вибираємо найвіддаленіші заклади, використовуючи сортування за відстанню за зменшенням. Після цього нам лишається «пройти» цими даними, вибираючи для кожного рядка найближче місто.

Ще один цікавий запит. Знайдімо, в яких містах найбільше закладів з піцою; вважатимемо, що там більше люблять італійські смаколики. Логічніше, звичайно, було б рахувати відношення кількості піцерій на душу населення, але мені було ліньки шукати інформацію за населеністю, тому обчислимо «абсолютну» любов. Вважатимемо, що піцерія розміщена в місті, якщо від неї до центру не більше ніж 10 км.

SELECT c.name, count(cp)
    FROM cities_min c,
         LATERAL (SELECT "name"
                  FROM pizzerias p
                  WHERE earth_distance(ll_to_earth(p.lat, p.lng), ll_to_earth(c.lat, c.lng)) < 10000
         ) AS cp
    GROUP BY c.name
    ORDER BY count(cp) DESC;
     name     | count
--------------+-------
 Podgorica    |    46
 Tivat        |    36
 Kotor        |    31
 Budva        |    23
 Bar          |    19
 Herceg Novi  |    16
 Ulcinj       |    16
 Petrovac     |    11
 Niksic       |     6
 Cetinje      |     6
 Danilovgrad  |     3
 Bijelo Polje |     3
 Zabljak      |     3
 Pljevlja     |     1
 Kolasin      |     1
(15 rows)

Time: 65.703 ms

Мабуть, результати говорять самі за себе: якщо відкинути столицю, туристичні міста на узбережжі виказують значно більшу любов до піци, ніж ті, що розміщуються в горах (навіть наявність гірськолижних курортів не дуже впливає на традиціоналізм місцевих горян).

Висновки

  • PostgreSQL має два вбудованих механізми для роботи з локаціями: оператор <@> та набір функцій earth_distance.
  • Використовувати ліпше другий, але їх можливо комбінувати.
  • PostGIS — найточніший метод оброблення таких даних, але він складніший з усіх боків, тому здебільшого можна обійтися простішим методом earth_distance.
  • Звузити межі пошуку, задавши певний обмежений простір для запиту, — завжди слушна ідея.

Матеріал також доступний у форматі відео.

Похожие статьи:
В начале хотелось бы представиться с профессиональной стороны: я не являюсь супер-мега гуру проектного менеджмента, но постоянно...
When you mentioned Safari, most people have the imagination of a jungle filled with all kind of wildlife as they see in wildlife documentaries- a jungle where the BIG 5 reign supreme. Well, that’s what you get from a Tanzania safari. As...
Вы хотите стать тестировщиком ПО и не знаете с чего начать? Приходите к нам на открытый интенсив «Как стать тестировщиком...
MacPaw планує запустити свій мобільний маркетплейс Setapp у Європейському Союзі. Про це у блозі на сайті компанії написав Director...
У кінці червня шахраї вкрали в українців 100 мільйонів гривень під виглядом оформлення фінансової допомоги від країн ЄС....
Яндекс.Метрика