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

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

Backpressure и ограниченная конкурентность

Суть Async легко запускает больше работы, чем система закончит, и цикл не остановит. Backpressure даёт продюсеру ощутить медленного консьюмера; ограниченная конкурентность ограничивает, сколько бежит разом. Без них неограниченный fan-out становится OOM и перегрузкой downstream.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 16 min

Скрипт миграции читает миллион ID пользователей и «для каждого достань профиль и сделай upsert». Написанный очевидным async-способом — await Promise.all(ids.map(processUser)) — он запускает миллион конкурентных операций в один и тот же миг. База открывает соединения, пока не откажет, память лезет вверх, пока миллион ожидающих промисов и их замыканий копятся, и процесс убивают по OOM раньше, чем закончится первая тысяча. Код был корректен. Конкурентность была неограниченной, а event loop радостно запустит бесконечную работу, которую никогда не сможет закончить.

Цикл тебя не остановит

Дар event loop — запустить тысячи операций дёшево — это и заряженное ружьё. Ничто в Promise.all(items.map(fn)) не ограничивает, сколько fn в полёте; он запускает их все, немедленно. Для десяти элементов это нормально. Для миллиона это самонанесённый отказ в обслуживании: каждая операция в полёте держит память (буферы, замыкания, ожидающий промис), и каждая молотит то, что вызывает. Две разные дисциплины укрощают это: backpressure (дать медленному консьюмеру толкнуть назад быстрого продюсера) и ограниченная конкурентность (ограничить, сколько операций бежит разом). Они лечат одну болезнь — производство быстрее, чем потребление — на разных слоях.

Backpressure: консьюмер толкает назад

Backpressure — это сигнал обратной связи от медленного консьюмера быстрому продюсеру: «перестань слать, я полон». В Node-стримах он встроен. Когда ты writable.write(chunk) и внутренний буфер превышает highWaterMark (по умолчанию 16 КБ для байтовых стримов, 16 объектов в object mode), write() возвращает false — твой сигнал перестать писать, пока стрим не испустит событие drain, означающее, что буфер опустел. Уважь сигнал — и память остаётся ограниченной; проигнорируй — и Node продолжит буферить каждый chunk в памяти, пока процесс не словит OOM.

Причина, почему pipe() и pipeline() — рекомендуемый способ соединять стримы, в том, что они проводят это рукопожатие автоматически — паузя readable, когда writable полон, возобновляя на drain — поэтому файл в 50 ГБ копируется через несколько сотен КБ живого буфера вместо загрузки целиком. Ручные циклы read/write, пропускающие танец false/drain, — классический источник OOM при стриминге.

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

Почему highWaterMark — порог, а не жёсткий потолок? Это линия, на которой стрим начинает говорить «false» — backpressure совещательный, не принудительный. Один write() chunk-а в 10 МБ всё равно буферит все 10 МБ, хотя метка 16 КБ; метка лишь управляет тем, когда стрим сигналит о полноте, а не сколько данный write может добавить. Это важно, потому что backpressure защищает тебя, только если ты проверяешь возвращаемое значение и ждёшь drain. Код, зовущий write() в плотном цикле, игнорируя булево, побеждает весь механизм — буфер растёт без границ независимо от high-water mark. Метка — это сигнал вежливости между кооперирующими сторонами; она ничего не делает для кода, который не слушает.

Ограниченная конкурентность: ограничь fan-out

Стримы решают случай байтового пайплайна. Другой случай — N независимых async-задач — достань эти 10 000 URL, обработай этот миллион строк — где фикс это лимит конкурентности: бежать максимум k разом, и как только каждая заканчивается, запускать следующую. Миграция выше становится безопасной заменой Promise.all(ids.map(fn)) на лимитер (например p-limit(20)), чтобы в полёте было только 20 операций, или паттерном worker-pool/for await, который тянет из списка по мере освобождения ёмкости.

Числа доказывают: последовательный цикл for...of await и ограниченный Promise.all могут отличаться на порядки по реальному времени (один бенчмарк: ~30 с последовательно против ~340 мс конкурентно), но неограниченная конкурентность не обгоняет ограниченную — за пределом того, что downstream способен впитать, лишняя работа в полёте лишь добавляет очередь, память и отказы. Сладкая точка — наибольшее k, которое downstream терпит, а не бесконечность. Это k обычно задаётся собственным лимитом downstream: пул соединений на 20, лимит API, IOPS диска.

Три режима, одно решение

Представь спектр. Последовательный (await в цикле) — по одному за раз: безопасно, медленно, ни на что не давит. Неограниченный (Promise.all по огромному списку) — всё сразу: быстро стартует, катастрофичен на масштабе. Ограниченный (лимит k) — продакшен-ответ: достаточно быстро, предсказуемая память, уважает downstream. Старший рефлекс при виде Promise.all(bigArray.map(...)) мгновенный: что это ограничивает? Если размер массива контролируется атакующим или данными, неограниченный Promise.all — латентный сбой.

ПаттернВ полётеСкоростьРиск
Последовательный for await1Самый медленныйНет, но тратит простаивающую ёмкость
Неограниченный Promise.all(map)Все NБыстрый старт, затем коллапсOOM, перегрузка downstream
Ограниченный (лимит k)Максимум kБыстро и стабильноНастрой k под лимит downstream
Стрим pipeline()~highWaterMarkРовноНельзя обходить backpressure
Викторина

`await Promise.all(millionIds.map(processUser))` убивает процесс по OOM. Что на самом деле пошло не так?

Викторина

В Node-стриме `writable.write(chunk)` возвращает `false`. Что это сигналит и что делать?

Викторина

Что обычно задаёт правильный лимит конкурентности *k* для ограниченного fan-out async-задач?

Вспомните перед уходом
  1. 01
    Почему Promise.all по огромному массиву вызывает сбой и какой фикс?
  2. 02
    Как работает backpressure Node-стримов и почему pipe/pipeline предпочтительнее ручных циклов?
  3. 03
    Сравни последовательную, неограниченную и ограниченную конкурентность и объясни, как выбрать k.
Итог

Та же дешевизна, что даёт event loop запустить тысячи операций, даёт ему и запустить работу, которую он никогда не сможет закончить, и он тебя не остановит — Promise.all по массиву в миллион элементов запускает миллион операций разом и убивает процесс по OOM, молотя каждый downstream. Две дисциплины подгоняют скорость производства к скорости потребления. Backpressure — это консьюмер, толкающий назад продюсера: запись в стрим возвращает false за highWaterMark (16 КБ или 16 объектов), и ты должен ждать drain, что pipe и pipeline делают автоматически, поэтому огромные файлы текут через крошечные живые буферы; high-water mark — лишь сигнальный порог, поэтому код, игнорирующий булево, побеждает его целиком. Ограниченная конкурентность ограничивает независимые задачи k в полёте, заменяя неограниченный fan-out лимитером или пулом воркеров, где k задаётся тем, что downstream способен впитать — пул на 20, лимит API, IOPS диска — потому что за этим больше конкурентности покупает лишь очередь и отказ. Последовательный безопасен, но медлен, неограниченный быстр, затем катастрофичен, ограниченный — ответ. С работой, сброшенной с цикла, и притоком, придушенным до ёмкости, финальный урок отдаляется на всю систему под нагрузкой: как очередь заставляет хвостовую задержку взрываться у насыщения и почему один цикл — это одно ядро.

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

Trademarks belong to their respective owners. Editorial reference only.