Кэширование в Symfony: как мы сломали авторизацию и починили ее через Lock

от автора

Привет, Хабр! На связи команда «Исходного Кода».

Введение

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

Но на практике мы редко ограничиваемся простым cache->get() и базовым TTL, особенно когда приложение крутится не на одном сервере, а в Kubernetes-кластере с пачкой внешних API и жесткой конкуренцией запросов. В таких условиях кэш — это уже не только про скорость, но и про синхронизацию состояния между процессами и pod’ами.

А где синхронизация, там появляются:

  • race condition;

  • проблемы конкурентного обновления данных;

  • рассинхронизация локального состояния между инстансами приложения.

В этой статье делимся инсайтами из проектов Исходного Кода. Разберем практический опыт:

  • какие виды кэширования мы реально используем;

  • какие данные действительно имеет смысл кэшировать;

  • и как кэширование JWT-токена устроило нам массовые 401 Unauthorized во время нагрузочного тестирования.

Отдельно обсудим:

  • почему локальный кэш в Kubernetes — это не shared state;

  • когда общий Memcached не решает проблему;

  • и как Symfony Lock спасает от distributed race condition.

База: какие виды кэширования есть в Symfony

В Symfony кэширование — это сразу несколько уровней оптимизации на разных этапах обработки запроса. В реальных проектах эти уровни работают вместе:

  • HTTP Cache рубит количество запросов до приложения;

  • application cache разгружает бизнес-логику и внешние сервисы;

  • Doctrine cache оптимизирует работу ORM;

  • OPcache ускоряет выполнение самого PHP-кода.

Не будем душнить теорией, просто коротко пробежимся по базе.

HTTP Cache

Первый рубеж. Задача — вообще не дергать приложение, если ответ не изменился. Symfony из коробки поддерживает Cache-Control, ETag и Last-Modified, а также умеет работать с reverse proxy и CDN. Идеально для публичных страниц и API с редко меняющимися данными. На хайлоаде это дает самый заметный прирост — запрос обрабатывается без участия PHP и БД.

Application Cache

Именно с ним мы, бэкенд-разработчики, работаем чаще всего. В Symfony за это отвечает Cache Component, дающий единый API для Memcached, Redis, APCu, filesystem cache и других адаптеров. Сюда мы складываем тяжелые SQL-запросы, ответы внешних API, справочники и конфиги.

Простейший пример работы с $cache->get и callback-функцией. Ссылка на код.

Простейший пример работы с $cache->get и callback-функцией. Ссылка на код.

Логика простая: если данные в кэше — сразу отдаем, нет — выполняем callback и сохраняем. Но именно вокруг application cache в distributed-инфраструктуре начинаются веселые проблемы: race condition, stale cache, конкурентное обновление и рассинхрон состояния между инстансами.

Doctrine Cache

ORM от Doctrine активно использует кэширование внутри себя. Кэшируются metadata сущностей, парсинг DQL и служебная инфа, чтобы не делать лишнюю работу на каждом запросе. Обычно это работает автоматически и не требует сложной настройки.

Twig Cache и OPcache

Эти оптимизации работают практически «из коробки». Twig компилирует шаблоны в PHP, а OPcache хранит готовый bytecode в памяти. PHP не нужно заново парсить файлы. Сегодня продакшн без OPcache представить уже сложно — это базовая часть runtime.

Наша практика: что и как мы кэшируем

В проектах Исходного Кода упор сделан на application cache. Остальное тоже работает, но основная масса прикладных задач и проблем возникает именно здесь.

Наши интеграционные системы постоянно общаются с внешними сервисами. Чтобы не положить чужие API, кэширование режет лишние запросы. Мы кэшируем то, что:

  • редко изменяется;

  • запрашивается большим количеством процессов;

  • требует обращения во внешний сервис.

Например:

  • списки сущностей;

  • справочные данные;

  • различные маппинги;

  • конфигурационные данные;

  • технические metadata.

Итогом: снизили нагрузку на внешние системы и урезали latency внутри приложения. Вместо HTTP-запросов переиспользуем данные из кэша.

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

В инфраструктуре мы использовали два типа Memcached: локальный и кластерный. Выбор зависел от объема данных. Большие объемы летели в локальный Memcached, чтобы убрать сетевой overhead и не грузить общий кэш. Мелкие данные — в кластерный.

Но со временем подход изменился. Оказалось, что в Kubernetes локальный кэш в подах создает сплошную боль.

Базовая конфигурация cache pools в Symfony (yaml конфиг). Ссылка на код

Базовая конфигурация cache pools в Symfony (yaml конфиг). Ссылка на код

Дальше мы используем пулы по ситуации.

Сделали, сломали, починили: кейс с JWT-токеном

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

Схема в лоб:

  1. Проверяем токен в кэше.

  2. Есть — используем.

  3. Нет — запрашиваем новый.

  4. Сохраняем.

  5. Работаем дальше.

Пример реализации метода getToken в коде. Ссылка на код.

Пример реализации метода getToken в коде. Ссылка на код.

Пока нагрузка небольшая и запросы идут друг за другом, все работает. Но мы пошли на нагрузочное тестирование.

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

Когда токен протухал, несколько запросов одновременно получали cache miss. Каждый шел во внешний сервис за новым JWT. И тут нюанс: внешний сервис при выдаче свежего токена инвалидировал предыдущий. В итоге одновременные запросы просто перетирали токены друг друга.

Упрощенный сценарий конфликта Request A и Request B

Упрощенный сценарий конфликта Request A и Request B

Приложение начинало слать устаревший токен и ловить 401 Unauthorized.

Локально или руками этого не поймать. Ошибка стреляет только при одновременных запросах. А так как у нас Kubernetes, конкурируют не просто процессы, а целые pod’ы.

Если токен лежит локально (APCu, Memcached внутри пода), каждый под живет в своей реальности. Один обновился, другой шлет старый мусор, третий пытается запросить новый. Если кэш кластерный, состояние единое, но это не мешает подам одновременно получить пустоту из кэша и одновременно ломануться в API.

Вывод: проблема не в месте хранения, а в отсутствии синхронизации обновления.

Диаграмма взаимодействия Request A, Request B, Cache и Auth Service

Диаграмма взаимодействия Request A, Request B, Cache и Auth Service

Race condition при конкурентном обновлении JWT-токена: более старый токен может быть записан в кэш позже нового.

Лечим распределенные гонки через Symfony Lock

Кэш тут ни при чем — он просто честно хранил последнее записанное. Нам нужно было запретить одновременное выполнение куска кода. Берем Symfony Lock Component. Его задача — закрыть критическую секцию (запрос во внешний API) для других.

Почему кэш не справляется один

Общий кэш отвечает на вопрос «где хранить», а lock — «кто имеет право обновлять».

Как должна выглядеть здоровая логика:

  1. Проверяем токен в кэше.

  2. Есть — берем.

  3. Нет — берем lock.

  4. И тут инсайт: после получения лока делаем double-check (проверяем кэш еще раз). Пока мы стояли в очереди, другой процесс мог уже положить туда свежий токен! Без проверки второй процесс просто повторит гонку.

  5. Все еще пусто? Запрашиваем новый.

  6. Сохраняем.

  7. Отпускаем lock.

Пример реализации ExternalServiceTokenProvider с LockFactory и double-check. Ссылка на код

Пример реализации ExternalServiceTokenProvider с LockFactory и double-check. Ссылка на код

Зачем нужен именно распределенный Lock

В K8s локальные блокировки (flock, in-memory) бесполезны — они лочат код только внутри одного пода. Соседний под ничего не узнает. Лок должен лежать в общем хранилище, доступном всем инстансам. Идеально подойдет Memcached-based lock.

Разделяем мухи и котлеты (где хранить)

Важно понимать разницу: где лежит токен, а где lock. Если оба в кластере — супер. Но если токен локальный (APCu), все плохо. Лок спасет API от спама, но локальные кэши подов останутся рассинхронизированными.

Под А хранит token_v2, Под Б хранит token_v1, Под В вообще пустой. Ссылка на код

Под А хранит token_v2, Под Б хранит token_v1, Под В вообще пустой. Ссылка на код

Поэтому общее состояние (JWT) всегда должно лежать в shared cache.

Пара подводных камней

  • TTL лока. Должен быть разумным. Завис процесс — лок не должен висеть вечно. Слишком короткий TTL — лок слетит раньше ответа от API, и гонка повторится.

  • Запас токена. Ставьте TTL в кэше меньше реального. Дают на 5 минут? кэшируйте на 4:30. Иначе словите ошибки на пограничных значениях.

  • Логируйте все. Кто взял лок, время запроса, факты refresh. Без метрик расследовать проблемы на проде нереально.

Выводы: что мы забрали в свои проекты

  • Кэшируйте то, что реально решает задачу. Уменьшайте latency и походы наружу. Справочники, маппинги, конфиги, ответы API. Редко меняется — в кэш.

  • Всегда делайте double-check. Взяли lock — проверьте кэш еще раз.

  • Пишите логи refresh flow. Observability крайне важна. Логируйте miss’ы, ошибки и время лока.

  • Кэш — не волшебная таблетка. Он не исправит N+1 запросы, отсутствие индексов и плохую архитектуру. Если тормозит без кэша, возможно, нужно чинить саму систему.

Кстати, вся эта история с JWT-токеном вскрылась именно на нагрузочном тестировании (см. нашу статью). Мы недавно как раз писали о том, как его проводим, какие грабли собираем и к какому чек-листу пришли. Там тоже есть про эту «гонку за токеном», но уже с высоты птичьего полета. Это будет отличное дополнение к теме.

Автор статьи: Данил Мануйлов, тимлид команды Web-разработки в «Исходном коде» @ideal1sm.

ссылка на оригинал статьи https://habr.com/ru/articles/1040522/