Производительность и кэширование
Как ускорить PHP — кэширование на уровне приложения в Redis или Memcached, инвалидация кэша, очереди и асинхронные задачи, профилирование через Xdebug и Blackfire, масштабирование пула php-fpm, вынос статики на CDN и заголовки HTTP-кэширования.
13 вопросов
JuniorТеорияЧастоЧем кэш приложения в Redis или Memcached отличается от OPcache?
Чем кэш приложения в Redis или Memcached отличается от OPcache?
OPcache хранит скомпилированные опкоды ваших PHP-файлов, чтобы скрипт не парсился и не компилировался заново на каждом запросе; данные приложения он не хранит. Redis или Memcached — кэш приложения: он хранит вычисленные вашим кодом результаты, например результат запроса, и общий для всех воркеров.
Типичные ошибки
- ✗Считать, что OPcache кэширует ваши данные или готовый вывод, а не скомпилированные опкоды
- ✗Ждать, что OPcache уберёт медленный запрос к базе из времени ответа
- ✗Кэшировать в APCu или в статическом свойстве, не замечая, что это на воркер, а не общее
Уточняющие вопросы
- →Почему OPcache не сокращает время, которое медленный запрос к базе добавляет к запросу?
- →Где размещать кэш приложения, если его должны делить несколько FPM-серверов?
JuniorТеорияЧастоЧто снимает с PHP-серверов вынос статики на CDN?
Что снимает с PHP-серверов вынос статики на CDN?
Картинки, CSS и JS всё равно занимают соединения и полосу на вашем origin. CDN отдаёт их с ближайшего к пользователю edge-узла, поэтому задержка падает, а origin обрабатывает только динамику. Имена файлов версионируют, чтобы новый деплой не отдавался из устаревшего edge-кэша.
Типичные ошибки
- ✗Выкладывать новые файлы под тем же именем и отдавать устаревшую копию с edge
- ✗Ждать от CDN ускорения динамических персональных ответов, которые нельзя кэшировать на edge
- ✗Оставлять статику на origin и винить FPM в полосе, которую она тратит
Уточняющие вопросы
- →Как хеш в имени собранного файла делает безопасным длинный
Cache-Controlmax-age? - →Какие ответы никогда нельзя хранить на edge-узле и почему?
JuniorТеорияЧастоЧто меняют Cache-Control и ETag в запросах, которые шлёт вам браузер?
Что меняют Cache-Control и ETag в запросах, которые шлёт вам браузер?
Cache-Control: max-age разрешает клиенту переиспользовать ответ, не спрашивая, поэтому до PHP запрос не доходит, пока срок не истёк. ETag — отпечаток содержимого; клиент шлёт его в If-None-Match, и при совпадении вы отвечаете 304 Not Modified без тела, экономя рендер и передачу.
Типичные ошибки
- ✗Считать
ETag, отрендерив всю страницу, — это экономит передачу, но не саму работу - ✗Слать длинный
max-ageна персональной странице и утечь ею через общий прокси-кэш - ✗Считать, что ответ
304всё-таки несёт тело
Уточняющие вопросы
- →Как
Cache-Control: privateменяет то, что общему прокси разрешено хранить? - →Когда
Last-Modified— лучший валидатор, чемETag?
JuniorТеорияЧастоЗачем выносить медленную работу вроде отправки почты в очередь, а не делать её в запросе?
Зачем выносить медленную работу вроде отправки почты в очередь, а не делать её в запросе?
Синхронно вы держите FPM-воркер весь вызов: пользователь ждёт, слот никому не доступен, а сбой стороннего сервиса роняет и сам запрос. Постановка задачи в очередь сразу отдаёт ответ, а работу делает отдельный долгоживущий воркер, повторяя её при сбое.
Типичные ошибки
- ✗Думать, что задача в очереди выполнится сама, забыв про воркер-процесс под супервизором
- ✗Делать медленный вызов стороннего сервиса синхронно, превращая его таймаут в таймаут запроса
- ✗Ставить в очередь задачу, результат которой нужен ответу, и ждать её из запроса
Уточняющие вопросы
- →Кто следит за воркером и что происходит с задачей, если воркер перезапустился во время деплоя?
- →Как повторять упавшую задачу и когда ей место в dead-letter очереди?
JuniorТеорияЧастоЧем горизонтальное масштабирование PHP-FPM отличается от вертикального?
Чем горизонтальное масштабирование PHP-FPM отличается от вертикального?
Вертикальное масштабирование — более мощная машина: больше ядер и RAM позволяют поднять pm.max_children. Горизонтальное — добавить серверы за балансировщиком. Shared-nothing модель PHP делает это естественным, если сессии, загрузки и кэш лежат в общем хранилище вроде Redis, а не на локальном диске.
Типичные ошибки
- ✗Считать
pm.max_childrenтолько по числу ядер и уйти в swap, забыв про RAM на воркер - ✗Добавлять серверы, когда сессии или загрузки всё ещё лежат на локальном диске одного узла
- ✗Масштабировать веб-слой, когда узкое место — одна база, общая для всех серверов
Уточняющие вопросы
- →Как посчитать
pm.max_childrenиз средней памяти воркера и доступной RAM? - →Что сломается первым, если добавить веб-серверы, оставив за ними одну базу?
JuniorТеорияЧастоЧем режим отладки Xdebug отличается от режима профилирования?
Чем режим отладки Xdebug отличается от режима профилирования?
xdebug.mode=debug даёт пошаговую отладку — точки останова, шаги и просмотр переменных из IDE. xdebug.mode=profile записывает, куда уходит время, и пишет cachegrind-файл, который вы открываете в KCachegrind. Оба режима сильно замедляют запрос, поэтому на проде им не место.
Типичные ошибки
- ✗Оставлять Xdebug включённым в проде, где он съедает заметную долю каждого запроса
- ✗Ждать точек останова от профайлера или графа вызовов от отладчика
- ✗Пытаться читать cachegrind-файл руками вместо открытия в смотрелке
Уточняющие вопросы
- →Как
xdebug.start_with_request=triggerограничивает профилирование выбранными запросами? - →Почему семплирующий профайлер вроде Blackfire безопаснее направлять на боевой трафик?
MiddleТеорияЧастоЧем очереди Laravel и Symfony Messenger отличаются в том, как задача отправляется и обрабатывается?
Чем очереди Laravel и Symfony Messenger отличаются в том, как задача отправляется и обрабатывается?
Laravel отправляет сериализованный Job-объект в connection (Redis, база, SQS), а queue:work его обрабатывает; повторы, backoff и таблица failed_jobs встроены. Messenger отправляет обычное сообщение в шину, middleware маршрутизирует его в transport, а обрабатывает отдельный handler — поэтому то же сообщение можно обработать и синхронно.
Типичные ошибки
- ✗Забыть, что потребитель — долгоживущий процесс под супервизором, требующий рестарта при деплое
- ✗Сериализовать в задачу целиком состояние Eloquent-модели вместо её id
- ✗Считать, что упавшая задача повторяется вечно, без лимита повторов и dead-letter транспорта
Уточняющие вопросы
- →Почему долгоживущий воркер надо перезапускать после деплоя и что делает
queue:restart? - →Как подбирать число воркер-процессов независимо от размера пула FPM?
MiddleДизайнИногдаЭндпоинт со списком товаров гоняет один и тот же тяжёлый агрегирующий запрос для каждого посетителя, а результат меняется, только когда админ правит товар. Вы решаете кэшировать результат в Redis. Спроектируйте кэширование: из чего строится ключ, сколько живёт запись и — самое сложное — как правка товара убирает или заменяет устаревшую запись. Объясните, как не отдавать устаревший список целый час и что происходит, когда кэш пуст и приходит всплеск запросов сразу.
Эндпоинт со списком товаров гоняет один и тот же тяжёлый агрегирующий запрос для каждого посетителя, а результат меняется, только когда админ правит товар. Вы решаете кэшировать результат в Redis. Спроектируйте кэширование: из чего строится ключ, сколько живёт запись и — самое сложное — как правка товара убирает или заменяет устаревшую запись. Объясните, как не отдавать устаревший список целый час и что происходит, когда кэш пуст и приходит всплеск запросов сразу.
Ключ стройте из всего, что меняет результат — фильтры, страница, сортировка, локаль — и дайте ему TTL как страховку. Главная работа — инвалидация: запись товара должна удалить или переписать затронутые ключи, обычно через тег или version-префикс, поднимаемый при записи, ведь перебрать ключи дёшево нельзя. Пустой кэш прикройте блокировкой, иначе всплеск запросов положит базу.
Типичные ошибки
- ✗Полагаться только на истечение TTL и отдавать устаревший список, пока он не протухнет сам
- ✗Строить ключ без фильтров, из-за чего два разных запроса сходятся в одну запись
- ✗Оставить холодный ключ без защиты, и каждый параллельный промах перестраивает его из базы
Уточняющие вопросы
- →Как version-префикс, поднимаемый при записи, инвалидирует много ключей без их перебора?
- →Что такое лавина промахов и как её останавливает короткая блокировка или окно stale-while-revalidate?
MiddleПроизводительностьИногдаЭндпоинт отвечает 3 секунды. Как профайлером найти, куда реально уходит это время?
Эндпоинт отвечает 3 секунды. Как профайлером найти, куда реально уходит это время?
Профилируйте реальный запрос — Xdebug локально или Blackfire на боевом трафике — и читайте граф вызовов по inclusive wall time, а не по числу вызовов. Почти всегда это одно из трёх: запрос, повторённый сотни раз, один медленный запрос или блокирующий вызов стороннего сервиса. Чините то, что показал профиль: кэш вокруг функции на 2 мс не даст ничего.
Типичные ошибки
- ✗Оптимизировать наугад, а мерить потом, вместо того чтобы сначала профилировать
- ✗Сортировать граф вызовов по числу вызовов, а не по реально потраченному времени
- ✗Пропустить N+1 — один дешёвый запрос, умноженный на 300 строк, а не один медленный
Уточняющие вопросы
- →Как повторённый запрос на 2 мс выглядит в графе вызовов и почему его легко пропустить?
- →Когда правильное лекарство — индекс в базе, а не кэш приложения?
SeniorДизайнИногдаМаркетингу нужно разослать кампанию 500 000 пользователей. Наивный foreach по пользователям с отправкой письма внутри отваливается по таймауту, а когда он умирает на середине, никто не знает, кому уже отправили. Спроектируйте систему на очередях: как работа делится на задачи, как не отправить письмо одному человеку дважды после повтора или редеплоя, как удержаться в лимите провайдера и как кампания остаётся наблюдаемой и возобновляемой во время работы.
Маркетингу нужно разослать кампанию 500 000 пользователей. Наивный foreach по пользователям с отправкой письма внутри отваливается по таймауту, а когда он умирает на середине, никто не знает, кому уже отправили. Спроектируйте систему на очередях: как работа делится на задачи, как не отправить письмо одному человеку дважды после повтора или редеплоя, как удержаться в лимите провайдера и как кампания остаётся наблюдаемой и возобновляемой во время работы.
Разбейте рассылку на мелкие задачи — по получателю или по чанку — и отправьте их батчем, а не одной гигантской задачей. Храните состояние по каждому получателю, чтобы повтор был идемпотентным: воркер занимает строку и пропускает уже помеченную как отправленную. Ограничьте воркеров лимитом провайдера, задайте предел повторов с backoff и показывайте прогресс и сбои, чтобы прогон можно было приостановить и возобновить.
Типичные ошибки
- ✗Считать доставку at-least-once доставкой ровно один раз, из-за чего повтор молча шлёт письмо снова
- ✗Делать единицей работы всю кампанию, из-за чего любой сбой теряет весь прогресс
- ✗Игнорировать лимит провайдера и получить троттлинг или блокировку всей рассылки
Уточняющие вопросы
- →Что делает задачу идемпотентной и где именно надо записывать пометку по получателю?
- →Как безопасно доработать задачи в полёте во время деплоя, который перезапускает всех воркеров?
SeniorДебаггингИногдаВремя ответа растёт только под нагрузкой, а одиночный запрос быстрый. Найдите причину.
Время ответа растёт только под нагрузкой, а одиночный запрос быстрый. Найдите причину.
Пул насыщен, а не медленный: 20 из 20 воркеров заняты и 312 запросов стоят в listen-очереди, поэтому большая часть из 8 с — ожидание воркера. Slow log называет виновника: блокирующий curl_exec() к сервису склада держит воркер секундами. Поднять pm.max_children — лишь передвинуть очередь; нужен таймаут и вынос этого вызова из запроса.
Типичные ошибки
- ✗Читать длинную listen-очередь как сетевую проблему, а не как исчерпание пула
- ✗Поднять
pm.max_childrenвыше бюджета RAM и превратить очередь в swap - ✗Игнорировать slow log, потому что одиночный запрос выглядит быстрым
Уточняющие вопросы
- →Почему блокирующий вызов стороннего сервиса под нагрузкой бьёт сильнее, чем в одиночном запросе?
- →Что таймаут
curlгарантирует про худшее время удержания воркера?
SeniorПроизводительностьИногдаКак нагрузочно протестировать Laravel API, чтобы выбрать pm.max_children для пула FPM?
Как нагрузочно протестировать Laravel API, чтобы выбрать pm.max_children для пула FPM?
Мерьте, а не угадывайте. Возьмите среднюю резидентную память воркера на реальном трафике и поделите на неё RAM, которую можете отдать, — это потолок. Затем нагрузите машину, похожую на боевую, через k6 с растущей конкурентностью, следя за p95, listen-очередью FPM и памятью. Верное значение — наибольшее, при котором задержка ещё не растёт, а машина не уходит в swap.
Типичные ошибки
- ✗Считать размер пула по числу ядер, игнорируя резидентную память каждого воркера
- ✗Нагружать один закэшированный URL, из-за чего прогон не задевает базу, куда идёт реальный трафик
- ✗Смотреть только на запросы в секунду и не заметить, что p95 уже развалился
Уточняющие вопросы
- →Что listen-очередь FPM говорит такого, что скрывает средняя задержка?
- →Почему пропускная способность падает, когда пул больше, чем позволяет бюджет RAM?
SeniorДизайнИногдаПубличный API с преобладанием чтения отдаёт одни и те же данные каталога миллионам анонимных клиентов, и база стала узким местом. Спроектируйте слой кэширования: какие ярусы вы используете и что хранит каждый, как через них проходит запись, что вы отдаёте, когда узел кэша недоступен, и как холодный или вытесненный ключ не устраивает лавину на базу. Скажите, какие данные нельзя класть в общий ярус и почему.
Публичный API с преобладанием чтения отдаёт одни и те же данные каталога миллионам анонимных клиентов, и база стала узким местом. Спроектируйте слой кэширования: какие ярусы вы используете и что хранит каждый, как через них проходит запись, что вы отдаёте, когда узел кэша недоступен, и как холодный или вытесненный ключ не устраивает лавину на базу. Скажите, какие данные нельзя класть в общий ярус и почему.
Стройте ярусами: HTTP-валидаторы на edge для публичных GET, общий ярус Redis для вычисленных ответов, база позади обоих. Запись инвалидирует явно — удаляет или версионирует затронутые ключи, а не ждёт TTL. Redis должен остаться кэшем, а не зависимостью: при сбое проваливайтесь в базу за circuit breaker. Холодные ключи прикройте блокировкой или stale-while-revalidate и никогда не кладите персональные и авторизованные данные в общий ярус.
Типичные ошибки
- ✗Кэшировать авторизованный или персональный ответ в ярусе, общем для всех клиентов
- ✗Считать Redis зависимостью, из-за чего отказ кэша сразу становится отказом API
- ✗Отказаться от инвалидации и позволить TTL решать, насколько устареет каталог
Уточняющие вопросы
- →Как circuit breaker перед Redis не даёт отказу кэша стать отказом сервиса?
- →Что отдаёт stale-while-revalidate, пока ключ перестраивается?