Как запустить поисковый сервис, если у тебя всего три недели, а данные нужно агрегировать с десятков источников, каждый из которых работает по своим правилам? Как обойти жёсткие лимиты партнёров, которые ограничивают запросы в 500 RPM и p99 до 5 секунд, когда для быстрой загрузки первых результатов нужно минимум 1000 RPM? Как справиться с геопоиском, когда традиционные решения вроде Elasticsearch не подходят?
В 2022 году мы в 2ГИС запустили сервис бронирования Отелло, и перед нами стояла амбициозная цель — не просто создать поиск, а сделать его быстрым, надёжным и масштабируемым, чтобы занять место на рынке. Спойлер: мы справились. В этой статье расскажем, как именно.
Материал будет полезен бэкенд-разработчикам и продакт-менеджерам, которые сталкиваются с задачами интеграции сложных данных, высокой нагрузки и оптимизации поисковых алгоритмов. А если тебе понравится наш проект, рассмотри нашу вакансию — мы в поисках Senior Golang Engineer.
Меня зовут Вадим Пестрянин, я тимлид бэкенд-команды Отелло, моя основная задача — создавать и развивать сервисы поиска и бронирования. В разработке уже более 10 лет, прошёл путь от небольших проектов до highload-систем с пиками свыше 130К RPS.
Как всё начиналось
2ГИС — это всем знакомый справочник, карты и навигатор. А Отелло появился в 2022 году, когда Booking ушёл с российского рынка, и освободилась ниша. Нам нужно было быстро запустить полноценный сервис бронирования и успеть закрепиться, пока её не заняли конкуренты. Но при этом нельзя было просто «собрать что-нибудь», иначе через год бы всё аукнулось техническими долгами и проблемами с масштабированием.
Причём это был не просто новый продукт, но и новая бизнес-модель для 2ГИС. Раньше компания зарабатывала на рекламе, а теперь — ещё и на комиссиях с бронирований. Это меняло подход к разработке, потому что теперь нужно было думать не только о пользователях, но и о рентабельности системы.
Это добавляло перца в ситуацию — в 2ГИС всегда было много сильных инженеров, но вот сервисов бронирования никто раньше не делал. Так что мы заходили в эту историю практически с нуля и с ощущением, что вот-вот встретимся с кучей подводных камней.
Как устроена экосистема бронирования
Ключевое звено в бронировании — провайдеры. Это своего рода агрегаторы, куда отели загружают всю информацию: описание, тарифы, удобства, доступные номера. Если отель хочет попасть в Отелло — ему нужно подключиться через них.
Вся информация о доступности номеров на определённые даты и для определённого количества гостей отображается в виде таблицы, которую в нашей сфере называют «шахматкой». Она получила такое название, потому что выглядит как шахматная доска.

В России три больших провайдера:
TravelLine — более 12 000 объектов, в основном работает с отелями.
Bronevik — более 50 000 объектов, потому что они агрегируют много апартаментов.
Bnovo — 16 000 объектов.
Когда мы запускали Отелло, первую версию сделали буквально за две недели. Поиск тогда строился на прямых запросах в API TravelLine. Это работало, потому что у нас не было большого потока пользователей.

Какие данные отдают провайдеры?
Базовая информация об отеле (идентификаторы) — propertyIds
Даты, сколько гостей, включено ли питание — центральная секция
Цены — pricePreference
На этих данных строится поисковая выдача и сам процесс бронирования.
"propertyIds": [
"1024"
],
"adults": 1,
"arrivalDate": "2024-05-06",
"departureDate": "2024-05-07",
"mealPreference": {
"mealPlanCodes": [
"BreakFast"
]
},
"pricePreference": {
"currencyCode": "RUB",
"minPrice": 0,
"maxPrice": 10000
}
Если открыть выдачу, слева будет список отелей, которые подходят под условия поиска, а справа — карта с пинами цен, так называемыми маркерами.

Но всё-таки первую версию не получилось просто оставить. Когда мы начали масштабироваться и подключать новых провайдеров, быстро выяснилось, что их системы работают по-разному, и это создавало сразу несколько сложностей:
Разная скорость поступления данных. Информация от разных провайдеров приходит с разной задержкой, и не всегда можно быстро всё свести воедино.
Ограничения на запросы. Каждый провайдер устанавливает свои лимиты: один даёт 200 RPS, другой — всего 100. Если запрашивать данные напрямую, загрузка может быть долгой и пользователь будет просто смотреть на пустой экран. А если случайно превысить лимит запросов, можно вообще остаться без данных.
Проблемы с фильтрацией. На стороне провайдеров фильтры либо работали плохо, либо вообще отсутствовали. Например, у одного из них не было фильтра по цене, поэтому нам нужно загружать весь массив данных, а затем уже у себя сортировать.
Скорость поиска. Пользователи не готовы ждать. Если первая страница загружается дольше пары секунд, они просто уходят к конкурентам. Но мы упирались в задержки провайдеров и их лимиты, так что срочно нужно было менять архитектуру.
Стало ясно, что нужна другая архитектура, и мы выделили логику поиска в отдельный микросервис:

А теперь подробнее о том, как устроен новый сервис...
Сессии вместо кэша
Обычно проблемы скорости выдачи и ограничение по запросам решают кэшированием. Например, один из сервисов бронирования кэширует ответы провайдеров, формирует свою модель данных и уже её отдаёт пользователям. Выглядит это так.

Мы пошли другим путём — разместили кэш прямо в сервисе поиска. Так можно сразу обрабатывать сортировку и фильтрацию для всех провайдеров без лишних запросов.
Этот кэш мы назвали сессиями. Это информация о размещениях, которая считается актуальной примерно час, но остаётся до восьми. В сессии сохраняются:
JSON с предложениями и отелями — это нужно, чтобы быстро выдавать результаты и показывать объекты на карте.
Поля для индексации — они позволяют фильтровать и сортировать выдачу.
Идентификатор сессии — ключ вида geo_dates_adults_AB_platform, который помогает направлять повторяющиеся запросы в кэш и повышать кэш-хитрейт.
В базе всё выглядит так:
ID сессии.
Branch ID — идентификатор организации в 2ГИС.
JSON с загруженными и обработанными предложениями.

А так выглядят поля для сортировки и фильтрации.

Для удобства пользователей при фильтрации мы учитываем:
Звёздность отеля.
Отзывы (0–5 баллов).
Минимальная цена.
Удобства (Wi-Fi, телевизор, холодильник и т. д.).
Сессии дали нам сразу несколько плюсов:
Быстрая выдача. Теперь можно моментально отдавать результаты поиска без перегрузки провайдеров.
Снижение потребления лимитов провайдеров.
Предсказуемость: пользователь может скроллить вперёд-назад без сюрпризов в виде пропавших или внезапно дублирующихся отелей.
Хранение сессий
Для хранения сессий мы перебрали несколько вариантов. Смотрели в сторону Redis, Elastic, даже Tarantool, но в итоге остановились на PostgreSQL. Почему?
Геоиндексы.Нам нужна была поддержка геопоиска, и PostGIS в PostgreSQL отлично с этим справляется. Быстрая доступность данных. Elastic сразу отпал, так как он не real-time-хранилище, а нам критично мгновенно получать данные.
Готовая инфраструктура. В 2ГИС уже есть кластер PostgreSQL с репликацией и мониторингом, за которым следит мощная инфраструктурная команда.
По надёжности и доступности он нас полностью устраивал. Но был нюанс:внутри компании этот кластер — своеобразная коммунальная квартира, где живут и другие проекты. Мы не хотели, чтобы другие продукты влияли на нас, и сами тоже не хотели влиять на других. Поэтому вместо общего кластера выбрали Zalando Spilo — про него чуть позже.

Как это работает
Всё крутится в Kubernetes. Когда фронтенд запрашивает доступные предложения, сервис поиска запускает три горутины, каждая из которых работает с отдельной сессией. Эти горутины параллельно отправляют ещё три запроса в API бронирования, а уже они идут к провайдерам.

Почему именно три? Мы нашли баланс эмпирически: с таким количеством данные загружаются быстро, но при этом система не перегружается и не упирается в лимиты провайдеров.
Всё это работает в фоне. Пока фронтенд «пуллит» сервер, пользователь видит, как появляются предложения:
Сначала загружается первая порция данных. Затем карта дополняется новыми отелями.
И если вдруг что-то задублировалось, фронтенд сам это дедуплицирует и показывает уже чистую выдачу.
Загрузка первой страницы
Для быстрой загрузки первой страницы были два варианта:
Надеяться, что все пользователи попадут в уже прогретый кэш.
Сделать так, чтобы кэш был готов заранее.
Очевидно, что второй вариант лучше, поэтому мы пошли к продакт-менеджерам, изучили аналитику и выяснили, какие направления бронируют чаще всего. Решение напрашивалось само собой — заранее прогревать кэш для этих направлений.
Для этого мы разработали механизм, который поднимает дополнительные воркеры. Они работают в фоне и наполняют сессии нужными данными, чтобы при запросе всё загружалось мгновенно.
А какие именно данные загружать? Снова пошли к продактам и узнали, что большинство пользователей сначала смотрят выдачу по популярности, а уже потом добавляют фильтры. Это тоже упростило задачу — прогреваем кэш без фильтров, но с сортировкой по популярности.
Чтобы данные были актуальными, запустили cron-job, который регулярно обновляет кэш. Получилось примерно так:
берём список популярных направлений,
воркеры заполняют сессии в фоне,
а cron-job периодически повторяет этот процесс.
В итоге первая страница всегда загружается быстро, без ожидания и лишних запросов к провайдерам.
Сайд эффекты сессий и как с ними бороться
У сессий, как и у любой системы, есть свои сайд-эффекты.
Низкий кэш-хитрейт
Кэш-хитрейт оказался не таким высоким, как хотелось бы. Причина проста — один и тот же отель мог попасть сразу в несколько разных сессий. Ключ формировался по параметрам geo_id, датам заезда и количеству гостей. В итоге одно и то же предложение могло оказаться в разных сессиях, из-за чего приходилось обращаться к провайдерам чаще, чем планировалось.
"search_params": {
"geo_id": "1830210118877480",
"checkin": "2024-05-01T00:00:00Z",
"checkout": "2024-05-02T00:00:00Z",
"adult_count": 2,
"children": []
}
Инфраструктура поиска
Ещё один эффект — ускоренный запуск второй версии поиска. Мы хотели развернуть её так же быстро, как сам Отелло, за две недели. Поэтому решили не городить сложную инфраструктуру, а разместили воркеры, заполняющие сессии, прямо в тех же подах, где крутился сервис поиска.

Соседство воркеров и основного сервиса поиска
Это сработало, но появился неожиданный побочный эффект: если все воркеры оказывались заняты, приходилось поднимать дополнительные сервисы поиска, чтобы обработать больше сессий.
Бывали и случаи, когда какая-то пода умирала, а вместе с ней оставались бесхозные сессии. Пользователь мог наткнуться на некорректные данные, и чтобы этого избежать, мы запустили отдельный job, который отслеживал такие ситуации и чистил устаревшие сессии.
Проблема с картой
Отдельная проблема была с тем, что в первых версиях поиска выдача и маркеры хранились в одной сессии.
"address": {
"city": "Санкт-Петербург",
"distance_to_center": 2264,
"name": "2-я линия В.О., 61/30 лит А",
"point": {
"lat": 59.948903,
"lon": 30.279504
},
"distance_to_nearest_station": 280
},
Это приводило к неожиданному поведению: если пользователь двигал карту, менялся не только список маркеров, но и сама выдача. Например, человек искал отель в Адлере, но стоило подвинуть карту, и в выдаче внезапно появлялись варианты из Красной Поляны.

Это ломало пользовательские ожидания. Поэтому решили разделить маркеры и выдачу. Теперь при изменении viewport (это область видимости, которую формирует окно браузера) фронтенд запрашивает только маркеры, а не всю выдачу. Они загружаются отдельно и мержатся на клиенте, создавая эффект бесшовной загрузки.

Ограничение зума
Пользователи любят приближать и отдалять карту. Если дать им полную свободу, они могут отдалиться так, что начнёт загружаться вся Россия. Это мгновенно выбьет лимиты провайдеров, а в худшем случае вообще положит их. Чтобы этого избежать, ограничили зум, запретив загрузку слишком больших областей.
Оптимизация геопоиска
Чтобы оптимизировать геопоиск, мы разбили территорию на кластеры вокруг крупных городов. Это позволило загружать данные не по отдельным отелям, а сразу по целому кластеру.


Работает это так: раз в восемь часов обновляем информацию о доступности, контенте и описаниях отелей, а затем запускаем кластеризацию. Для этого используем PostGIS и метод K-средних.
Кластеризация реализована так:
Выбираем множество координат отелей.
Определяем количество кластеров.
Сначала центры кластеров выбираются случайно.
Считаем среднеквадратичные отклонения от центров кластеров и минимизируем их, формируя более точные кластеры.
Пересчитываем центры кластеров, находя среднюю координату всех объектов в группе.
Если на определенной итерации результаты перестают меняться, алгоритм завершает работу.
PostGIS в Zalando Spilo оказался удобным, потому что у него есть встроенная функция Cluster K-Means, позволяющая кластеризовать объекты по геометрии, количеству кластеров и максимальному радиусу. Этот радиус как раз нужен, чтобы, например, отели Владивостока случайно не попали в один кластер с Москвой.
ST_ClusterKMeans(geometry, number_of_clusters, max_radius)
Но у кластеров тоже есть свои сайд-эффекты. Например, bounding box, который определяет видимую область пользователя, не всегда полностью совпадает с границами кластеров. Фронтенд отправляет нам координаты области, backend проверяет, какие кластеры пересекаются с этим bbox, и отдаёт их список.

Это создаёт интересные кейсы. Например, во вьюпорте может быть 10-20 отелей, но технически мы загружаем сразу три кластера, а это уже 7 000 объектов. Или если поиск идёт по станции метро, то система затянет сразу все отели Москвы, потому что кластеры строятся по географическим объектам.
Избыточная кардинальность
Как продуктовая команда, мы регулярно проводим A/B-тесты, проверяя новые методы поиска и фильтрации. Но однажды чуть не положили прод из-за избыточной кардинальности.

Мы запускали новый метод оплаты, который добавился в фильтры поиска. Код A/B-теста тоже попал в сессии, так что разные предложения с разными вариантами оплаты показывались разным пользователям.
Проблема началась, когда мы параллельно интегрировали нового провайдера трафика. Он пригнал нам кучу пользователей, база начала раздуваться, сессии резко удвоились, а Postgres показал, что место неожиданно заканчивается.
Разобрались: код A/B-теста в идентификаторе создавал огромное количество новых сессий. Экстренно убрали его из ключа, сразу уменьшили объём хранимых данных, но задумались — а точно ли нам нужна такая кардинальность?
В итоге отрезали лишнее: сократили количество доступных вариантов (например, вместо множества платформ оставили просто web и mobile), уменьшили гранулярность данных. Это позволило сильно сократить нагрузку на базу и избежать проблем в будущем.
Кардинальность — это хорошо, пока не становится слишком хорошо. Теперь мы проверяем, стоит ли её усложнять, прежде чем отправить фичу в прод.
Третья версия поиска
Сейчас в Отелло работает третья версия поиска, и основное изменение здесь — архитектура. Мы полностью отказались от сессий и вместо них держим общий кэш предложений. Теперь данные больше не дублируются в разных сессиях, а хранятся централизованно:
session_id больше нет — вместо него теперь property_id (идентификатор отеля);
У каждого предложения есть offer_id (идентификатор конкретного тарифа у провайдера);
JSON теперь содержит информацию только об актуальных предложениях.

Этот кэш наполняется так же, как и раньше: при запросе к провайдерам мы забираем информацию о предложениях и загружаем её в базу. Но теперь у нас появился ещё один важный элемент — кэш объектов размещения. Он содержит метаинформацию об отелях и работает асинхронно.

Раньше одно и то же предложение могло попасть сразу в несколько сессий. Например, на картинке ниже видно, что предложения 3, 4, 5 дублируются. Теперь такого нет — все данные хранятся в одном месте, без лишних повторов.

Помимо этого, мы доработали жизненный цикл кэша: предложения теперь хранятся 30–60 минут, а их актуальность пересчитывается раз в 2–3 часа.
Улучшение UX: BBox вместо кластеров
Вместо кластеризации мы решили использовать прямоугольную область поиска (BBox), но с небольшим нюансом:
внутренний bbox — это область, которую видит пользователь;
внешний bbox (+25%) — включает немного больше отелей, чем реально отображается на экране.

Такой подход позволяет загружать данные заранее, создавая плавный UX без резких подгрузок. Когда пользователь двигает карту, данные берутся из расширенного bbox. Как только он выходит за границы мелкого Bbox, фронтенд отправляет новый запрос, загружает обновленные данные и мерджит их с уже загруженными маркерами. Это избавляет от скачков в отображении и делает процесс более естественным.
Оптимизация пагинации.После отказа от сессий нам пришлось переделать способ запроса данных, потому что старая консистентная пагинация больше не работала. Мы перешли на курсорную пагинацию, которая лучше подходит для бесконечного скролла.
Как это работает:
Фронт получает курсор на 300 предложений.
Когда пользователь доходит до конца списка, фронт запрашивает следующую порцию данных, передавая курсор.
Бэкенд возвращает новый набор предложений, начиная с места, где закончилась предыдущая загрузка.
Фронт кеширует данные и добавляет их в список.

Такой подход не перегружает систему и не ломает порядок выдачи.
Влияние на SEO
Когда мы запускали SEO, у нас была только вторая версия поиска, и главная проблема была в сессиях.
Долгая загрузка. Они не позволяли мгновенно отдавать данные ботам, потому что сначала надо было дождаться загрузки от провайдеров, а это могло занять до пяти секунд.
Проблема клиентского рендеринга. Наш фронтенд на React использует client-side rendering, а поисковики такое не любят. Им нужно, чтобы страница загружалась с уже готовым HTML, иначе они просто не увидят важные элементы.
Наше решение: подняли отдельный сервис для SEO. Он:
Хранит сессии две недели.
Подготавливает серверный рендеринг для поисковиков. То есть мы сделали прослойку, которая проверяет, кто к нам заходит. Если это поисковый бот, он получает сервер-сайд рендеринг со второй версией поиска и длинными сессиями. Если это обычный пользователь, он попадает в стандартную версию сайта с третьей версией поиска.
Создает предсказуемые URL для индексации.
Например, вот так выглядит страница с отелями Москвы: поисковик видит уже готовый контент, полученный через сервер-сайд рендеринг, и сразу индексирует его. Выглядит она так же, как обычный сайт Отелло, но загружается не через динамический поиск, а через предзагруженные данные.

Итоги
Этот кейс подсветил нам несколько ценных уроков.
Во-первых, правило 80/20 работает! Мы не стали усложнять инфраструктуру на старте — просто объединили сервисы, разместили воркеры в тех же подах, где крутился поиск, и прожили так год. За это время выдержали рост нагрузки в 20 раз. А когда стало нужно — спокойно выделили отдельные компоненты и обновили архитектуру без боли.
Во-вторых, когда нужно было быстро выпускать фичи, важно вовремя остановиться. Не всегда сложное и идеально проработанное техническое решение давало тот же результат, что и простая рабочая схема. Например, вместо сложной кластеризации мы перешли к BBox с запасом, и это дало более естественный UX. Или когда отказались от сессий в пользу централизованного кэша — выиграли и в скорости, и в консистентности данных.
И ещё: архитектура — это всегда компромисс. Нам не хотелось создавать себе проблем на будущее, но и важно было не тормозить развитие, пытаясь учесть всё сразу. Постепенно упрощали, избавлялись от дублирующихся данных, снижали кардинальность, наводили порядок в фильтрах и идентификаторах. Всё это — поэтапно, по мере необходимости, без авральных переделок.