Как я победил медленную загрузку файлов в Web-Spring-приложение, стоящее за Nginx

Как я победил медленную загрузку файлов в Web-Spring-приложение, стоящее за Nginx

С чего всё началось

Клиенты моей системы периодически жаловались на долгую загрузку файлов. Происходило это не часто, но в последнее время — всё чаще. Я полез разбираться.

Первым делом добавил логирование на уровне контроллера через CommonsRequestLoggingFilter — стандартный фильтр Spring, который пишет Before request в момент прихода запроса и After request по завершении обработки. Логика простая: смотрю разницу во времени между этими двумя строками — вот и время обработки запроса.

Но результат меня озадачил. Строка Before request появлялась в логах не в момент, когда клиент начинал загрузку файла, а гораздо позже — иногда через десятки секунд после того, как (по ощущениям и по времени клика в интерфейсе) запрос должен был уже стучаться в сервер.

Шаг 1: фильтр логирования — не первый в цепочке

Первая гипотеза была логичной: CommonsRequestLoggingFilter, зарегистрированный простым Spring-бином, попадает в цепочку фильтров сервлета не первым — где-то раньше него могли отрабатывать другие фильтры (security, CORS, свои кастомные), и если кто-то из них раньше трогает параметры запроса, это могло приводить к неожиданному поведению.

Исправление — явно выставить порядок регистрации фильтра на передний край цепочки:

@Bean
public FilterRegistrationBean<CommonsRequestLoggingFilter> logFilterRegistration(CommonsRequestLoggingFilter filter) {
    FilterRegistrationBean<CommonsRequestLoggingFilter> reg = new FilterRegistrationBean<>(filter);
    reg.setOrder(Ordered.HIGHEST_PRECEDENCE);
    return reg;
}

Локально это сработало идеально — Before request стала появляться сразу при отправке запроса. Но на боевом сервере проблема осталась: лог всё так же запаздывал. Значит, дело было не в порядке фильтров внутри приложения — где-то раньше, ещё до Tomcat, запрос застревал.

Шаг 2: буферизация запросов в Nginx

На проде перед Java-приложением стоит классический реверс-прокси — Nginx. И тут я вспомнил про директиву proxy_request_buffering, которая по умолчанию включена (on).

Как это работает. Когда буферизация включена, Nginx не начинает пересылать запрос на backend сразу по мере получения данных от клиента. Вместо этого он сначала полностью принимает всё тело запроса — сохраняя его во временный файл на диске (или в память, если тело небольшое) — и только после этого, когда весь запрос у него на руках, начинает передавать его на upstream (в моём случае — Tomcat).

Для обычных API-запросов с маленькими JSON-телами эта разница незаметна. Но для загрузки больших файлов это означает: пока клиент с медленным интернетом заливает файл, Nginx честно ждёт и складывает байты к себе — а Tomcat всё это время вообще не видит никакого запроса. Именно поэтому Before request в моих логах появлялась с большой задержкой: приложение узнавало о запросе только тогда, когда Nginx уже получил файл целиком.

Отключение буферизации делается точечно, для нужного location:

location /api/files {
    proxy_request_buffering off;
    proxy_pass http://your-backend;
    client_max_body_size 100M;
}

С proxy_request_buffering off Nginx начинает пробрасывать байты на backend потоково, по мере их поступления от клиента, не дожидаясь полного тела запроса.

Результат превзошёл ожидания — но не в ту сторону, на которую я рассчитывал. Логи действительно стали честными: Before request теперь появлялась ровно в момент начала запроса. Но время загрузки большого файла составило 6 минут. С включённой буферизацией (то есть в изначальном, «проблемном» варианте) — было 4 минуты.

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

Шаг 3: а что там с виртуальными потоками?

Я вспомнил, что недавно перевёл приложение на Java 25 и Spring Boot 4 — но саму миграцию делал в первую очередь ради новых API и обновлений зависимостей, про виртуальные потоки (virtual threads) даже не подумал: я был уверен, что раз уж проект современный, то Tomcat и так работает на них.

Оказалось — нет. Виртуальные потоки не включаются автоматически ни переходом на новую версию Java, ни переходом на Spring Boot 4. Об этом ясно говорили и имена потоков в логах — классические http-nio-10371-exec-N, то есть обычный пул платформенных ОС-потоков Tomcat.

Включается это одной строчкой:

spring.threads.virtual.enabled=true

После рестарта имена потоков в логах моментально изменились:

Было:

2026-08-27T12:13:21.723Z DEBUG --- [http-nio-10371-exec-10] r.k.k.c.RequestLoggingFilterConfig$1 : Before request [POST /api/files]

Стало:

2026-08-27T13:19:15.887Z DEBUG --- [tomcat-handler-19] r.k.k.c.RequestLoggingFilterConfig$1 : Before request [POST /api/files]

tomcat-handler-N вместо http-nio-10371-exec-N — явный признак того, что запрос теперь обрабатывается на виртуальном потоке. Технически при этом сам Tomcat не становится «виртуально-нативным» — он по-прежнему использует свой протокол-хендлер, но конкретно обработку запроса Spring Boot теперь диспетчеризует на виртуальный поток, который в момент блокирующего ожидания (чтения байт из медленного сокета клиента, вызова к БД, HTTP-запроса наружу) отсоединяется («unmount») от несущего ОС-потока — освобождая его для другой работы — и снова «примонтируется», когда данные подоспеют.

Дополнительный бонус, который я увидел в логах: пока один запрос POST /api/files «висел» две минуты в ожидании клиента, параллельно спокойно отрабатывали и плановые задачи (@Scheduled кэш-инвалидация), и другие запросы к внешним интеграциям — никто никого не блокировал:

13:19:15.887 [tomcat-handler-19] Before request [POST /api/files]
13:19:27.801 [scheduling-2]      Autoevict cache for keys: [OpenNotificationsSummary]
13:19:39.084 [tomcat-handler-23] vk iterate: messages.getConversations
13:19:57.802 [scheduling-2]      Autoevict cache for keys: [isSystemBlocked, isGptUsedInSystem]
13:20:12.803 [scheduling-2]      Autoevict cache for keys: [OpenNotificationsSummary]
13:20:23.890 [tomcat-handler-29] vk iterate: messages.getConversations
13:20:57.805 [scheduling-2]      Autoevict cache for keys: [OpenNotificationsSummary]
13:21:09.046 [tomcat-handler-35] vk iterate: messages.getConversations
13:21:10.932 [tomcat-handler-19] Start write file to fileWrapper service. img13-2.bmp
13:21:11.200 [tomcat-handler-19] Result of calling REST: status: 200 OK
13:21:11.202 [tomcat-handler-19] End file writing
13:21:11.375 [tomcat-handler-19] After request [POST /api/files]

Отлично — но виртуальные потоки сами по себе не победили мои 4 минуты, которые давала включённая буферизация Nginx. Она всё так же продолжала честно принимать файл целиком на диск, прежде чем отдать его приложению, и это время никуда не девалось.

Шаг 4: комбинация — вот тут и случилось чудо

Я решил ещё раз отключить proxy_request_buffering, но теперь уже с включёнными виртуальными потоками на стороне Tomcat.

Результат: 20 секунд.

Не 4 минуты, не 6 минут — 20 секунд. Я не поверил своим глазам и на всякий случай откатил буферизацию обратно, чтобы проверить, что дело не в случайности сети. Вернулось привычное — 4 минуты. Отключил снова — снова 20 секунд.

Почему так вышло

Две проблемы наложились друг на друга, и по отдельности решение одной из них ничего не давало (а иногда даже вредило), пока не были сняты обе:

  1. Nginx с буферизацией добавляет к общей задержке отдельную, полностью синхронную фазу — сначала весь файл должен доехать от клиента до Nginx и осесть на диске, и только потом начинается фаза «Nginx → Tomcat». Эти фазы идут строго последовательно, а не параллельно, поэтому итоговое время — это фактически сумма двух этапов передачи одного и того же файла.
  2. Платформенные потоки Tomcat без буферизации Nginx оказывались единственным местом, где происходило реальное, длительное блокирующее ожидание байт от медленного клиента — и это ожидание «съедало» воркер-поток целиком на всю длительность загрузки. При достаточной конкуренции за ограниченный пул потоков (фоновые задачи, другие запросы, интеграции) это могло приводить к дополнительным задержкам в планировании и обработке — отсюда и разница «6 минут против 4» на голых платформенных потоках.

Только когда байты пошли напрямую от клиента к Tomcat (без промежуточного буфера на диске Nginx), и ожидание этих байт перестало держать в заложниках дефицитный ресурс (платформенный поток), — вся система заработала так, как должна: задержка свелась к честному физическому времени передачи файла по сети, без искусственных накладных расходов ни на буферизацию, ни на конкуренцию за потоки.

Итог

Никогда бы не докопался до этого, если бы не прошёл через всю цепочку диагностики шаг за шагом. Каждое отдельное изменение либо ничего не меняло, либо, как ни странно, ухудшало картину — и только их совместное применение дало кратный (не на проценты, а в 12+ раз) прирост скорости.

Чек-лист: как быстро загружать файлы в Spring-приложении за Nginx

  1. Логируйте начало и конец запроса отдельным фильтром (CommonsRequestLoggingFilter или аналог) и явно ставьте его первым в цепочке (FilterRegistrationBean + Ordered.HIGHEST_PRECEDENCE) — иначе не увидите реальную картину.
  2. Отключайте proxy_request_buffering для эндпоинтов приёма файлов в Nginx:
    location /api/files {
       proxy_request_buffering off;
       client_max_body_size 100M;
    }

    Это уберёт лишний последовательный этап «клиент → диск Nginx → Tomcat» и позволит серверу видеть запрос сразу.

  3. Включайте виртуальные потоки, если у вас Java 21+ (в моём случае — Java 25) и Spring Boot 3.2+/4.x:
    spring.threads.virtual.enabled=true

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

  4. Проверяйте, что переключение реально произошло, по именам потоков в логах: tomcat-handler-N вместо http-nio-*-exec-N.
  5. Для по-настоящему больших файлов и совсем медленных клиентов — рассмотрите переход на presigned URL (S3-совместимое хранилище генерирует временную ссылку, и клиент загружает файл напрямую в объектное хранилище, минуя ваш сервер и Nginx целиком). Это следующий логичный шаг после описанного выше — он убирает сервер из цепочки передачи файла вообще, а не просто оптимизирует прохождение через него.

Комбинация «буферизация Nginx выключена + виртуальные потоки включены» сама по себе — это уже готовый рецепт, который может дать кратный прирост скорости приёма файлов без единой строчки изменений в бизнес-логике.

(Просмотрено 2 раз, 2 раз за сегодня)

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *