TTFB 800 мс при пинге 20 мс: раскладываем ожидание первого байта по слоям
Вы измерили ping — 20 мс, честная низкая задержка. Открываете вкладку Network в браузере или гоняете curl -w, и видите TTFB (Time To First Byte) в 800 мс. Это не сетевая проблема, и разбирать маршрут или гонять mtr тут бессмысленно: узкое место почти всегда сидит на сервере, между моментом, когда запрос долетел до бэкенда, и моментом, когда бэкенд начал отдавать ответ. Разбираем TTFB не как единое число, а как сумму конкретных этапов обработки запроса — и что делать с каждым из них по отдельности.
Содержание
- TTFB — это сумма, а не атомарная величина
- Вычисления в приложении: бизнес-логика без единого внешнего вызова
- База данных: самый частый источник медленного TTFB
- Синхронные вызовы внешних API перед ответом
- Очередь у сервера: когда воркеров не хватает на нагрузку
- Холодный старт: платите за инициализацию, а не за обработку
- Как найти виновника: замер, сравнение эндпоинтов, профилирование
TTFB — это сумма, а не атомарная величина
TTFB измеряет время от начала запроса до получения первого байта ответа. В него входят две принципиально разные части: сетевая (DNS, TCP- и TLS-хендшейк — этапы, которые при разумной задержке до сервера складываются в единицы, максимум первые десятки миллисекунд) и серверная — всё, что происходит между тем, как веб-сервер принял запрос, и тем, как он отдал первый байт тела ответа. Подробный разбор сетевой части, включая точный смысл time_namelookup, time_connect и time_appconnect в выводе curl -w, уже разобран в статье про разрыв между низким пингом и медленной загрузкой страницы — там же объяснено, почему ping вообще не измеряет то же самое, что видит браузер. Здесь сетевая часть намеренно остаётся за скобками — разбираем именно серверную половину TTFB, ту, что начинается сразу после TLS-хендшейка и заканчивается первым байтом ответа.
Формула для практики простая: TTFB = сетевой RTT (DNS + TCP + TLS) + время обработки на сервере. Если у вас ping 20 мс, а curl -w показывает time_starttransfer в 800 мс, на сетевую часть в этой сумме уходит от силы 60-100 мс (грубая оценка для нескольких RTT при 20-миллисекундной задержке — у вас цифра будет своя, в зависимости от версии TLS и длины цепочки сертификатов). Остальные 700+ мс — время, которое сервер физически потратил на сборку ответа, прежде чем начать его передавать. Дальше речь только про эту часть: из каких слоёв она складывается и как найти, какой именно съедает время.
Вычисления в приложении: бизнес-логика без единого внешнего вызова
Первый и самый очевидный слой — то, что делает сам код приложения, не выходя за пределы своего процесса: разбор входящих данных, валидация, циклы по коллекциям, сериализация ответа в JSON или рендеринг HTML-шаблона, работа с локальным кешем в памяти. Обычно это самая быстрая часть TTFB — миллисекунды, редко десятки миллисекунд, — но именно здесь прячутся неожиданные тормоза, если код написан без оглядки на объём данных.
Типичные причины, по которым чистые вычисления в приложении сами по себе становятся заметной долей TTFB:
- Рендеринг тяжёлого шаблона на каждый запрос без компиляции и кеширования шаблона — время рендеринга растёт линейно с числом запросов вместо того, чтобы оставаться константой.
- Сериализация большого объёма данных в JSON — не бесплатная операция, особенно на вложенных структурах или полях, которые никто не читает на клиенте, но которые всё равно нужно обойти.
- Синхронные операции с диском внутри обработчика запроса — чтение конфига или временного кеша с диска в момент обработки HTTP-запроса вместо того, чтобы держать это в памяти.
- Неэффективные алгоритмы на реальных объёмах данных. Код, который прекрасно работал на десяти тестовых записях, может оказаться квадратичным по сложности на проде, где записей десятки тысяч.
Отличительный признак этого слоя — TTFB растёт вместе с объёмом обрабатываемых данных, но не зависит от нагрузки на базу или внешние сервисы. Если вы воспроизводите медленный запрос локально с той же нагрузкой данных, а зависимостей вроде базы нет — почти наверняка проблема здесь.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверБаза данных: самый частый источник медленного TTFB
Если приложение синхронно ждёт ответа от базы данных, прежде чем начать отдавать HTML или JSON, время этого ожидания добавляется к TTFB напрямую и обычно оказывается самой большой его частью. Три классические причины:
Отсутствующий или неподходящий индекс. Запрос делает полное сканирование таблицы (Seq Scan в PostgreSQL, type: ALL в EXPLAIN MySQL) там, где мог бы использовать индекс и найти нужные строки за миллисекунды. На маленькой таблице разницы не видно, на таблице в миллионы строк полное сканирование может стоить сотни миллисекунд и больше. Проверяется командой:
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';
Если план показывает Seq Scan там, где ожидался поиск по индексу, и Execution Time внизу вывода заметно больше времени всего остального запроса — вот он, виновник. Отдельная и не менее частая ловушка — индекс формально есть, но планировщик его игнорирует из-за низкой избирательности условия или устаревшей статистики; это подробно разобрано в статье про то, почему база иногда сознательно отказывается от существующего индекса.
N+1 запросов через ORM. Если код получает список сущностей одним запросом, а затем в цикле для каждой делает ещё один запрос за связанными данными, итоговое число обращений к базе растёт линейно с размером списка — двадцать элементов превращаются в двадцать один запрос вместо одного с JOIN. Большинство ORM умеют это эффективно (eager loading), но по умолчанию часто настроены на ленивую загрузку, и проблема не заметна, пока список маленький.
Блокировки на таблице под конкурентной нагрузкой. Если два запроса одновременно пытаются изменить одни и те же строки, один ждёт снятия блокировки другим — и это ожидание целиком ложится в TTFB. Симптом характерный: TTFB одного эндпоинта скачет от запроса к запросу без видимой причины, особенно под нагрузкой, именно в момент пиковой конкурентности записи.
Практический способ найти проблему на уровне базы, не трогая код приложения, — включить логирование медленных запросов. В PostgreSQL это log_min_duration_statement в postgresql.conf:
log_min_duration_statement = 200
Все запросы дольше 200 мс попадут в лог с текстом SQL и временем выполнения — остаётся сопоставить их с моментами, когда TTFB был аномально большим.
Синхронные вызовы внешних API перед ответом
Если обработчик запроса перед тем, как отдать ответ, синхронно дожидается ответа от стороннего сервиса — платёжного шлюза, сервиса геолокации, курса валют, внешнего API рекомендаций — задержка этого вызова добавляется к TTFB один в один. Причём в худшую сторону сильнее, чем кажется: если внешний сервис в среднем отвечает быстро, но иногда подвисает, эти редкие подвисания напрямую становятся вашими всплесками TTFB, и от вашей инфраструктуры тут ничего не зависит.
Что усугубляет проблему:
- Отсутствие таймаута на вызов. Если HTTP-клиент настроен без явного таймаута (или с таймаутом по умолчанию в десятки секунд), а внешний сервис завис, ваш запрос будет ждать столько же.
- Последовательные вызовы вместо параллельных. Если нужно опросить два независимых сервиса по очереди вместо параллельно (
Promise.all,asyncio.gatherили эквивалент), TTFB получает сумму задержек обоих вместо задержки самого медленного. - Отсутствие деградации при недоступности внешнего сервиса. Без fallback (закешированное значение, ответ без опциональной части данных) каждый запрос ждёт полного таймаута, прежде чем вообще что-то отдать.
Практическое решение почти всегда одно и то же: явные короткие таймауты на все синхронные внешние вызовы, распараллеливание независимых вызовов, и там, где позволяет бизнес-логика — вынос необязательных внешних обращений из синхронного пути ответа, с догрузкой зависящей от них части отдельным запросом уже на клиенте.
Очередь у сервера: когда воркеров не хватает на нагрузку
Ещё до того, как ваш код вообще начал выполняться, запрос может простоять в очереди у веб-сервера или прокси, ожидая свободный воркер-процесс или поток. Это время тоже целиком входит в TTFB, но не имеет отношения ни к базе, ни к коду обработчика — оно связано с тем, что число одновременных запросов превысило число процессов, готовых их обрабатывать.
Характерный признак этого слоя — TTFB одного эндпоинта резко растёт под нагрузкой и почти нормальный в спокойное время, при этом сам код обработчика в изоляции выполняется одинаково быстро в обоих случаях: разница именно в том, сколько запрос простоял в очереди.
Для PHP-FPM это упирается в pm.max_children — если все дочерние процессы заняты, новый запрос встаёт в очередь на уровне сокета и ждёт освобождения воркера. Посмотреть состояние пула можно через pm.status:
curl http://127.0.0.1/status?full
Показатели active processes вплотную к max children и ненулевой listen queue — прямое указание, что пул упирается в лимит. Формула расчёта пула под конкретный объём памяти и профиль нагрузки, а также разница между static и dynamic режимом, подробно разобраны в статье про расчёт числа воркеров PHP-FPM до первых 502-х.
Для однопоточных event-loop окружений (Node.js и подобных) картина другая: если обработчик где-то выполняет синхронную блокирующую операцию (тяжёлые вычисления, синхронное чтение файла), она блокирует event loop целиком — и все остальные запросы простаивают в очереди, пока этот один обработчик не закончит работу. Для nginx как реверс-прокси похожая логика действует на уровне worker_connections: если лимит соединений на воркер исчерпан, новые либо встают в очередь accept-сокета, либо отклоняются, в зависимости от глубины backlog.
Холодный старт: платите за инициализацию, а не за обработку
Отдельный источник большого TTFB — холодный старт: первый запрос к только что запущенному процессу ждёт дольше последующих, потому что перед обработкой запроса приложению нужно завершить инициализацию.
В serverless-функциях это особенно заметно: если функция не вызывалась какое-то время, платформа выгружает её из памяти, и следующий вызов требует поднять новый экземпляр — распаковать код, запустить рантайм, выполнить инициализацию модуля (импорты, подключение к базе, прогрев кешей) — и только потом начать обработку самого запроса. С точки зрения клиента это выглядит как "иногда сайт отвечает за 800 мс, а иногда за 80 мс без видимой причины".
В контейнерных окружениях с автомасштабированием (Kubernetes и подобные) картина похожая: пока новый под не прошёл readiness-проверку, трафик идёт на существующие реплики — но сам процесс внутри свежего пода, если у него тяжёлая инициализация (прогрев JIT, соединения в пул к базе, справочники в память), в первые секунды после старта отвечает заметно медленнее прогретого.
Что обычно помогает смягчить эффект: держать минимум один тёплый инстанс вместо масштабирования от нуля; прогревать соединения к базе и внешним зависимостям при старте процесса, а не лениво при первом запросе; настраивать health-check с реальной проверкой готовности зависимостей, а не просто факта запуска процесса; для редко используемых, но критичных эндпоинтов — периодические прогревочные запросы, чтобы процесс не выгружался из памяти платформы между реальными обращениями.
Как найти виновника: замер, сравнение эндпоинтов, профилирование
Гадать между пятью перечисленными выше слоями бессмысленно — порядок диагностики всегда один и тот же: сначала отделить сетевую часть TTFB от серверной, потом сузить серверную часть до конкретного слоя.
Шаг 1. Замерить чистый TTFB. Здесь важна строка time_starttransfer минус time_appconnect — это и есть серверная часть TTFB, без DNS/TCP/TLS:
curl -o /dev/null -s -w "appconnect: %{time_appconnect}s\nstarttransfer: %{time_starttransfer}s\n" https://example.com/api/orders
Разница в сотни миллисекунд при низком ping до сервера — прямое указание искать строго на сервере, сеть можно исключить.
Шаг 2. Сравнить TTFB разных эндпоинтов одного приложения. Самый быстрый способ отличить проблему инфраструктуры от проблемы конкретного обработчика — запросить статический файл (картинку, robots.txt, любой ассет, который веб-сервер отдаёт напрямую, минуя код приложения) и динамический эндпоинт с той же машины:
curl -o /dev/null -s -w "%{time_starttransfer}\n" https://example.com/favicon.ico
curl -o /dev/null -s -w "%{time_starttransfer}\n" https://example.com/api/orders
Если статика отвечает быстро, а динамический маршрут — на порядок медленнее, проблема не в сервере, сети или веб-сервере как таковом — она внутри конкретного обработчика приложения или его зависимостей (база, внешний API). Если медленно отвечают оба, включая статику, — вероятнее очередь на уровне сервера или сетевые проблемы, которые вы не до конца исключили на шаге 1.
Шаг 3. Разбить обработку запроса на этапы внутри приложения. Дальше curl уже не поможет — нужно видеть, сколько времени уходит внутри самого обработчика на каждый шаг: валидацию, запрос к базе, вызов внешнего API, рендеринг. Два практичных подхода, без привязки к конкретному инструменту:
- Ручное логирование меток времени вокруг подозрительных участков кода — не требует новых зависимостей: засечь время перед и после запроса к базе, перед и после вызова внешнего API, залогировать разницу.
- APM-инструмент или трассировка запросов, который строит дерево вызовов внутри одного запроса и показывает его в виде таймлайна. Общий подход к профилированию живого продакшен-сервиса без остановки — в статье про профилирование приложения на проде.
- HTTP-заголовок
Server-Timing— стандартный способ передать разбивку времени по этапам прямо в ответе, видно в DevTools рядом с остальными фазами TTFB, без стороннего инструмента:
Server-Timing: db;dur=420, external_api;dur=180, render;dur=15
Такой заголовок сервер формирует сам, накапливая длительности по ходу обработки запроса, а браузер показывает их во вкладке Network в детальной панели конкретного запроса — удобно, когда нужно быстро увидеть разбивку без развёртывания полноценного APM.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
TTFB 800 мс на одном эндпоинте и 50 мс на другом того же приложения — с чего начинать?
Со сравнения: обращается ли медленный к базе или внешнему API, а быстрый — нет. Дальше замерьте время именно этих обращений через логирование меток или Server-Timing, не гадая.
Может ли TTFB быть большим без единого запроса к базе или внешнему API?
Да, если код сам делает тяжёлые вычисления синхронно — сериализацию большого объёма данных, рендеринг сложного шаблона без кеша, неэффективный алгоритм на реальном объёме данных. Проверяется воспроизведением запроса локально с теми же данными, но без внешних зависимостей.
Индекс есть, а запрос всё равно медленный — почему?
Индекс может существовать, но не использоваться планировщиком из-за низкой избирательности условия или устаревшей статистики. EXPLAIN ANALYZE покажет, использует ли план индекс или уходит в полное сканирование.
Стоит ли добавлять кеш, если TTFB упирается в базу?
Кеш убирает симптом для повторяющихся запросов, но не решает первопричину: если запрос неэффективен (нет индекса, N+1 через ORM), первый некешированный запрос всё равно будет медленным. Разумная последовательность — сначала исправить запрос, кеш добавлять уже поверх рабочего решения.
Как понять, что дело именно в холодном старте, а не в постоянно медленном коде?
Смотрите на распределение TTFB во времени: если аномально долгие ответы концентрируются сразу после деплоя или простоя, а не размазаны равномерно, — это холодный старт. Постоянно медленный обработчик даёт стабильно высокий TTFB независимо от момента старта процесса.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →