open atlas
↑ К треку
Разборы System Design SDC · 04 · 01

Проектируем сервис поиска поблизости

Проектируем поиск рядом (как Yelp): индексируем мир через geohash, quadtree или S2/H3, запрашиваем по ячейкам вместо сканирования, решаем проблему границы ячейки и кэшируем ответы по ячейкам на read-heavy нагрузке.

SDC Senior ◷ 34 min
Уровень
ОсновыJuniorMiddleSenior

«Покажи кофейни в радиусе 2 км» звучит как одна строка SQL — и это ровно ловушка. Наивный запрос считает расстояние от пользователя до каждого из десяти миллионов заведений, сортирует и возвращает ближайшие — полное сканирование таблицы на каждом нажатии клавиши в подсказке, умноженное на сотню тысяч одновременных пользователей. Первая версия часто делает именно это, отлично работает на ноутбуке с тысячей строк и плавится в продакшене. Всё мастерство сервиса поиска поблизости — превратить «сравни со всем» в «загляни в горстку заранее посчитанных корзин», а потом смириться с тем, что ближайшее заведение иногда лежит в соседней корзине.

К концу урока ты будешь знать, какой пространственный индекс выбрать, почему первая реализация пропускает ближайший результат и как кэшировать ответы так, чтобы база почти не видела этой нагрузки.

Требования

Сначала форма, потом хранилище. У сервиса поиска рядом, как Yelp или поиск магазинов, узкий набор функций и очень специфический профиль нагрузки.

  • Функциональные: по широте/долготе пользователя и радиусу (или числу «k ближайших») вернуть подходящие места, ранжированные. Сверху накладываются фильтры (категория, открыто-сейчас, рейтинг). Места меняются медленно; координаты кофейни по сути статичны.
  • Нефункциональные: read-heavy с большим перевесом — поисков несравнимо больше, чем правок. Низкая задержка для подсказки (десятки миллисекунд). Допустима eventual-свежесть данных о местах; новый ресторан, появившийся с минутной задержкой, приемлем. Доступность важнее строгой консистентности.

Решающее наблюдение — асимметрия чтения/записи. Локации пишутся редко и читаются постоянно, поэтому можно позволить себе один раз построить тяжёлый индекс и амортизировать его на миллионах запросов — противоположность write-heavy задачи живой локации из следующего урока.

Оценки

Пусть 100 млн мест в мире и 200 млн поисков в сутки. Средний QPS поиска — 2×10^8 / ~10^5 с ≈ 2000, и провижиним на пик ~10 000 (обеденные и вечерние всплески). Каждый поиск не должен трогать 100 млн строк; он трогает несколько сотен кандидатов внутри нужных ячеек. Данные о месте малы — скажем, 1 КБ на запись, то есть ~100 ГБ всего, что легко индексируется и в основном кэшируется. Горячий рабочий набор — «центры популярных городов», доля от целого, поэтому кэширование по локации так окупается.

Высокоуровневая схема

Конвейер один и тот же, какой бы пространственный индекс вы ни выбрали: точку пользователя и радиус превратить в набор идентификаторов ячеек, достать кандидатов из этих ячеек, посчитать точные расстояния и фильтры на этом малом множестве, затем ранжировать и вернуть.

Гео-индекс-сервис — сердце системы. Всё ниже работает на малом множестве кандидатов, которое даёт поиск по ячейкам, поэтому выбор индекса — центральное решение схемы.

Глубокое погружение

Выбор пространственного индекса

Есть три семейства, которые стоит знать, и правильный ответ зависит от паттерна доступа.

Geohash чередует биты широты и долготы в одну строку, где каждый добавленный символ уточняет локацию в меньший прямоугольник. Его большое достоинство: близкие точки обычно делят префикс, поэтому префиксное сканирование LIKE 'u4pruyd%' по обычному B-дереву находит соседей — без особого типа индекса. Большой изъян: свойство префикса верно лишь обычно: две точки в метре друг от друга могут лечь по разные стороны границы и не делить ни одного символа (классический пример — у экватора и нулевого меридиана). Ячейки geohash также неравномерны по реальной площади, потому что градусы долготы сжимаются к полюсам.

Quadtree рекурсивно делит пространство на четыре квадранта, разбивая ячейку только когда в ней больше некоторого порога точек. Это адаптируется к плотности: плотный Манхэттен получает глубокие мелкие ячейки; пустой океан — одну неглубокую. Эта адаптивность — выигрыш для перекошенных данных, но дерево — это stateful-структура в памяти, которую нужно строить, балансировать и перестраивать при сдвиге данных — больше операционного веса, чем у строкового префикса.

S2 (Google) проецирует сферу на куб и кривой Гильберта даёт каждой ячейке 64-битный ID; H3 (Uber) покрывает планету шестиугольниками. Оба чинят худшие проблемы geohash: ячейки куда ближе к равноплощадным, а ID на разных разрешениях вложены чисто. У шестиугольников H3 есть уникально полезное свойство — каждый сосед равноудалён (у шестиугольника шесть рёберных соседей, все на одном расстоянии), что делает операцию «расширить кольцо поиска» чистой. Это продакшен-выбор на масштабе; цена — зависимость от библиотеки и более крутая кривая входа.

Почему это работает

Почему шестиугольники лучше квадратов для поиска поблизости? У квадратной сетки два рода соседей — четыре по ребру и четыре по углу — и угловые в 1,41× дальше рёберных. Так что «ячейки вокруг меня» — неоднозначное, перекошенное множество. У шестиугольной сетки ровно шесть соседей, все по ребру, все на одном расстоянии до центров. Когда вы растёте наружу кольцо за кольцом (в H3 это kRing), каждое кольцо — чистый равномерный пояс, а число ячеек в кольце растёт предсказуемо (6k). Эта равномерность — причина, почему райдхейлинг и логистика, где постоянно спрашивают «что рядом и как расширить сеть», стандартизировались на шестиугольниках.

Выбери лучший вариант

Вы строите сервис поиска мест поблизости для глобального приложения со 100 млн мест. Типичный радиус поиска — 500 м–5 км в плотных городских районах. Данные мест обновляются редко (часы–дни). Требуется чистое и предсказуемое расширение соседних ячеек. Какой пространственный индекс подходит лучше всего?

Проблема границы ячейки

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

Решение — запрашивать ячейку и её соседей, а не одну ячейку. На квадратной сетке вы берёте ячейку плюс восемь окружающих (блок 3×3); в H3 — центральную ячейку плюс её kRing соседей под нужный радиус. Затем считаете точные ортодромические расстояния на объединении кандидатов и оставляете лишь тех, кто реально в радиусе. Индекс ячейки — это грубый фильтр, дёшево отбрасывающий те 99,99% мест, что нигде рядом; точная математика расстояний — тонкий фильтр над уцелевшими. Забыть разбиение «грубо-потом-точно» и доверять одной ячейке — каноническая ошибка поиска поблизости.

Граничные случаи

Тонкая версия проблемы границы второго порядка: нужное разрешение ячейки зависит от радиуса запроса. Если ячейки 1 км, а пользователь просит места в радиусе 5 км, одного кольца соседей не хватит — нужны ячейки на весь радиус, что может быть большим блоком. Наоборот, если ячейки крошечные относительно радиуса, вы достаёте их огромное число. Продакшен либо берёт разрешение под типовой радиус, либо использует переменное разрешение индекса: S2 и H3 позволяют одному запросу собрать «покрытие» — минимальный набор ячеек, возможно смешанных разрешений, накрывающий диск поиска — так выборка кандидатов масштабируется по площади поиска, а не по фиксированному размеру ячейки.

Узкие места и компромиссы

Первое узкое место — база данных, а не математика индекса, и кэширование по ячейке — рычаг. Так как нагрузка read-heavy, а локации меняются медленно, список кандидатов для данной ячейки стабилен минутами. Кэшируйте по ключу (cell_id, фильтры), и популярная центральная ячейка обслуживает тысячи поисков из памяти между обновлениями. Поэтому асимметрия чтения/записи из раздела требований — вся игра: вы платите за индекс один раз и собираете попадания в кэш вечно.

Второе — перекос плотности. Сетка фиксированного разрешения тратит усилия в океане и переполняется на Таймс-сквер, где одна ячейка может держать десять тысяч мест. Quadtree и переменное разрешение S2/H3 существуют именно для того, чтобы держать число кандидатов на запрос примерно ограниченным, где бы ни стоял пользователь.

Третье — ранжирование против расстояния. Возвращать геометрически ближайшие места — редко то, что хочет продукт; Yelp ранжирует смесью расстояния, рейтинга, популярности и рекламы. Поэтому поиск по ячейкам даёт кандидатов, а финальный порядок — шаг скоринга — и именно его вы кэшируете и тюните, отдельно от геометрии. Держите две заботы врозь: пространственный индекс отвечает «какие места правдоподобно рядом», сервис ранжирования — «в каком порядке их показать».

Викторина

Сервис поиска рядом переводит локацию пользователя в одну ячейку geohash, достаёт места в ней, ранжирует и возвращает. Пользователи сообщают, что явно близкий магазин иногда пропадает. В чём ошибка?

Викторина

Сервис поиска рядом на квадратной сетке фиксированного разрешения. Центральные ячейки переполнены десятками тысяч мест, а океанские почти пусты, и хвостовая задержка ужасна для плотных запросов. Лучший структурный фикс?

Закончи аналогию

Пространственный индекс работает как грубый фильтр, дёшево отбрасывающий почти все точки, после чего точное ортодромическое _______ считается лишь на малом множестве кандидатов, что вернули ячейки — грубо, чтобы сузить, потом точно, чтобы ранжировать.

Вспомните перед уходом
  1. 01
    Сравните geohash, quadtree и S2/H3 как индексы для поиска поблизости.
  2. 02
    Что такое проблема границы ячейки и как её решить?
  3. 03
    Почему кэширование по ячейке так эффективно, и какие ещё узкие места?
Итог

Сервис поиска поблизости — это искусство не сканировать каждую точку. Нагрузка read-heavy и медленно меняется, поэтому тяжёлый пространственный индекс строится один раз и амортизируется на миллионах запросов. Выбор индекса — центральное решение: geohash (строка из чередованных битов, дружелюбна к B-дереву, но хрупка на границах и неравномерна), quadtree (адаптируется к плотности, но это stateful-дерево) или S2/H3 (близкие к равноплощадным, чисто вложенные ячейки; шестиугольники H3 дают шесть равноудалённых соседей для чистого расширения кольца — продакшен-выбор). Каждый запрос — грубо-потом-точно: поиск по ячейке дёшево отбрасывает почти всё, затем точное ортодромическое расстояние ранжирует уцелевших. Каноническая ошибка — проблема границы ячейки: запрос лишь своей ячейки пропускает ближайшее место за линией, поэтому всегда берите окружающих соседей под радиус. Узкие места — база данных (бьётся кэшированием по ячейке, что окупает read-heavy профиль), перекос плотности (ограничивается адаптивным разрешением) и ранжирование (отдельный шаг скоринга поверх геометрии). Теперь, встретив proximity-сервис с высокой хвостовой задержкой или таинственно пропадающими близкими результатами, ты знаешь, что спросить: адаптивен ли индекс, запрашиваются ли соседние ячейки, прогрет ли кэш по ячейке?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 8 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.