TTFB — самая честная метрика серверной части сайта: она показывает, сколько посетитель ждёт, прежде чем сервер вообще начнёт отвечать. Ниже разбираем, как разложить это время на фазы, отделить сеть от работы приложения и чем лечится каждая часть — от индексов в базе до кеша ответов в nginx. Материал для тех, кто сам администрирует VPS или выделенный сервер с PHP-приложением.

Что такое TTFB

TTFB (Time To First Byte) — время от начала обращения к странице до получения первого байта ответа, включая DNS-резолвинг, TCP- и TLS-рукопожатие. Это не синоним «сколько думал PHP»: в метрику попадает всё, что происходит до того, как приложение вообще узнает о запросе.

Раскладывать TTFB удобно на четыре фазы — у каждой свои причины и свои средства.

ФазаЧто происходитЧем сокращают
DNSКлиент выясняет IP домена. На повторных визитах чаще всего берётся из кеша резолвераРазумный TTL записей, устойчивый DNS-хостинг, dns-prefetch на стороне страницы
TCPТрёхэтапное рукопожатие — один круговой обход (RTT)keep-alive, чтобы не повторять его на каждый запрос; сервер ближе к аудитории
TLSРукопожатие: один RTT в TLS 1.3, два в TLS 1.2TLS 1.3, session resumption и tickets, короткая цепочка сертификатов. OCSP stapling с сертификатами Let's Encrypt с 2025 года неактуален — OCSP-URL в них больше нет
ОтветЗапрос уходит на сервер, приложение готовит ответ, первый байт возвращается: ещё один RTT плюс работа бэкендаКеш ответов, индексы в БД, OPcache, вынос тяжёлых операций в очереди

Кодом и конфигурацией вы влияете только на последнюю строку — и даже в ней сидит целый круговой обход сети. Об этом стоит помнить, когда кто-то обещает «TTFB 50 мс» аудитории на другом континенте.

Как измерить

Разбивка по фазам через curl

curl печатает тайминги по фазам без единого дополнительного инструмента:

curl -s -o /dev/null -w '
dns:   %{time_namelookup}
tcp:   %{time_connect}
tls:   %{time_appconnect}
pre:   %{time_pretransfer}
ttfb:  %{time_starttransfer}
total: %{time_total}
' https://example.com/

Значения кумулятивные и в секундах: каждое следующее включает предыдущие. Отсюда DNS — это time_namelookup, TCP-рукопожатие — time_connect минус time_namelookup, TLS — time_appconnect минус time_connect.

Дальше начинается частая ошибка. Разность time_starttransfer минус time_appconnect — это не «работа бэкенда». В неё входит как минимум один полный RTT: запрос ещё должен уйти на сервер, а первый байт — вернуться. Между appconnect и отправкой запроса есть отдельная фаза pretransfer, а на http:// без TLS значение time_appconnect равно нулю, и формула ломается совсем.

Считайте так: time_starttransfer минус time_pretransfer — это круговая задержка плюс время генерации ответа; вычтите из неё один RTT (например, time_connect минус time_namelookup), чтобы получить оценку серверной части. Точное значение всё равно берите из $upstream_header_time в логах nginx.

Один замер ничего не значит

Первый запрос попадает на холодные кеши — OPcache, буферы БД, кеш файловой системы. Прогрев считайте отдельно, а статистику собирайте по серии:

for i in $(seq 1 3); do curl -s -o /dev/null https://example.com/; done   # прогрев, в статистику не идёт

for i in $(seq 1 30); do
  curl -s -o /dev/null -w '%{time_starttransfer}\n' https://example.com/
done | sort -n | awk '{a[NR]=$1} END {
  print "median:", (NR%2 ? a[(NR+1)/2] : (a[NR/2]+a[NR/2+1])/2);
  print "p95:", a[int(NR*0.95 + 0.9999)]
}'

Округление вверх в последней строке не придирка: на 30 замерах int(NR*0.95) даёт 28-й элемент, то есть 93-й перцентиль, а не 95-й. Медиана показывает типичного посетителя, p95 — тех, кому не повезло. Рост p95 при стабильной медиане обычно означает конкуренцию за ресурс: блокировки в базе, нехватку воркеров FPM, своп.

Время бэкенда — из логов nginx

curl меряет снаружи, вместе с сетью. Чтобы увидеть чистое время приложения, добавьте тайминги в лог. Важно, где именно: log_format разрешён только в контексте http, а access_log — в http, server и location. Если положить оба куска в server-блок сайта, nginx не стартует и напишет [emerg] "log_format" directive is not allowed here.

# /etc/nginx/nginx.conf, контекст http
log_format timing '$remote_addr "$request" $status '
                  'rt=$request_time uct=$upstream_connect_time '
                  'uht=$upstream_header_time urt=$upstream_response_time';

# конфиг сайта, контекст server
access_log /var/log/nginx/timing.log timing;

Применяем и проверяем конфиг одной командой:

sudo nginx -t && sudo systemctl reload nginx

Что означают переменные:

  • $request_time — полное время обработки запроса, от первого байта запроса до последнего байта ответа клиенту. Сюда входит и медленный канал посетителя.

  • $upstream_header_time — время от начала соединения с PHP-FPM до получения заголовков ответа; при работе через unix-сокет это практически чистое время генерации ответа приложением.

  • $upstream_response_time — то же, но до последнего байта ответа от FPM. Разница с предыдущим показывает, сколько приложение отдавало тело.

  • $upstream_connect_time на unix-сокете почти всегда 0.000 — это нормально, переменная полезна только для TCP-апстримов.

Дальше достаточно отсортировать:

# 20 самых медленных ответов приложения
grep -o 'uht=[0-9.]*' /var/log/nginx/timing.log | cut -d= -f2 | sort -rn | head -20

Бэкенд: где теряются сотни миллисекунд

Запросы к базе

По вкладу в TTFB база данных почти всегда на первом месте. Включите лог медленных запросов с низким порогом:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL long_query_time = 0.2;

SET GLOBAL действует до перезапуска сервера; чтобы закрепить, впишите те же параметры в /etc/mysql/mysql.conf.d/mysqld.cnf. Порог в 0.2 секунды для веб-страницы уже щедрый — начните с него и опускайте по мере уборки.

Найденный запрос разбирайте планировщиком:

EXPLAIN ANALYZE
SELECT id, title, published_at
FROM posts
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT 20;

EXPLAIN ANALYZE доступен с MySQL 8.0.18 и реально выполняет запрос, а не только строит план. На боевой базе это ещё один прогон тяжёлой выборки со всеми блокировками и чтением с диска. На проде берите обычный EXPLAIN, а EXPLAIN ANALYZE гоняйте на реплике или копии.

Типичный итог — не хватает составного индекса под фильтр и сортировку сразу:

ALTER TABLE posts ADD INDEX posts_status_published_at_index (status, published_at);

Второй постоянный источник задержки — N+1: один запрос на список и ещё по одному на каждую связанную запись. Двадцать постов превращаются в 21 запрос, и на медленном диске это уже сотни миллисекунд:

// N+1: 1 запрос на список + по одному на автора каждой записи
$posts = Post::query()->latest('published_at')->take(20)->get();

// 2 запроса вместо 21
$posts = Post::query()->with('author')->latest('published_at')->take(20)->get();

Чтобы такие места не появлялись заново, включите строгий режим вне продакшена — ленивая загрузка начнёт падать с исключением ещё в разработке:

// app/Providers/AppServiceProvider.php, метод boot()
Model::preventLazyLoading(! $this->app->isProduction());

Сессии и кеш приложения

Файловые сессии и файловый кеш упираются в диск и не переживают горизонтального масштабирования. Переносите в Redis:

sudo apt install redis-server php-redis
sudo systemctl restart php8.3-fpm
# php8.3-redis — только если подключён PPA ondrej/php;
# в штатных репозиториях Ubuntu 24.04 пакет называется без версии
php -m | grep redis

Перезапуск FPM обязателен: на пакетах Debian и Ubuntu триггер обычно срабатывает сам, но на PPA и при сборке из PECL — нет, и расширение молча не подхватится.

# .env
SESSION_DRIVER=redis
CACHE_STORE=redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis

После правки .env выполните php artisan config:clear, а если конфиг закеширован — php artisan config:cache. Без этого приложение продолжит читать старые значения.

Заодно уточним расхожее утверждение про блокировку. В нативных сессиях PHP (session.save_handler = files) файл сессии блокируется на время запроса, и параллельные запросы одного пользователя выстраиваются в очередь. Laravel использует собственный FileSessionHandler без блокировки — там та же схема даёт не очередь, а гонку записи: последний параллельный запрос затирает изменения сессии остальных, плюс лишние IOPS. В обоих случаях лечится переносом сессий в Redis.

OPcache и деплой

Без OPcache PHP парсит и компилирует исходники на каждый запрос. Минимальный рабочий набор для PHP 8.3:

# /etc/php/8.3/fpm/conf.d/99-opcache.ini
opcache.enable=1
opcache.memory_consumption=192
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

validate_timestamps=0 убирает проверку mtime у каждого файла на каждый запрос — и вместе с ней возможность подхватить новый код автоматически. После каждого деплоя нужен sudo systemctl reload php8.3-fpm, иначе посетители будут получать предыдущую версию приложения. Если процесс выкладки этого не гарантирует, оставьте значение 1.

Туда же относятся кеши фреймворка: php artisan config:cache, route:cache, view:cache — или одна команда php artisan optimize. С закешированным конфигом вызовы env() вне файлов config/ вернут null, это стандартная ловушка первого прода.

Кеш ответов в nginx

Всё перечисленное сокращает работу приложения. Кеш ответов её убирает совсем: nginx отдаёт готовый HTML, не будя PHP-FPM. Это самый крупный одиночный выигрыш по TTFB — и самый опасный по последствиям, если настроить наспех.

Хранилище и условия

# /etc/nginx/nginx.conf, контекст http
fastcgi_cache_path /var/cache/nginx/fcgi levels=1:2 keys_zone=app:100m
                   inactive=10m max_size=1g use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";

map $http_cookie $skip_cache {
    default 0;
    "~*(^|;\s*)logged_in="  1;
}

use_temp_path=off — не косметика. Без него nginx сначала пишет ответ во временный каталог (/var/lib/nginx/fastcgi), а потом переносит в кеш; если это разные файловые системы, каждый MISS оплачивается лишним копированием.

sudo mkdir -p /var/cache/nginx/fcgi
sudo chown www-data:www-data /var/cache/nginx/fcgi
# на RHEL и AlmaLinux пользователь называется nginx: chown nginx:nginx

Теперь про $skip_cache. Проверять сессионную куку Laravel — ложный след: сессию приложение выдаёт всем подряд, включая анонимных посетителей, ради CSRF-токена и flash-сообщений. Со второго запроса любого гостя условие сработает, и кеш выключится сам собой. Вдобавок имя куки задаётся переменной SESSION_COOKIE и по умолчанию собирается из APP_NAME, так что жёстко зашитое $cookie_laravel_session из чужого конфига у вас почти наверняка не совпадёт с реальностью.

Признак «залогинен» выносим в отдельную куку, которую приложение ставит на входе и удаляет на выходе:

// после успешного входа
cookie()->queue(cookie()->forever('logged_in', '1'));

// при выходе
cookie()->queue(cookie()->forget('logged_in'));

nginx сопоставляет только имя куки, поэтому шифрование кук в Laravel этой схеме не мешает.

Блок location

location ~ \.php$ {
    try_files $uri =404;

    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;

    fastcgi_cache app;
    fastcgi_cache_valid 200 301 302 10m;
    fastcgi_cache_use_stale error timeout updating http_500 http_503;
    fastcgi_cache_background_update on;
    fastcgi_cache_lock on;

    fastcgi_cache_bypass $skip_cache $http_authorization;
    fastcgi_no_cache     $skip_cache $http_authorization;

    fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
    fastcgi_hide_header    Set-Cookie;

    add_header X-Cache $upstream_cache_status always;
}

Разберём неочевидные строки.

try_files $uri =404; стоит первой не ради скорости. Без неё при cgi.fix_pathinfo=1 и наличии каталога с пользовательскими загрузками запрос вида /uploads/avatar.jpg/x.php уходит в FPM и может исполнить залитый файл. Конфиги из статей про производительность копируют целиком, так что эта строка должна быть в них по умолчанию.

У fastcgi_cache_use_stale свой набор параметров, не такой, как у proxy_cache_use_stale: error | timeout | invalid_header | updating | http_500 | http_503 | http_403 | http_404 | http_429 | off. Привычного http_502 здесь нет — nginx откажется стартовать с [emerg] invalid parameter "http_502". Коварство в том, что nginx -t тогда не пройдёт, systemctl reload не выполнится, сервер продолжит работать со старым конфигом, а вы решите, что «кеш не завёлся».

fastcgi_ignore_headers нужен, потому что nginx по умолчанию не кеширует ответы с Set-Cookie или с Cache-Control: no-cache/no-store/private. Laravel отдаёт и то и другое практически на любой странице, поэтому без этой директивы вы получите X-Cache: MISS всегда — без единой ошибки в логах.

fastcgi_ignore_headers Set-Cookie без fastcgi_hide_header Set-Cookie — прямая утечка: в кеш попадёт Set-Cookie первого посетителя и уедет всем остальным вместе со страницей. Эти две директивы ставятся только парой. Побочный эффект тоже надо знать заранее: на закешированных страницах перестанет выдаваться свежий CSRF-токен, так что формы на них должны забирать токен отдельным запросом.

Проверка

sudo nginx -t && sudo systemctl reload nginx
curl -sI https://example.com/ | grep -i x-cache

Первый запрос — MISS, второй — HIT. Если HIT не появляется, проверьте по списку:

  • ответ уходит с Set-Cookie, а fastcgi_ignore_headers не настроен;

  • запрос попадает под $skip_cache — в браузере это легко проверить в инструментах разработчика, curl куки не отправляет;

  • метод не GET и не HEAD — по умолчанию кешируются только они;

  • у nginx нет прав на каталог кеша — ищите записи об этом в error.log.

Сжатие

Оговоримся сразу: сжатие почти не влияет на TTFB — первый байт уходит примерно тогда же, а иногда даже на пару миллисекунд позже. Оно сокращает время полной загрузки. Но раз уж вы всё равно правите конфиг, там есть ловушка, которая стоит абзаца.

gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css text/javascript application/javascript application/json
           text/xml application/xml image/svg+xml;

Ловушка — в gzip_types. Начиная с nginx 1.23.4 (а 1.24 — стабильная ветка из неё) расширение .js в conf/mime.types отдаётся как text/javascript, а не application/javascript. Списки из старых статей без text/javascript оставляют JS-бандлы несжатыми, и заметить это можно только по размеру ответа. text/html в список добавлять не нужно — он сжимается всегда.

Brotli в Ubuntu 24.04 ставится модулями из репозитория:

sudo apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
sudo nginx -t && sudo systemctl reload nginx

Сами по себе пакеты ничего не сжимают — нужны директивы, иначе модули стоят, а ответы уходят как раньше:

brotli on;
brotli_comp_level 5;
brotli_static on;
brotli_types text/plain text/css text/javascript application/javascript application/json
             text/xml application/xml image/svg+xml;

brotli_static on; отдаёт заранее сжатые файлы .br, если они лежат рядом с оригиналом. Для собранных фронтенд-бандлов это лучший вариант: максимальная степень сжатия без затрат процессора на каждый запрос.

География и RTT

Теперь про сеть — на примере расчёта, а не замера. Пусть RTT между посетителем и сервером равен 120 мс. Холодное соединение по HTTPS стоит трёх круговых обходов: TCP-рукопожатие, TLS 1.3 и, наконец, уход запроса с возвратом первого байта. Итого около 360 мс ещё до того, как приложение сделает хоть что-нибудь. Подставьте свой RTT из ping или из разности time_connect и time_namelookup — арифметика та же.

Отсюда два практических вывода. Первый: сокращайте количество рукопожатий — keep-alive, HTTP/2, session resumption; внутри уже открытого соединения от трёх RTT остаётся один. Второй: если приложение генерирует заголовки ответа за 800 мс, перенос сервера уберёт часть сетевых сотен миллисекунд, но главный вклад останется на месте — сначала кеш и запросы к БД, потом география.

Обратное тоже верно. Когда $upstream_header_time уже держится на 20-30 мс, а TTFB у аудитории всё ещё уходит за полсекунды, оптимизировать в коде больше нечего: дальше решают расположение сервера и раздача статики ближе к пользователю.

Порядок действий

  1. Снимите базовую линию: серия curl-замеров с отдельным прогревом, медиана и p95.

  2. Включите тайминги в логе nginx и найдите страницы с большим $upstream_header_time.

  3. Разберитесь с базой: медленные запросы, недостающие индексы, N+1.

  4. Перенесите сессии и кеш в Redis, включите OPcache и кеши фреймворка.

  5. Только после этого включайте кеш ответов в nginx — на неисправленном приложении он маскирует проблемы вместо того, чтобы их решать.

  6. Повторите замер тем же скриптом и сравните с базовой линией.

Каждый шаг проверяйте измерением, а не ощущением. Иначе через месяц никто не вспомнит, что именно дало эффект, а что просто добавило конфигу строк.