Добавить в корзинуПозвонить
Найти в Дзене
Исходный Код | IT

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

Привет, Хабр! На связи команда «Исходного Кода». Когда бэкенд на Symfony начинает тормозить, одним из первых инструментов оптимизации почти всегда становится кэширование. И это логично: правильно настроенный кэш круто снижает нагрузку на базу данных, режет latency API и убирает лишние ресурсоемкие операции внутри приложения. Но на практике мы редко ограничиваемся простым cache->get() и базовым TTL, особенно когда приложение крутится не на одном сервере, а в Kubernetes-кластере с пачкой внешних API и жесткой конкуренцией запросов. В таких условиях кэш - это уже не только про скорость, но и про синхронизацию состояния между процессами и pod'ами. А где синхронизация, там появляются: В этой статье делимся инсайтами из проектов Исходного Кода. Разберем практический опыт: Отдельно обсудим: В Symfony кэширование - это сразу несколько уровней оптимизации на разных этапах обработки запроса. В реальных проектах эти уровни работают вместе: Не будем душнить теорией, просто коротко пробежимся по ба
Оглавление

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

Введение

Когда бэкенд на 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.