«Первый запрос долгий, дальше нормально» и «все запросы долгие» — это две разные неисправности, и лечатся они противоположными способами. Прогрев кэша не поможет приложению, у которого каждый запрос делает лишнюю работу. Оптимизация запроса не поможет приложению, которое пятнадцать секунд импортирует библиотеки.
Разделить их можно за минуту, и с этого стоит начинать всегда.
#Что понадобится
- Возможность запускать приложение с замером времени по этапам.
- Инструмент для повторяющихся запросов (
curlв цикле подойдёт). - Известная величина: сколько именно длится «долго». Без числа разговор бессмысленный.
- Работающая выкладка: перезапускать приложение вам придётся много раз, и если каждый перезапуск — это отдельное приключение, начните с маршрута о том, как поднять свой ИИ-сервис.
#Шаг 1. Разделить два случая
# три запроса подряд после перезапуска
for i in 1 2 3; do
curl -s -o /dev/null -w '%{time_total}\n' http://127.0.0.1:8000/timeline
done
Читаем результат:
| Что видно | Диагноз | Куда идти |
|---|---|---|
| 4.2 / 0.3 / 0.3 | холодный старт | шаги 2–4 |
| 4.2 / 4.1 / 4.0 | медленный запрос | это другая задача |
| 4.2 / 0.3 / 3.9 | что-то истекает | шаг 5 |
Третья строка — самая интересная и самая частая на реальных сервисах: кэш живёт минуту, соединение закрывается по простою, модель выгружается из памяти. Тогда «холодный старт» случается у вас по десять раз в день.
#Шаг 2. Разложить старт на этапы и померить каждый
Общая цифра «стартует 14 секунд» бесполезна. Нужна разбивка.
import time
t0 = time.perf_counter()
import app.web.main as m
t_import = time.perf_counter()
application = m.create_app()
t_build = time.perf_counter()
print(f"импорт {t_import - t0:.2f}s")
print(f"сборка app {t_build - t_import:.2f}s")
Типовое распределение и что за ним стоит:
| Этап | Что обычно занимает |
|---|---|
| Импорт модулей | тяжёлые библиотеки на верхнем уровне |
| Сборка приложения | регистрация маршрутов, разбор шаблонов |
| Схема базы | проверка журнала миграций |
| Первое соединение с базой | прагмы, открытие файла |
| Первый запрос | компиляция шаблона, прогрев кэшей |
| Первый вызов модели | загрузка весов — самое дорогое |
Самая частая находка на первом этапе — одна библиотека, импортированная на верхнем уровне модуля, которая нужна одной функции из ста. Лечится отложенным импортом внутри функции. Выглядит неаккуратно и почти всегда правильно.
Полезный приём, чтобы это не возвращалось: зафиксировать бюджет времени сборки приложения тестом. У нас такой тест собирает приложение отдельным процессом и валит сборку при превышении потолка. Случайно подтянутая тяжёлая зависимость ловится в тот же день, а не через полгода на жалобе. Про это и про другие архитектурные тесты — в статье про архитектуру веб-приложения на FastAPI.
Проверка схемы базы — этап, о котором стоит знать отдельно: на втором и последующих запусках она обязана быть почти бесплатной (у нас — доли секунды). Если она стоит секунды каждый раз, значит миграции применяются заново при каждом старте, и это отдельная неисправность, а не медленный старт.
#Шаг 3. Убрать работу со старта
Три приёма по убыванию отдачи.
Отложенный импорт. Тяжёлое — внутрь функции, где оно нужно.
Ленивая инициализация. Соединения, клиенты внешних сервисов, модели создаются при первом обращении, а не при запуске. Осторожно: вы переносите задержку с запуска на первый запрос, поэтому приём работает только в паре с прогревом.
Отказ от предвычислений. Всё, что можно посчитать по требованию и закэшировать, не должно считаться на старте — особенно если в типичной сессии это не понадобится.
#Шаг 4. Прогреть после старта, а не до
Прогрев — это ровно та работа, которую вы убрали со старта, выполненная после того, как приложение начало отвечать.
@asynccontextmanager
async def lifespan(app):
async def _warm() -> None:
await asyncio.sleep(1) # дать серверу начать принимать запросы
async with get_connection() as conn:
await conn.execute("SELECT 1")
templates.get_template("timeline.html")
task = asyncio.create_task(_warm()) # НЕ ждём его здесь
yield
task.cancel()
Ключевое — фоновая задача, а не ожидание в обработчике запуска. Прогрев, выполняемый синхронно, — это тот же самый медленный старт, просто написанный в другом месте.
Что стоит прогревать: одно соединение с базой, два-три самых частых шаблона, кэш конфигурации. Что прогревать не стоит: всё подряд. Прогрев ради полноты снова превращается в медленный старт.
Отдельная строка про языковую модель. Её загрузка — самая дорогая из всех, и у нас на этом был реальный инцидент: кнопка проверки соединения с локальной моделью вешала веб-сервер больше чем на минуту, потому что попадала на холодный старт модели и ждала синхронно. Вывод, годный для любого внешнего сервиса: обращение к внешней системе на пути запроса обязано иметь потолок ожидания, а долгая операция — уходить в фон с показом состояния.
#Шаг 5. Разобраться, что истекает
Если медленным оказывается не первый запрос, а каждый десятый, ищите то, что протухает.
- Кэш с коротким временем жизни. Проверьте, не пересчитывается ли на каждом промахе что-то дорогое.
- Соединения, закрывающиеся по простою. Первое обращение после паузы платит за установку заново.
- Модель, выгруженная из памяти. Локальные среды выполнения выгружают веса после простоя; интервал обычно настраивается.
- Кэш файловой системы. Файл, который перестал быть горячим, читается с диска.
Приём, который у нас дал самый заметный эффект на страницах: кэш на уровне процесса для дешёвых, но частых операций. Функция, вызываемая при отрисовке каждой строки списка и каждый раз открывающая соединение с базой, ускоряется на порядки простым кэшем в памяти процесса. Ищите такие вызовы в шаблонах: там они незаметны, потому что выглядят как одна безобидная функция.
Правило, выведенное оттуда же: в шаблонах не должно быть обращений к базе. Ни одного. Всё нужное для отрисовки готовится до неё.
#Проверка результата
# 1. время старта до готовности принимать запросы
time ( .venv/bin/python -c "import app.web.main as m; m.create_app()" )
# 2. время до первого успешного ответа после перезапуска
systemctl restart persona
until curl -sf -o /dev/null http://127.0.0.1:8000/healthz; do :; done
# засеките, сколько шёл цикл
# 3. первый запрос против последующих
for i in 1 2 3 4 5; do
curl -s -o /dev/null -w '%{time_total}\n' http://127.0.0.1:8000/timeline
done
Критерий приёмки: разница между первым и вторым значением в пункте 3 сократилась, а второе и последующие не выросли. Второе условие важно не меньше первого: лениво инициализируемое и не прогретое приложение перекладывает задержку на первого пользователя, и суммарно становится хуже.
Отдельно проверьте пункт 2 после перезагрузки машины, а не только после перезапуска сервиса: там к вашему старту добавляется всё остальное, что поднимается одновременно.
#Что может пойти не так
Проверка живости убивает здоровый сервер. Наш случай: под нагрузкой одна проба отвечала медленно, наблюдатель считал сервис мёртвым и перезапускал его; сервис уходил в холодный старт, отвечал ещё медленнее — и его убивали снова. Лечится порогом в несколько неудачных проб подряд и дешёвой ручкой живости, которая не ходит в базу. Подробности — в статье про мониторинг личного сервиса.
Ускорили старт, замедлили работу. Слишком агрессивная ленивая инициализация. Первый пользователь после каждого выпуска платит за всех.
Первый запрос быстрый, страница медленная. Тогда дело не в сервере: посмотрите, что происходит в браузере. Генерация стилей в браузере, некэшируемая статика, отсутствие версионирования адресов — типовые виновники; схема — в статье про PWA и кэш сервис-воркера.
Медленно стало после роста объёма данных. Это не холодный старт, а другая задача: запрос, который перестал попадать в индекс. Где у базы реальные пределы и что мерить — в статье про SQLite и FTS5 на миллионах строк.
Тормозит не приложение, а машина. Фоновая обработка — распознавание текста, эмбеддинги — способна съесть ресурсы так, что медленным станет всё. Порядок этой нагрузки разобран в статье о том, сколько это весит и тормозит ли ПК.
#Частые вопросы
#Сколько секунд старта считать нормой?
Зависит от того, как часто вы перезапускаете сервис. Для личного сервиса с выкладкой раз в неделю пятнадцать секунд не проблема. Для сервиса, который перезапускает наблюдатель после каждого сбоя, — уже проблема: пользователь видит эту паузу.
#Помогает ли увеличить число рабочих процессов?
Нет: каждый процесс платит за старт отдельно, а стартуют они одновременно. Больше процессов — больше конкуренции за диск и процессор в момент запуска, то есть старт выходит дольше, а не короче.
#Стоит ли держать модель постоянно загруженной?
Если она у вас локальная и используется каждый день — да, это самый большой единичный выигрыш. Плата — постоянно занятая память. Компромисс: настроить интервал выгрузки под свой реальный ритм работы.
#Как отличить медленный старт от медленной сети?
Замерить с самой машины по локальному адресу и снаружи. Совпало — дело в приложении. Расходится — дело в сети или в прокси; там свои настройки, которые могут добавлять секунды.
#Нужно ли оптимизировать старт, если сервис работает месяцами?
Нужно ровно настолько, насколько вы цените возможность быстро перезапуститься. Приложение, которое стартует минуту, превращает каждую выкладку и каждый сбой в минуту недоступности, а наблюдателя, перезапускающего его по таймауту, — в источник новых сбоев.