Real-Time Backend
Search в real-time
Пользователь набрал три буквы - система уже знает, что он ищет, и показывает результаты. За этим «волшебством» стоит инвертированный поиск, in-memory структуры и хирургически точный refresh. Разбираемся, как это работает в продакшне.
- **Twitter Search (2010-н.в.):** перколяция 500 млн твитов/день через 2 млн сохранённых запросов за 50-150 мс - так работают «saved searches» и real-time уведомления о трендах
- **Google Search typeahead:** FST-структуры в RAM на 1 000+ серверах обеспечивают completion за 15-25 мс для 8.5 млрд запросов в день - задержка сети до ДЦ больше, чем время обработки в ES
- **Cloudflare Logs:** ES-кластер из 200 нод индексирует 50 TB access-логов в сутки с refresh каждые 2 с - инженеры Cloudflare задокументировали, что `refresh=true` при их throughput приводит к merge storm за 4 минуты
- **Shopify product search:** сокращение payload live search с 8 KB до 1.2 KB через `_source` filtering снизило p99 на мобильных с 380 мс до 95 мс - без изменения кластера
Percolate
Обычный поиск работает так: есть документы, приходит запрос - Elasticsearch находит совпадения. **Percolate переворачивает эту логику**: запросы хранятся в индексе, а каждый новый документ проверяется против них. Это «поиск наоборот» - не «найди документы по запросу», а «найди запросы, которые совпали бы с документом».
Twitter использует percolate для push-уведомлений. Пользователь сохранил поиск «earthquake -japan». Каждый новый твит прогоняется через миллионы таких сохранённых запросов. За 50-150 мс Elasticsearch находит всех подписчиков, чьи фильтры совпали, - и сервер уведомлений отправляет push. Без percolate пришлось бы поллить каждый сохранённый поиск раз в N секунд - это O(queries) запросов в секунду вместо O(1) per document.
Percolate-индекс - это обычный ES-индекс с полем `query` типа `percolator`. Хранить можно любой Lucene-запрос: match, bool, geo_distance, range. Один документ прогоняется через все зарегистрированные запросы за один Lucene-проход - это намного эффективнее, чем N отдельных поисков.
Реальные masштабы: Bloomberg хранит ~2 млн percolate-запросов для price alerts. Latency одного prcolate-вызова - 20-80 мс при 500K активных запросов на shard. При этом throughput - до 5 000 документов/с на кластер из 6 нод. Главное ограничение - память: каждый запрос компилируется в Lucene-объект и кешируется в heap JVM.
Чем percolate принципиально отличается от обычного поиска в Elasticsearch?
Live Search
Live search - это когда результаты обновляются прямо по ходу ввода, без нажатия Enter. Но за этим бесшовным UX скрывается целая цепочка компромиссов: каждый символ генерирует запрос, а 10 млн одновременных пользователей Google генерируют десятки миллиардов запросов в сутки только на «живой» поиск.
Ключевая проблема - **debounce vs latency**. Без debounce запрос летит на каждый keydown: пользователь набирает «react hooks» за 1.5 секунды - это 10 символов = 10 HTTP-запросов. С debounce в 300 мс - 2-3 запроса. Но debounce добавляет задержку: пользователь остановился, ждёт 300 мс - и только потом видит результаты. Netflix экспериментально выяснил, что оптимум для их поиска фильмов - 150 мс debounce с отменой предыдущего запроса (AbortController).
Для live search важно отдавать **минимальный payload**: только id, title и highlight-фрагмент. Shopify измерили - сокращение ответа с 8 KB до 1.2 KB на запрос снизило p99 latency live search с 380 мс до 95 мс на мобильных сетях. `_source` filtering в ES позволяет вернуть только нужные поля без десериализации полного документа.
**Кеширование live search** - нетривиальная задача. Запросы почти уникальны: «rea», «reac», «react» - три разных ключа. Airbnb решает это через request coalescing в Redis: если за 50 мс пришло 200 запросов с q="react" - выполняется один, остальные ждут и получают тот же ответ. Попадание в кеш - 60-70% для популярных prefixes, экономия ~40% нагрузки на ES.
Почему debounce в live search - это всегда компромисс?
Typeahead
Typeahead - это completion: пользователь набрал «pytho» - видит «Python», «Python 3.11», «Python tutorial». В отличие от live search, typeahead возвращает не документы, а **предсказанные завершения запроса**. Это разные индексы, разные алгоритмы, разные trade-offs.
Elasticsearch предоставляет специальный тип поля `completion` с оптимизированной структурой данных - **FST (Finite State Transducer)**. FST хранится в памяти (не на диске) и позволяет за O(prefix_length) найти все варианты дополнения. Google Search обрабатывает typeahead-запросы за 15-25 мс - это возможно именно потому, что FST-структура целиком в RAM.
Веса (weight) в completion-индексе - критичны для качества. GitHub строит веса из комбинации: 60% - частота поиска за 7 дней, 30% - клики на результат, 10% - звёзды репозитория. Это даёт «react» с весом 95 000 выше, чем «react-native» с весом 41 000, но ниже чем «reactjs» c 99 000 - точное отражение реального спроса.
**Context suggester** - расширение completion для персонализации. Можно добавить контексты: категория, язык, гео. Запрос «bank» для пользователя с контекстом `{category: "finance"}` вернёт «банковский перевод», «банк открытие» - а не «bank robbery tutorial». Spotify использует context suggester с контекстом `{genre, mood, recent_artists}` - typeahead адаптируется к текущему «настроению» плейлиста.
Почему для typeahead используют отдельный тип поля `completion`, а не обычный full-text search?
Real-Time Индексирование
Elasticsearch по умолчанию делает refresh каждые **1 секунду** - именно тогда новый документ становится видимым для поиска. Это называется «near real-time» (NRT). Документ написан в Lucene-сегмент в памяти - он доступен для поиска только после того, как сегмент попадает в память reader'а, то есть после refresh.
Для систем, где 1 секунда слишком долго (биржа, чат, сенсоры), можно вызвать `POST /index/_refresh` явно - но это дорого. Twitter при запуске real-time search в 2010 году установил refresh interval в 200 мс для индекса твитов - это привело к 5x росту нагрузки на диск и CPU. Решением стало **selective refresh**: индекс твитов от verified-аккаунтов обновляется каждые 200 мс, от обычных - каждую секунду.
**`refresh=wait_for` vs `refresh=true`**: `wait_for` ждёт ближайшего scheduled refresh (до 1 с) - не создаёт лишних сегментов. `refresh=true` форсирует немедленный refresh - создаёт новый сегмент на каждый запрос, что при высоком throughput приводит к segment explosion и деградации поиска. Cloudflare зафиксировали: при 10K write/s с `refresh=true` кластер уходил в merge storm через 3-4 минуты.
Для максимально свежих данных рядом с ES часто ставят **in-memory слой**: Redis Sorted Set или Apache Ignite хранят последние N документов, поиск идёт сначала туда (latency 1-2 мс), потом в ES (50-100 мс), результаты мержатся. Так работает поиск по последним твитам в Twitter: ES отвечает за «архив» старше 30 секунд, Redis Cluster - за свежайшие события. Это позволяет показывать твиты возрастом 2-5 секунд без агрессивного refresh в ES.
Если поставить `refresh_interval: "100ms"`, поиск будет практически мгновенным без каких-либо компромиссов
Агрессивный refresh interval кратно увеличивает нагрузку на CPU и диск: каждый refresh создаёт новый Lucene-сегмент, фоновый merge не успевает, кластер входит в merge storm
Lucene устроен так, что каждый refresh = новый immutable сегмент. Чем чаще refresh - тем больше мелких сегментов, тем медленнее поиск (нужно обойти все сегменты) и тем агрессивнее merge. Twitter при 200 мс refresh получил 5x рост нагрузки. Решение - гибридный подход: Redis для свежих данных + ES для архива
Что такое «near real-time» (NRT) в Elasticsearch и откуда берётся задержка?
Итоги
- **Percolate = инвертированный поиск**: запросы хранятся в индексе, новые документы матчатся против них - один проход вместо N поллингов. Основа для price alerts, breaking news, content moderation
- **Live search требует debounce + min payload**: оптимум debounce - 150-300 мс в зависимости от use case; `_source` filtering сокращает payload в 5-7x и критично для мобильных сетей
- **Typeahead = completion field + FST в RAM**: `type: completion` хранит Finite State Transducer в памяти, отвечает за O(prefix_length) - в 10-50x быстрее prefix query по обычному full-text полю
- **NRT-задержка = refresh interval**: по умолчанию 1 с, агрессивный refresh (< 500 мс) при высоком throughput вызывает merge storm. Гибрид Redis (свежее) + ES (архив) решает проблему без компромиссов по нагрузке
Связанные темы
Real-time search пересекается с несколькими ключевыми темами бекенда:
- Redis Sorted Sets — Хранение свежайших документов поверх ES - Redis отвечает за данные возрастом 0-30 с, снимая нагрузку с refresh
- Event Streaming (Kafka) — Kafka Connect ES Sink Connector - стандартный путь доставки событий в ES-индекс для real-time индексирования без ручного bulk API
- WebSocket и SSE — Live search результаты можно стримить через SSE по мере появления hits - особенно полезно при slow ES queries и federated search
Вопросы для размышления
- Если бы нужно было добавить percolate-уведомления в существующую систему с 5 млн пользователей и их сохранёнными поисками - с чего начать оценку нагрузки на ES-кластер?
- Как изменится стратегия refresh interval и кеширования, если 80% поисковых запросов приходит в течение 2 часов пика (например, новостной сайт во время Breaking News)?
- Completion field хранит FST в heap JVM - что произойдёт, если в индексе 50 млн уникальных suggestions, и как это повлияет на выбор архитектуры typeahead?