awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Стриминг и backpressure: когда клиент читает медленнее, чем вы пишете

Суть Ответ не закончен, когда обработчик вернул значение — он закончен, когда байты слиты. Если клиент читает медленнее, чем сервер пишет, и backpressure проигнорирован, ответ буферизуется в RAM, пока процесс не умрёт.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 16 min

Сервис отчётов стримит CSV-экспорт на 400 МБ. В тестах работает. В проде он OOM-ится и перезапускается каждый день после обеда. Код экспорта в порядке — он читает строки и вызывает res.write() в плотном цикле. Баг в том, что один клиент скачивает через телефон на раздаче в 200 КБ/с, и сервер генерирует строки куда быстрее, чем телефон успевает их читать. Непрочитанные байты копятся в куче процесса, пока ядро его не убьёт.

Буфер против стрима

Есть два способа отправить тело. Буфер: собрать весь ответ в памяти, выставить Content-Length, отправить. Просто, но экспорт на 400 МБ означает 400 МБ резидентной памяти на каждую одновременную загрузку. Стрим: производить тело по частям и писать каждую часть, как только она готова, используя Transfer-Encoding: chunked, чтобы общий размер не нужно было знать заранее. Стриминг держит память плоской — если вы уважаете backpressure.

Backpressure: запись, которая говорит «стоп»

У каждого writable-стрима есть буфер с порогом, называемым high-water mark (дефолт Node — 16 КБ для байтовых стримов, 64 КБ для файловых). Механизм:

  • res.write(chunk) возвращает true, пока внутренний буфер ниже high-water mark — пишите дальше.
  • Возвращает false, когда буфер полон — прекратите писать и ждите события 'drain', прежде чем продолжить.

write(), вернувший false, не означает, что запись провалилась. Он означает, что потребитель отстаёт и буфер полон. Если вы проигнорируете false и продолжите писать, данные не исчезают — они накапливаются во внутреннем буфере стрима в куче вашего процесса. Это и есть OOM. Лечение — соблюсти сигнал: приостановить производство до 'drain'. pipe() и pipeline() делают это за вас автоматически, поэтому pipeline(source, res) — безопасный дефолт, а самописный while-цикл из write() — классический футган.

ПаттернПамять при медленном клиентеВердикт
Буферизовать всё тело, затем отправитьO(размера тела) на соединениеТолько для маленьких тел
write() в цикле, игнор возвращаемого значенияРастёт без границ → OOMБаг
write() + ждать 'drain' на falseПлоская (≈ high-water mark)Корректно
pipeline(source, res)Плоская, обрабатывает ошибки + очисткуКорректно, предпочтительно

Почему буфер наполняется: это черепахи до самого TCP

Backpressure — не изобретение Node; это прикладная поверхность цепочки окон управления потоком. TCP-окно приёма клиента сообщает, сколько он может принять. Когда приложение клиента читает медленно, его буфер приёма наполняется, оно сжимает анонсированное окно, и буфер отправки в ядре сервера перестаёт сливаться. Тогда writable-стрим в userland перестаёт сливаться, write() возвращает false — и, если вы слушаете, ваш код прекращает производить. Каждый слой давит назад на тот, что выше. Игнорировать backpressure — значит расцепить вашу скорость производства со всей этой цепочкой, и разрыв накапливается в единственном месте без управления потоком: в вашей куче.

Почему это работает

Почему HTTP/2 делает backpressure одновременно важнее и тоньше? HTTP/2 мультиплексирует много стримов поверх одного TCP-соединения, и у него свои окна управления потоком на каждый стрим (дефолт 64 КБ) поверх окна TCP уровня соединения. Один медленный стрим может застрять, если его окно исчерпано, но поскольку все стримы делят одно TCP-соединение, head-of-line blocking на уровне TCP может застопорить и несвязанные стримы. Поэтому HTTP/2-сервер должен уважать два слоя окон, а плохо ведущая себя большая загрузка может заморить маленькие одновременные запросы на том же соединении способами, невидимыми на уровне HTTP/1.1.

Атака медленного потребителя

Тот же механизм — вектор отказа в обслуживании. Slowloris и slow-read атаки намеренно читают ответы по одному байту, чтобы держать соединения открытыми и занимать буферы на стороне сервера. Сервер, буферизующий на соединение и игнорирующий backpressure, можно исчерпать горсткой медленных клиентов — без всякого объёма. Защиты те же, что и исправления корректности, плюс лимиты: уважать backpressure, ограничивать буферизацию на соединение и ставить таймауты записи/простоя, чтобы застрявший слив был брошен, а не удержан навсегда (тема следующего урока).

Викторина

Стриминговый эндпоинт OOM-ится только когда клиент скачивает по очень медленному каналу. Код вызывает res.write() в цикле и игнорирует возвращаемое значение. Что происходит?

Викторина

Что на самом деле означает res.write(), вернувший false?

Викторина

Почему backpressure описывают как прикладную поверхность управления потоком TCP?

Вспомните перед уходом
  1. 01
    Что именно сигнализирует res.write(), вернувший false, и какова правильная реакция?
  2. 02
    Проследите, как медленный клиент в итоге вызывает OOM на стороне сервера, слой за слоем.
  3. 03
    Почему HTTP/2 добавляет второй слой заботы о backpressure поверх TCP, и как медленная загрузка может навредить другим запросам?
Итог

Ответ заканчивается, когда его байты слиты, а не когда обработчик вернул значение — и трудный случай это клиент, читающий медленнее, чем сервер пишет. Буферизация всего тела стоит O(размера тела) памяти на соединение; стриминг держит память плоской лишь если уважать backpressure. Сигнал — write(), возвращающий false, когда внутренний буфер пересекает high-water mark (дефолт 16 КБ): остановиться и возобновить на ‘drain’, или дать pipeline() управлять этим. Игнорирование сигнала копит непрочитанные байты в куче, пока процесс не убьют по OOM. Backpressure — userland-поверхность цепочки окон управления потоком вплоть до окна приёма TCP, а HTTP/2 добавляет окна на каждый стрим поверх. Тот же механизм — вектор DoS медленного потребителя, и поэтому финальная остановка, таймауты, должна бросать слив, который никогда не завершается.

Связанные уроки
встречается в185
Продолжить восхождение ↑Таймауты и хвостовая задержка: бюджеты, дедлайны и ловушка fan-out
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.