- PVSM.RU - https://www.pvsm.ru -
Всем привет! Хочу рассказать, как мы небольшой командой проектировали кэш поиска отелей для сервиса по бронированию отелей и почему за полтора года прошли путь от Infinispan к managed Redis а затем к Postgres. По стеку java-21, spring-boot-3, 1 вендор отелей, расчетная нагрузка 1000 RPS и 10M запросов в сутки.
Пользовательская нагрузка сейчас средняя, мы проектируем систему под расчетную. История о том, почему managed Redis нам не подошел и как мы в итоге положили кэш в Postgres, а не о том, как выживали под миллионом запросов.
Пользователь приходит с запросом «Москва, 2 гостя, даты такие-то», мы идем к вендору, получаем ответ с сотнями отелей, отдаем обратно. Средний ответ 8-10 мб, на крупных городах доходит до 500 мб. Ходить за этим к вендору на каждый поиск долго и дорого.
Нужен кэш со следующими свойствами:
кэшировать все пользовательские запросы. Ключ — слепок фильтров;
прогреваться популярными запросами заранее, чтобы 80%+ поисков попадали в горячие данные и отдавались пользователю быстро;
TTL для устаревших записей;
метрики хитов и миссов через Prometheus + Micrometer
TTL мы сразу поставили неделю. Это бизнес-решение, не техническое — устаревшая цена в рамках недели оказалась меньшим злом, чем промах и заставлять ждать пользователя, пока мы ходим к вендору
Первая версия была максимально простая: Infinispan в одной ноде в докере, Spring Boot приложение, сериализация через protostream. Кэш пишет все подряд: слепок фильтров запроса как ключ, JSON ответа как значение.
Почему Infinispan, а не Redis? Redis тогда (в 2024) ушел из оперсорс, и было непонятно чем это кончится, а Infinispan Java-native и со Spring дружит из коробки. Для прототипа норм.
Кстати, про сериализацию. Там был не совсем protobuf, как может показаться. По факту хранился JSON, который Infinispan оборачивал в protostream для совместимости.
Прогрев сделали простой: отдельный сервис по крону в 5 утра запускает список заданий и проходит их по порядку. Полный прогон занимает ~7 часов. Приоритизации городов нет, просто берем список популярных и прогреваем.
С Infinispan в одной ноде в прод идти не хочется: в случае проблем может начать съедать память на диске и не чистить ее по TTL. Для прототипа на пре-проде это не беда, но каждую неделю разбираться с Infinispan, который забил диск никто не хочет.
Решили, что хочется managed. Тогда мы еще не знали, что именно это решение определит всю дальнейшую архитектуру.
Перед миграцией мы сделали одну важную вещь — вынесли всю работу с кэшем за интерфейс. Условно так:
public interface RemoteCacheClient<K, V> {
Optional get(K key);
void put(K key, V value);
void remove(K key);
void clear();
void forEachEntry(int batchSize, BiConsumer<K, V> consumer);
}
Контроллерам и сервисам стало все равно, что под капотом — Infinispan, Redis, Postgres. Казалось, избыточная абстракция, зачем нам подменять реализацию, мы же в Redis идем и там остаемся. Через полгода этот шаг выиграл нам недели, об этом дальше.
В процессе заметили, что поиск в рублях и поиск в долларах с теми же фильтрами — это два разных запроса к Броневику и два разных ключа в кэше. Хотя по сути это одни и те же отели, просто цену надо пересчитать.
Сделали конвертацию на нашей стороне, курс обновляем раз в час. На абсолютный трафик это повлияло слабо (запросов в usd/eur у нас немного), но шанс попадания в кэш вырос — один ключ теперь закрывает все валюты.
Взяли managed Redis на 64 GB, 1 ноду. Для кэша с данными, которые восстанавливаются из вендора, 2 нода нам не нужна, просто экономия. Загрузка памяти в стабильном состоянии держалась близко к максимальной, 10-15к ключей.
Казалось, все хорошо: прогрев работает, хитрейт целевой.
А потом объем данных продолжил расти.
Смотрим на цену следующей ступени по памяти — примерно ×2 дороже. Неприятно, но ради быстрого поиска можно пережить. Потом смотрим на потолок managed Redis у провайдера — 98 GB.
То есть даже если заплатить, мы отодвигаем ту же стену на несколько месяцев. Redis OSS не умеет выгружать холодные ключи на диск. Managed Redis у провайдера — это и есть OSS. Redis Enterprise с tiered storage у РФ-провайдеров не предлагается.
Рассматривали альтернативы: Dragonfly, KeyDB, S3+локальный кэш. Не подошли либо по цене, либо по отсутствию managed решения. А возвращаться к селфхосту после того, как только что от него уехали, совсем не хотелось.
Логика такая — у Postgres диск из коробки, managed-версии есть у всех провайдеров, JSONB хорошо жмет повторяющиеся структуры через TOAST. А solution-agnostic слой, который мы сделали на прошлом этапе, позволяет подменить реализацию, не трогая ни один сервис. Вот она, награда за избыточную абстракцию.
Взяли managed Postgres — 4 vCPU / 8 GB RAM / 1 TB SSD, одну ноду для оптимизации затрат. Hikari с дефолтными 10 соединениями, обычный JDBC без R2DBC и jOOQ, проще и быстрее в реализации.
CREATE TABLE search_cache (
cache_key JSONB PRIMARY KEY,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_search_cache_expires_at ON search_cache (expires_at);
Ключ — канонизированный слепок фильтров в JSONB. Значение — весь ответ в JSONB. Десериализация в приложении через Jackson, не на стороне Postgres.
Почему ключ JSONB, а не хэш в TEXT. В кэше живут не только поисковые запросы, но и бронирования, и закэшированные цены с промокодами. У каждого типа данных своя структура ключа. JSONB позволяет не придумывать отдельный формат под каждый случай. Да, хэш в TEXT был бы производительнее как PK, но гибкость нам важнее.
Почему JSONB, а не bytea + protobuf, несколько причин
1) отладка — можно зайти в базу и прочитать значение глазами.
2) если вдруг понадобится доставать отдельные поля, есть jsonb_*-операторы — можно не тащить всю запись в приложение.
3) JSONB терпимо относится к добавлению полей, версионирование проще.
Они обновляются независимо от основного ответа. Если держать их внутри JSONB-пейлода, каждое обновление рейтинга инвалидирует весь многомегабайтный ключ. Выносим в отдельную таблицу и обновляем точечно.
Структура таблиц такая же, но JOIN в Postgres мы не делаем. В приложении отдельно достаем нужные данные и обогащаем основной ответ. Так проще менять логику обогащения, не трогая схему БД, и нет риска уронить базу тяжелым join-ом по многомегабайтным JSONB.
В Redis TTL из коробки, в Postgres его нет. Пришлось делать самим.
Проходимся воркером на стороне приложения с запросом вида DELETE FROM search_cache WHERE expires_at < now(). Триггеры на стороне БД рассматривали, но это не самое удобное для дальнейшей поддержки и изменений решение — проще держать логику TTL там же, где вся остальная бизнес-логика кэша.
Некоторые ответы в кэше реально огромные, до 500 мб и больше. Это крайние кейсы (большие города в сезон, дальние даты, у отелей куча фото), но они есть, и с ними надо что-то делать. Условно, поиск по Москве [1] возвращает 3 тысячи отелей, у каждого куча фото и описаний. Именно такие пейлоды мы и кэшируем.
Такой payload — это один цельный ответ на один поиск, а не агрегат. При прогреве мы сохраняем такие пейлоды для всех популярных запросов, и потом отдаем их как есть.
Обычный подход «прочитать ResultSet в строку, скормить в ObjectMapper» на таких объемах дает либо OOM, либо GC-паузы по несколько секунд. Решение: стримить напрямую из JDBC в ObjectMapper, минуя промежуточный String.
String sql = String.format( "SELECT payload FROM %s WHERE cache_key = ?", tableName);try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setFetchSize(1); ps.setObject(1, key); try (ResultSet rs = ps.executeQuery()) { if (rs.next [2]()) { try (InputStream is = rs.getBinaryStream("payload"); JsonParser parser = jsonFactory.createParser(is)){ return objectMapper.readValue(parser, SearchResponse.class); } } }}
Имя таблицы подставляется динамически. У нас несколько таблиц одинаковой структуры (основной кэш, рейтинги, фото), и воркеры ходят в них по одной и той же логике.
Ключевая настройка — autocommit=false + ненулевой fetchSize, иначе pgjdbc материализует весь ответ в памяти еще до того, как вы откроете InputStream. Вылезает это только на больших строках и дебажится ужасно ;)
На запись симметрично через setBinaryStream.
|
|
Infinispan |
Redis 64 GB |
Postgres 1 TB |
|
Managed |
нет |
да |
да |
|
Потолок по объему |
— |
98 GB у провайдера |
1 TB диска, расширяемо |
|
Где холодные данные |
RAM |
RAM |
диск |
|
Ключей влезало |
10–15 тыс. |
10–15 тыс. |
до сотен тыс. |
|
Стоимость следующей ступени |
— |
×2 |
линейно по диску |
|
TTL |
из коробки |
из коробки |
вручную |
|
Отладка |
клиент |
CLI |
|
Главный вывод: для кэша с прогреваемым горячим ядром и длинным холодным хвостом Postgres оказался адекватным выбором, когда managed Redis у провайдера уперся в потолок памяти.
Пока есть куда расти внутри этой схемы, 1 TB не заполнен. Обсуждаем двухуровневый кэш с локальным Caffeine перед Postgres, аналитику реальных запросов для прогрева (сейчас прогреваем фиксированный список), возможно шардинг, если упремся в один инстанс по записи. Но все это когда появится настоящий трафик, а пока строим под расчетную нагрузку.
Выводы базовые: если проектируете кэш на рост, не привязывайтесь к одной реализации сразу, вынесите ее за интерфейс, даже если кажется избыточным. Проверяйте потолки managed-решений у своего провайдера заранее, а не в момент, когда кэш уже распух. И Postgres в роли кэша — не дикость, особенно если managed Redis у вас кончился, а SLA страшно.
Наверное для тех, кто каждый день решает задачи такого класса, мой рассказ база ;-) Но если кому-то пригодится, буду рад.
Автор: vadimbydanov
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/450896
Ссылки в тексте:
[1] поиск по Москве: https://moyabron.ru/oteli-v-moskve
[2] rs.next: http://rs.next
[3] Источник: https://habr.com/ru/articles/1030874/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1030874
Нажмите здесь для печати.