Привет, Хабр! На связи команда «Исходного Кода».
Введение
Когда бэкенд на 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, справочники и конфиги.
Логика простая: если данные в кэше — сразу отдаем, нет — выполняем 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 локальный кэш в подах создает сплошную боль.
Дальше мы используем пулы по ситуации.
Сделали, сломали, починили: кейс с JWT-токеном
Самый показательный фейл, из которого мы вынесли кучу пользы. Сервис ходит во внешнюю систему, нужен JWT. Чтобы не запрашивать постоянно, сохраняем в кэш.
Схема в лоб:
-
Проверяем токен в кэше.
-
Есть — используем.
-
Нет — запрашиваем новый.
-
Сохраняем.
-
Работаем дальше.
Пока нагрузка небольшая и запросы идут друг за другом, все работает. Но мы пошли на нагрузочное тестирование.
Кстати, про тесты. Именно там вылезают все болячки с кэшем. Подробно про наш подход мы писали в статье про нагрузочное тестирование. Почитайте, там много полезного.
Когда токен протухал, несколько запросов одновременно получали cache miss. Каждый шел во внешний сервис за новым JWT. И тут нюанс: внешний сервис при выдаче свежего токена инвалидировал предыдущий. В итоге одновременные запросы просто перетирали токены друг друга.
Приложение начинало слать устаревший токен и ловить 401 Unauthorized.
Локально или руками этого не поймать. Ошибка стреляет только при одновременных запросах. А так как у нас Kubernetes, конкурируют не просто процессы, а целые pod’ы.
Если токен лежит локально (APCu, Memcached внутри пода), каждый под живет в своей реальности. Один обновился, другой шлет старый мусор, третий пытается запросить новый. Если кэш кластерный, состояние единое, но это не мешает подам одновременно получить пустоту из кэша и одновременно ломануться в API.
Вывод: проблема не в месте хранения, а в отсутствии синхронизации обновления.
Race condition при конкурентном обновлении JWT-токена: более старый токен может быть записан в кэш позже нового.
Лечим распределенные гонки через Symfony Lock
Кэш тут ни при чем — он просто честно хранил последнее записанное. Нам нужно было запретить одновременное выполнение куска кода. Берем Symfony Lock Component. Его задача — закрыть критическую секцию (запрос во внешний API) для других.
Почему кэш не справляется один
Общий кэш отвечает на вопрос «где хранить», а lock — «кто имеет право обновлять».
Как должна выглядеть здоровая логика:
-
Проверяем токен в кэше.
-
Есть — берем.
-
Нет — берем lock.
-
И тут инсайт: после получения лока делаем double-check (проверяем кэш еще раз). Пока мы стояли в очереди, другой процесс мог уже положить туда свежий токен! Без проверки второй процесс просто повторит гонку.
-
Все еще пусто? Запрашиваем новый.
-
Сохраняем.
-
Отпускаем lock.
Зачем нужен именно распределенный Lock
В K8s локальные блокировки (flock, in-memory) бесполезны — они лочат код только внутри одного пода. Соседний под ничего не узнает. Лок должен лежать в общем хранилище, доступном всем инстансам. Идеально подойдет Memcached-based lock.
Разделяем мухи и котлеты (где хранить)
Важно понимать разницу: где лежит токен, а где lock. Если оба в кластере — супер. Но если токен локальный (APCu), все плохо. Лок спасет API от спама, но локальные кэши подов останутся рассинхронизированными.
Поэтому общее состояние (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/