📱 Подписаться
IT и цифровая трансформация

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

📰 Habr 👁️ 3 просмотров

codesrc1 час назад

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

Уровень сложностиСреднийВремя на прочтение6 минОхват и читатели2.6KSymfony*PHP*Высоконагруженные системы*КейсПривет, Хабр! На связи команда «Исходного Кода».

Введение

Когда бэкенд на 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-функцией. Ссылка на код.Логика простая: если данные в кэше - сразу отдаем, нет - выполняем 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 конфиг). Ссылка на кодДальше мы используем пулы по ситуации.

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

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

Схема в лоб:

• Проверяем токен в кэше.
• Есть - используем.
• Нет - запрашиваем новый.
• Сохраняем.
• Работаем дальше.Пример реализации метода getToken в коде. Ссылка на код.Пока нагрузка небольшая и запросы идут друг за другом, все работает. Но мы пошли на нагрузочное тестирование.

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

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

Упрощенный сценарий конфликта Request A и Request BПриложение начинало слать устаревший токен и ловить 401 Unauthorized.

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

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

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

Диаграмма взаимодействия Request A, Request B, Cache и Auth ServiceRace condition при конкурентном обновлении JWT-токена: более старый токен может быть записан в кэш позже нового.

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

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

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

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

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

• Проверяем токен в кэше.
• Есть - берем.
• Нет - берем lock.
• И тут инсайт: после получения лока делаем double-check (проверяем кэш еще раз). Пока мы стояли в очереди, другой процесс мог уже положить туда свежий токен! Без проверки второй процесс просто повторит гонку.
• Все еще пусто? Запрашиваем новый.
• Сохраняем.
• Отпускаем lock.Пример реализации ExternalServiceTokenProvider с LockFactory и double-check. Ссылка на код

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

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

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

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

Под А хранит 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.Теги:• Symfony Lock
• Symfony Cache
• JWT кэширование
• race condition
• distributed lock
• Memcached
• 401 Unauthorized
• double-checkХабы:• Symfony
• PHP
• Высоконагруженные системы

Получайте больше инсайтов о систематизации бизнеса

Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности

💬 Подписаться на канал