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

Введение

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

Комментарии (2)


  1. strokoff
    28.05.2026 11:19

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


    1. codesrc Автор
      28.05.2026 11:19

      Спасибо большое за комментарий!

      По поводу механизма лока и куба. На уровне конфигурации компонента Symfony Lock настраивается хранилище (Memcached, PostgreSQL, Redis и т. д.), где лежит информация о локе, после чего мы создаем лок, который будет использовать сконфигурированное хранилище (https://github.com/Codesrc-public-ru/Examples/blob/main/Symphony%20Lock/ExternalServiceTokenProvider.php#L17).

      В нашем случае используется кластерный Memcached, поэтому все поды понимают, есть в данный момент лок или нет.

      По поводу поведения. Исключение при попытке взять занятый лок не бросается. В примере (https://github.com/Codesrc-public-ru/Examples/blob/main/Symphony%20Lock/ExternalServiceTokenProvider.php#L19) у нас просто блокируется выполнение и ожидается освобождение лока. Также у лока есть TTL на случай падения пода или других непредвиденных ситуаций - это позволяет избежать вечного дедлока.