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 — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности
💬 Подписаться на канал→ Оригинальная статья