
Введение
Когда работа бэкенда на Symfony начинает замедляться, кэширование обычно является одним из первых решений, к которому мы прибегаем.
Это вполне логично, ведь правильно настроенный кэш снижает нагрузку на базу данных, уменьшает задержку API и исключает повторение дорогостоящих операций внутри приложения.
Однако в реальных проектах кэширование редко остается таким простым, как cache->get() с базовым значением TTL.
Привет, Хабр! На связи Илья Новиков, технический директор компании «Исходный код».
В тот момент, когда приложение работает не на одном сервере, а внутри кластера Kubernetes, взаимодействует с несколькими внешними API и одновременно получает множество конкурирующих запросов, кэширование перестает быть просто средством повышения скорости.
Речь идет о синхронизации состояний между процессами и подами.
После установления синхронизации возникает обычный набор проблем:
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 по умолчанию. Также она может работать с обратными прокси и CDN.
Этот слой хорошо подходит для общедоступных страниц и API, где данные изменяются редко. В системах с высокой нагрузкой он может дать очень наглядный результат, поскольку запрос обрабатывается без PHP и без базы данных.
Application cache
Это тот слой, к которому мы, как бэкенд-разработчики, обращаемся чаще всего.
В Symfony это обрабатывается компонентом Cache. Он предоставляет единый API для различных адаптеров: Memcached, Redis, APCu, filesystem cache и других.
Здесь мы размещаем результаты ресурсоемких SQL-запросов, ответы внешних API, словари, конфигурации и другие данные, получение которых каждый раз обходится дорого.

$cache->get с функцией обратного вызова. Cсылка на код. Основной алгоритм довольно прост.
Если данные находятся в кэше, мы их возвращаем. Если данных нет, мы вызываем функцию callback, получаем значение и сохраняем его.
На первый взгляд это кажется безобидным, пока application cache не попадет в распределенную distributed-инфраструктуру.
Вот тут и начинаются проблемы: race condition, stale cache, одновременное обновление и расхождение состояний между экземплярами приложения.
Doctrine cache
Doctrine ORM также использует внутреннее кэширование.
Он кэширует метаданные сущностей, разобранный DQL-запрос и информацию о сервисах, поэтому не повторяет одну и ту же внутреннюю работу при каждом запросе.
В большинстве случаев это работает практически автоматически. Обычно мы не тратим много времени на ручную настройку, если только нет особых причин, связанных с производительностью.
Twig Cache и OPcache
Эти оптимизации практически являются частью обычного процесса выполнения производственных задач.
Twig компилирует шаблоны в PHP. OPcache хранит скомпилированный байт-код в памяти, поэтому PHP не обрабатывает одни и те же файлы повторно при каждом запросе.
Сегодня сложно представить себе работу PHP в продакшене без OPcache. Это уже не какой-то сложный трюк, а базовый слой среды выполнения.
Наша практика: что мы кэшируем и как?
В наших проектах основное внимание уделяется application cache.
Другие уровни тоже присутствуют, но большинство прикладных задач и производственных проблем возникают в связи с кэшированием на уровне приложений.
Наши интеграционные системы постоянно взаимодействуют с внешними сервисами. Кэширование помогает нам сократить количество повторных вызовов и избежать излишней нагрузки на другие API.
Мы кэшируем данные, обладающие тремя свойствами:
Редко изменяется.
Требуется многими процессами.
Требует обращения к сторонней службе или дорогостоящей операции.
Типичные примеры:
списки сущностей;
справочные данные;
сопоставления;
данные конфигурации;
технические метаданные.
Результат практичен. Мы снижаем нагрузку на внешние системы и сокращаем latency внутри нашего собственного приложения. Вместо отправки еще одного HTTP-запроса мы повторно используем значение, которое уже находится в кэше.
Есть еще одна вещь, которую мы кэшируем отдельно: JWT-токен для авторизации во внешнем API.
Задача кажется простой. Нам не нужно запрашивать новый токен перед каждым внешним запросом. Мы сохраняем токен в кэше и используем его повторно до истечения срока действия.
Это выглядело как самая обычная оптимизация в мире, которая впоследствии привела к неприятной гонке ресурсов под нагрузкой.
В нашей инфраструктуре мы использовали два типа Memcached: локальный и кластерный. Выбор зависел от объема данных.
Большие объемы данных помещались в локальный Memcached. Причина: избежать накладных расходов на сеть и предотвратить загрузку общего кэша тяжелыми данными. Меньшие значения были помещены в кластер Memcached. Какое-то время это казалось вполне разумным.
Кубернетес нас не заставлял пересматривать подход. В целом у нас всегда был кубернетес и от локального к кластерному memcached мы перешли по причине того, чтобы в целом не использовать лишние ресурсы для создания у каждого приложения memcached, а пользоваться общим. Локальный кэш внутри подов создает отдельное состояние внутри каждого пода. В некоторых случаях это приемлемо. В других случаях это становится источником сбоев.

С тех пор мы стали более тщательно использовать пулы кэша, в зависимости от того, какое состояние мы хранили.
Создал, сломал, починил: кейс с JWT-токеном
Это была самая полезная неудача во всей этой истории.
Сервис обращается к внешней системе. Внешняя система требует JWT. Мы не хотим постоянно запрашивать токен, поэтому храним его в кэше.
Первый вариант был прямым:
Проверяем токен в кэше.
Если он существует, то используем его.
Если его нет, запрашиваем новый.
Сохраняем его.
Продолжаем работать.

При низкой загруженности сети все работало.
Запросы поступали один за другим. Один запрос обновлял токен. Следующий запрос извлекал его из кэша. Ничего подозрительного не наблюдалось.
Затем мы провели нагрузочное тестирование. Проблемы с кэшем — это именно то, что выявляет нагрузочное тестирование. Сбой произошел, когда срок действия токена истек.
Приложение одновременно получило несколько запросов. Каждый запрос проверял кэш. В результате каждого запроса происходил промах кэша.
Затем каждый запрос отправлялся во внешний сервис для получения нового JWT.
Важная деталь была скрыта в поведении внешнего сервиса. При выдаче нового токена он аннулировал предыдущий. Это означало, что одновременные запросы не просто дублировали работу. Они делали недействительными токены друг друга.

Приложение начало отправлять инвалидированный токен. Внешний API ответил ошибкой 401 Unauthorized.
Такую ошибку сложно обнаружить локально.
Ручное тестирование тоже мало помогает. Вы проходите сценарий, и все работает. Один запрос обновляет токен, другой запрос его использует.
Ошибка возникает только тогда, когда несколько запросов одновременно поступают в один и тот же участок кода.
Kubernetes сделал ситуацию еще интереснее. Соревнование велось не только между процессами PHP. Конкурировали целые поды.
Если токен хранится локально, например, в APCu или Memcached внутри пода, то каждый под будет находиться в собственном состоянии.
Один под обновил токен. Другой под по-прежнему отправляет старый. Третий под вообще не имеет токена и пытается запросить новый.
Если кэш кластерный, состояние становится общим. Это решает часть проблемы, но не всю.
Общий кэш не предотвращает одновременное отображение пустого значения несколькими подами. Он также не препятствует их одновременному обращению к внешнему API аутентификации.
Этот вывод был для нас важен: проблема заключалась не только в том, где хранился токен, но и в отсутствии синхронизации при обновлении токена.

Состояние гонки во время одновременного обновления JWT может привести к странному конечному состоянию: более старый токен может быть записан в кэш после более нового токена.
Сам кэш не понимает, какой токен семантически более актуален, он хранит только последнюю полученную операцию записи.
Исправление распределенных состояний гонки с помощью Symfony Lock
Виноват здесь был не кэш. Он честно хранил значение, которое было записано последним.
Нам нужно было предотвратить одновременное выполнение одной и той же критической секции несколькими процессами. В нашем случае критической секцией был запрос к внешнему API для получения нового JWT.
Для этого мы использовали компонент блокировки Symfony. Его задача — закрыть выбранный фрагмент кода для всех, кроме процесса, получившего блокировку.
Почему одного кэша недостаточно
Общий кэш отвечает на вопрос, где хранить данные. Блокировка отвечает на другой вопрос: кому разрешено ее обновлять. Это отдельные проблемы.
Правильный поток данных должен выглядеть следующим образом:
Проверяем токен в кэше.
Если он существует, то используем его.
Если не существует, то берем lock.
После получения lock’а еще раз проверяем кэш. Пока мы стояли в очереди, другой процесс мог уже положить туда свежий токен! Без проверки второй процесс просто повторит гонку.
Если токен по-прежнему отсутствует, запрашиваем новый.
Сохраняем.
Отпускаем lock.
Четвертый шаг легко пропустить. Именно он предотвращает повторное проведение той же гонки в другом формате.
Если запрос получает ошибку кэширования, это не означает, что токен останется недоступен после того, как запрос дождется блокировки. Другой процесс, возможно, уже получил блокировку, запросил токен, сохранил его в общем кэше и освободил блокировку.
Если следующий процесс не проверит кэш еще раз после получения блокировки, он все равно запросит еще один токен. Это приведет к еще одному обновлению. В нашем случае еще одно обновление может аннулировать только что записанный токен.
Вот почему важно все перепроверять.

Первая проверка защищает обычный быстрый путь.
Lock защищает критически важную секцию.
Вторая проверка защищает нас от выполнения работы, которая уже была завершена в рамках другого процесса.
Зачем нужен именно распределенный Lock
В K8s локальные блокировки (flock, in-memory) бесполезны - они лочат код только внутри одного пода. Соседний под ничего не узнает. Лок должен лежать в общем хранилище, доступном всем инстансам. Идеально подойдет Memcached-based lock.
Разделяем мухи и котлеты (где хранить)
Важно понимать разницу: где лежит токен, а где lock. Если оба в кластере - супер. Но если токен локальный (APCu), все плохо. Лок спасет API от спама, но локальные кэши подов останутся рассинхронизированными.

Поэтому общее состояние (JWT) всегда должно лежать в shared cache.
Пара подводных камней
TTL лока. Должен быть разумным. Завис процесс - лок не должен висеть вечно. Слишком короткий TTL - лок слетит раньше ответа от API, и гонка повторится.
Запас токена. Ставьте TTL в кэше меньше реального. Дают на 5 минут? кэшируйте на 4:30. Иначе словите ошибки на пограничных значениях.
Логируйте все. Кто взял лок, время запроса, факты refresh. Без метрик расследовать проблемы на проде нереально.
Разделение двух вопросов хранения
В этой истории есть две разные вещи: где хранится токен и где хранится lock.
Эти вещи не следует смешивать.
Если и токен, и блокировка находятся в общем хранилище, модель считается корректной.
Если блокировка общая, но токен локальный, система все равно неисправна и другим способом.
Блокировка может защитить внешний API от шторма обновлений. Она гарантирует, что только один под запрашивает новый токен одновременно.
Это не позволит синхронизировать локальные значения токенов внутри подов.

Именно поэтому JWT, используемый всеми подами, должен храниться в общем кэше.
Блокировка синхронизирует операцию обновления, а общий кэш синхронизирует результат. Нам нужно и то, и другое.
Несколько ловушек
В процессе производства важно учитывать несколько деталей.
Время жизни блокировки (TTL) должно быть разумным. Если процесс зависает, блокировка не должна оставаться вечной. Если TTL слишком короткое, блокировка может истечь до того, как API аутентификации ответит, и проблема с состоянием гонки может возникнуть снова.
Запас прочности токена. Время жизни токена в кэше (TTL) должно быть короче реального срока его действия. Если токен действителен в течение 5 минут, кэшируйте его на 4:30. Граничные значения — это то, где часто возникают ошибки авторизации.
Журналы. Записывайте, кто получил блокировку, сколько времени занял запрос и когда произошло обновление. Также записывайте промахи кэша и ошибки обновления. Без мониторинга расследование в производственной среде превращается в догадки.
Что мы использовали в наших проектах
Кэшируйте то, что действительно решает задачу. Используйте кэш для уменьшения задержек и внешних вызовов. Кэшируйте справочные данные, сопоставления, конфигурации, ответы API. Если данные редко меняются и часто считываются, это хороший вариант.
Всегда выполняйте двойную проверку после получения блокировки. Промах кэша перед получением блокировки недостаточен. Состояние могло измениться, пока процесс ожидал.
Регистрируйте процесс обновления. Наблюдаемость в этой части системы обязательна. Регистрируйте пропущенные события, попытки обновления, время блокировки и ошибки.
Кэш — это не волшебная таблетка. Он не исправит N+1 запросов, отсутствующие индексы или слабую архитектуру. Если система работает медленно без кэша, правильное решение может заключаться в самой системе.
Соавтор статьи: Данил Мануйлов, тимлид команды Web-разработки @ideal1sm.