Как реализовать кэширование запросов к внешним сервисам
Что проверяют
Понимает ли кандидат кеш как компромисс, а не как «добавил аннотацию и стало быстро». Ждут разговора про ключ, TTL, инвалидацию, поведение при промахе и при падении внешнего сервиса.
Как отвечать
Базовая схема это cache-aside: посмотреть в кеш, при промахе сходить во внешний сервис, положить результат. В Spring это делается абстракцией @Cacheable, а провайдер подключается отдельно: Caffeine для локального кеша, Redis, когда инстансов много и данные дорого получать заново.
@Cacheable(cacheNames = "rates", key = "#from + ':' + #to", unless = "#result == null")
public Rate fetchRate(String from, String to) {
return restClient.get().uri("/rates/{from}/{to}", from, to).retrieve().body(Rate.class);
}
@CacheEvict(cacheNames = "rates", key = "#from + ':' + #to")
public void invalidate(String from, String to) { }Что действительно спрашивают дальше:
- Ключ должен включать всё, что влияет на ответ: параметры, локаль, версию API, идентификатор пользователя для персональных данных. Ключ только по URL опасен, если ответ зависит от заголовка.
- TTL обязателен, и здесь главный подвох: сама абстракция Spring Cache сроков жизни не знает. TTL настраивается на уровне провайдера (
expireAfterWriteу Caffeine,entryTtlвRedisCacheConfiguration). С дефолтнымConcurrentMapCacheManagerзапись живёт вечно и растёт без ограничений. - Размер и вытеснение:
maximumSizeу Caffeine, иначе локальный кеш выедает heap. В Redis отдельно настраивается сериализация, лучше JSON: при JDK-сериализации изменение класса ломает чтение старых записей. - Cache stampede: на холодном ключе сто параллельных запросов уйдут во внешний сервис одновременно. У Caffeine это лечит
sync = true, в распределённом случае распределённая блокировка или упреждающее обновление до истечения TTL. - Не кешировать ошибки и пустые ответы обычным TTL. Либо
unless, либо отдельный негативный кеш с коротким сроком, чтобы не долбить упавший сервис и не запомнить пустоту на час. - Самовызов: вызов кешируемого метода изнутри того же бина идёт мимо прокси, и кеш просто не срабатывает. Метод выносится в отдельный бин.
- Инвалидация:
@CacheEvict, версия в имени ключа, вебхуки поставщика, если он их даёт. Полезно уважатьCache-ControlиETagвнешнего API: условный запрос дешевле полного. - Метрики hit/miss и размер. Без них нельзя сказать, помогает кеш или просто занимает память.
Где спотыкаются
Вешают @Cacheable, не задают ни TTL, ни предел размера, и потом отдают устаревшие данные неопределённо долго. Второе: зовут кешируемый метод из соседнего метода того же класса и не понимают, почему промах происходит каждый раз.