
Всем привет! Я Виктор Кугай, руководитель команды разработки спецпроектов в Тинькофф. Мы создаем геймификационные проекты, основанные на данных, чтобы познакомить пользователей с экосистемой компании и повысить узнаваемость бренда.
Расскажу, как с помощью Node.js Streams и механизма Back Pressure протокола TCP реализовать пакетную обработку сотен гигабайтов данных на машинах с жестким лимитом памяти.
Как мы передаем по сети большие объемы данных
Спецпроекты помогают пользователям в игровой форме вовлечься в использование экосистемы компании. Например, пользователь переводит в благотворительный фонд X рублей в месяц или выполняет какое-то другое действие в приложении Тинькофф и получает за это экстрабонусы.
Для долгосрочных спецпроектов отлично подходит потоковая обработка данных с помощью Apache Kafka. Например, для реализации игровых механик наша команда обрабатывает более 150 000 000 событий в сутки суммарным объемом свыше 250 ГБ.
Для краткосрочных спецпроектов — сроком жизни несколько месяцев — создание новой потоковой интеграции сопряжено с рисками. Например, настройка интеграции потребует больше ресурсов и времени, чем мы ожидали, и мы не успеем подготовить надежную интеграцию к старту проекта.
В этом случае на помощь приходит сценарий пакетной обработки данных:
Платформа для совместной обработки данных Helicopter по расписанию инициирует агрегацию данных в Data Warehouse.
Процесс отправляет CSV-файл с составным содержимым формата multipart/form-data по HTTP.
Node.js-сервис валидирует входящие данные и сохраняет в PostgreSQL базу данных спецпроекта.
Мы используем формат multipart/form-data для передачи составных данных по сети, потому что во многих языках программирования есть его поддержка, а реализация не меняется в зависимости от языка.
Например, в JavaScript для загрузки составных данных используется объект FormData. В браузере отправить составные данные по сети можно с помощью объекта XMLHttpRequest и Fetch API, а в Node.js — с помощью модуля http/https, который используется в библиотеке axios, или модуля net в библиотеке undici.
const body = new FormData(); body.append('file', file); fetch(url, {method: 'POST', body});
В python поддержка multipart/form-data встроена в модуль requests и дополнительных преобразований не требуется, поэтому multipart/form-data удобно использовать при написании скриптов переливки данных.
import requests requests.post( url, files={'file', file} )
Загружать составные данные несложно, но что произойдет, если игнорировать контроль потока при обработке данных? Потребление памяти превысит лимиты, и приложение упадет вот с такой ошибкой OOMKilled:
$ docker inspect --format='{{json .State}}' <container> { "Status": "exited", "Running": false, "Paused": false, "Restarting": false, "OOMKilled": true, "Dead": false, "Pid": 0, "ExitCode": 137, "Error": "", "StartedAt": "2024-03-11T15:30:38.222462386Z", "FinishedAt": "2024-03-11T15:31:24.086041716Z" }
Контроль потока при обработке составного содержимого сетевых запросов в Node.js
Пример ручного контроля потока с помощью подписки на событие data:
const HIGH_WATER_MARK = 1_048_576; // ~1MB let buffer = Buffer.from([]); readable.on('data', (data) => { buffer = Buffer.concat([buffer, data]); if (buffer.byteLength > HIGH_WATER_MARK) { readable.pause(); // ... // === Обработать данные === // ... buffer = Buffer.from([]); readable.resume(); } });
В Node.js v10 добавили поддержку асинхронной обработки данных с помощью цикла for await … of:
for await (const data of readable) { // ... // === Обработать данные === // ... }
Верхнеуровневый алгоритм обработки данных с помощью цикла for await … of такой:
Пакеты данных аккумулируются в буфере.
Общий размер данных в буфере достигает лимита highWaterMark.
Потребление данных приостанавливается.
Все непрочитанные пакеты объединяются.
Данные поступают в цикл for await … of.
Данные обрабатываются в теле цикла.
Буфер освобождается.
Потребление данных продолжается.
В случае с асинхронными итераторами данные сначала накапливаются в буфере до порогового значения highWaterMark. Затем потребление данных из потока приостанавливается, и только после завершения обработки оно продолжается. Ручное управление потоком с помощью readable.pause() и readable.resume() игнорируется.
В случае с подпиской на событие data за факт обработки данных принимается вызов функции-подписчика, поэтому полезная работа выполняется конкурентно, что может привести к переполнению памяти. Для контроля переполнения памяти нужно вручную ставить обработку данных на паузу с помощью readable.pause() и readable.resume().
Параметр highWaterMark конфигурирует пороговое значение буфера для всех типов стримов в Node.js. Для Duplex- и Transform-стримов предусмотрено два независимых буфера. Значение highWaterMark не является строгим лимитом памяти и носит рекомендательный характер, то есть реальная утилизация памяти буфером может существенно превышать значение highWaterMark.
Параметр highWaterMark имеет два режима конфигурации: в байтах и в символах.
В байтах параметр конфигурируется по умолчанию, а в символах — при явном указании кодировки стрима с помощью stream.setEncoding(...). Один символ обычно занимает один байт (latin1, ascii), но может занимать несколько байт, поэтому конфигурация highWaterMark в символах не эквивалентна конфигурации в байтах.
В документации Node.js конфигурация setEncodingупоминается в самом последнем абзаце — его легко пропустить.
Отличия в обработке составного содержимого сетевых запросов в Node.js
При подписке на событие data обработчик вызывается каждый раз при чтении следующего TCP-пакета. Параметр highWaterMark не влияет на размер TCP-пакета, поэтому использовать подписку на событие data не очень удобно. Во-первых, необходимо вручную контролировать поток с помощью readable.pause() и readable.resume(). Во-вторых, данные обрабатываются слишком часто. Например, пакетами по 64 килобайта, потому что это максимальный размер пакета в протоколе IPv4.
Обработка данных с помощью асинхронных итераторов буферизует TCP-пакеты до порогового значения highWaterMark. Затем пакеты объединяются в один чанк и отправляются в тело цикла. Размер чанка в цикле зависит от значения highWaterMark, поэтому можно управлять размерами чанков более гибко. Важно помнить, что highWaterMark — это не жесткий лимит, а всего лишь пороговое значение, которое может быть превышено или проигнорировано.
Механизм Back Pressure при обработке сетевых запросов в Node.js
Node.js использует возможности библиотеки libuv для контроля сетевых соединений, которая с помощью механизма окон протокола TCP регулирует пропускную способность потока.
Как только размер данных в буфере превышает пороговое значение highWaterMark, библиотека libuv уменьшает размер TCP-окна до нуля и тем самым сигнализирует, что отправку данных нужно приостановить.
Параметры TCP-сессии, которые имеют ключевое значение для управления потоком:
Max Segment Size — максимальный размер сегмента для передачи по сети. Нужен, чтобы отправитель и получатель не передавали слишком большие пакеты друг другу.
Window Scale — коэффициент для расчета актуального размера окна. Нужен, чтобы экспоненциально увеличивать размер окна.
Window — размер окна. Нужен, чтобы подсказывать отправителю количество данных, которые потребитель может обработать.
Верхнеуровневый алгоритм управления потоком с помощью окон в протоколе TCP выглядит так:
Клиент и сервер обмениваются параметрами для установки TCP-соединения.
Клиент передает на сервер пакеты размером не больше Max Segment Size.
Сервер буферизует пакеты, подтверждает их получение и уведомляет об уменьшении окна.
Клиент приостанавливает отправку пакетов, когда окно становится недостаточным для отправки следующего пакета.
Сервер обрабатывает пакеты из буфера и уведомляет клиента об увеличении окна.
Клиент продолжает отправку данных.
Если клиент игнорирует размер окна и продолжает отправлять пакеты, лишние пакеты будут отброшены.
Отправка потоковых данных на практике
Рассмотрим пример отправки потоковых данных на сервер с контролем переполнения памяти и использованием утилиты tcpdump для логирования TCP-пакетов. Будем использовать только встроенные в Node.js модули node:stream и node:http, чтобы исключить влияние фреймворков на Readable-стрим.
1. Реализуем исходящий поток данных. Создаем Readable Stream, который генерирует данные чанками по 5 МБ, и отправляем с помощью функции request из модуля node:http.
/* client.js */ const {request} = require('node:http'); const {Readable} = require('node:stream'); const STREAM_SIZE_IN_BYTES = 104_857_600; // ~100MB const STREAM_CHUNK_SIZE_IN_BYTES = 5_242_880; // ~5MB main().catch((error) => { console.log(error); process.exit(1); }); async function main() { await new Promise((resolve) => { let counter = 0; const readableStream = new Readable({ read() { if (counter < STREAM_SIZE_IN_BYTES) { this.push(Buffer.alloc(STREAM_SIZE_IN_BYTES)); counter += STREAM_CHUNK_SIZE_IN_BYTES; } else { this.push(null); } }, }); const options = { path: '/', method: 'POST', protocol: 'http:', hostname: 'localhost', port: '3000', }; const req = request(options, (res) => { res.on('end', () => { resolve(null); }); }); readableStream.pipe(req); }); }
2. Реализуем обработку входящего потока. HighWaterMark указываем 1 МБ, чтобы убедиться, что управление потоком работает. Каждая итерация цикла использует задержку, чтобы имитировать сложную работу, которая выполняется в теле цикла.
/* server.js */ const {createServer} = require('node:http'); const {PassThrough} = require('node:stream'); const HIGH_WATER_MARK = 1_048_576; // ~1MB const MAX_DELAY_IN_MS = 5_000; const MIN_DELAY_IN_MS = 3_000; main().catch((error) => { console.log(error); }); async function main() { await new Promise((resolve) => { const server = createServer(async (req) => { const passThrough = new PassThrough({ highWaterMark: HIGH_WATER_MARK, }); req.pipe(passThrough); for await (const chunk of passThrough) { console.log(`Обработано ${chunk.toString().length} байт`); const randomTimeoutMs = Math.random() * (MAX_DELAY_IN_MS - MIN_DELAY_IN_MS) + MIN_DELAY_IN_MS; await new Promise((resolve) => setTimeout(resolve, randomTimeoutMs)); } }); server.listen(3000, () => { console.log(`Сервер успешно запущен`); resolve(null); }); }); }
3. Собираем Docker-контейнер и конфигурируем ресурсы в файле Docker Compose для лимитирования памяти. Лимит памяти ставим 50 МБ, из которых ~20 МБ закрепляются за Node.js.
FROM node:18.16-alpine3.18 COPY build ./build EXPOSE 3000 CMD ["node", "./build/server.js"]
version: '3' name: node-streams services: server: build: dockerfile: ./Dockerfile ports: - 3000:3000 deploy: resources: limits: cpus: '0.5' memory: 50M reservations: cpus: '0.5' memory: 50M
4. Запускаем контейнер и отправляем данные. Видим, что сервер обрабатывает чанки размером примерно 1 МБ. Так происходит, потому что highWaterMark не лимит, а всего лишь пороговое значение.
node-streams-server-1 | Сервер успешно запущен node-streams-server-1 | Обработано 32671 байт node-streams-server-1 | Обработано 1081344 байт node-streams-server-1 | Обработано 1114112 байт node-streams-server-1 | Обработано 1114112 байт node-streams-server-1 | Обработано 1114112 байт
5. Запускаем утилиту tcpdump, чтобы подробно отследить процесс управления потоком на уровне протокола TCP. Параметр -i — это интерфейс localhost, port — порт, который слушает утилита.
$ sudo tcpdump -i lo0 port 3000
6. Запускаем пример повторно. Сначала стороны обмениваются параметрами для установки TCP-сессии. Max Message Size (mss) — 16,324 байта, коэффициент Window Scale (wscale) — 6.
15:47:10.004647 IP localhost.54892 > localhost.hbci: Flags [S], seq 2664759488, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 792746651 ecr 0,sackOK,eol], length 0 15:47:10.005743 IP localhost.hbci > localhost.54892: Flags [S.], seq 4116912290, ack 2664759489, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 840270532 ecr 792746651,sackOK,eol], length 0
7. Клиент отправляет данные пакетами по 16332 байта, а получатель подтверждает получение пакетов. Номер последовательности (seq) указывает на диапазон данных, например 1:16313 и далее 16313:32625. Получатель подтверждает, что пакеты получены, например ack: 16313. Размер окна при этом постоянно уменьшается.
15:47:10.006046 IP localhost.54892 > localhost.hbci: Flags [.], seq 1:16333, ack 1, win 6379, options [nop,nop,TS val 792746653 ecr 840270534], length 16332 15:47:10.006047 IP localhost.54892 > localhost.hbci: Flags [.], seq 16333:32665, ack 1, win 6379, options [nop,nop,TS val 792746653 ecr 840270534], length 16332 15:47:10.006087 IP localhost.hbci > localhost.54892: Flags [.], ack 16333, win 6124, options [nop,nop,TS val 840270534 ecr 792746653], length 0 15:47:10.006099 IP localhost.hbci > localhost.54892: Flags [.], ack 32665, win 5869, options [nop,nop,TS val 840270534 ecr 792746653], length 0
8. Так продолжается, пока размер окна не становится слишком маленьким для отправки следующего пакета (win: 255). Отправитель приостанавливает отправку и ждет, когда размер окна восстановится. Значение параметра win — это не значение в байтах. Чуть ниже расскажу, как рассчитать актуальный размер окна.
15:47:10.031553 IP localhost.hbci > localhost.54892: Flags [.], ack 1159573, win 510, options [nop,nop,TS val 840270559 ecr 792746678], length 0 15:47:10.031567 IP localhost.hbci > localhost.54892: Flags [.], ack 1175905, win 255, options [nop,nop,TS val 840270559 ecr 792746678], length 0
9. Через три секунды приходит подтверждение, что размер окна восстановлен и можно продолжить отправку данных. Отправка продолжается.
15:47:10.031567 IP localhost.hbci > localhost.54892: Flags [.], ack 1175905, win 255, options [nop,nop,TS val 840270559 ecr 792746678], length 0 15:47:13.033733 IP localhost.hbci > localhost.54892: Flags [.], ack 1175905, win 4338, options [nop,nop,TS val 840270561 ecr 792746678], length 0 15:47:13.033850 IP localhost.54892 > localhost.hbci: Flags [.], seq 1175905:1192237, ack 1, win 6379, options [nop,nop,TS val 792746680 ecr 840270561], length 16332 15:47:13.033853 IP localhost.54892 > localhost.hbci: Flags [.], seq 1192237:1208569, ack 1, win 6379, options [nop,nop,TS val 792746680 ecr 840270561], length 16332
Чтобы получить размер актуального окна, нужно к значению параметра win применить формулу расчета актуального размера окна. Когда создавали протокол TCP, разработчики решили, что размер окна не должен превышать 65,535 байта. Для размера окна выделили параметр Window в заголовке сообщения. Значение параметра не должно превышать 16 бит (2^16 === 65,535).
В конце 90-х стало очевидно, что текущего размера окна недостаточно, поэтому в стандарт TCP добавили атрибут Window Scale Option, чтобы не ломать обратную совместимость протокола. При установке соединения хосты обмениваются параметрами Window Scale и рассчитывают актуальный размер окна согласно формуле Window Size * (2 ^ Window Scale), таким образом максимальный размер окна увеличился с 0,06 МБ (65,535 байта) до ~1 ГБ и не потребовалось менять размер заголовка Window.
Собираем все вместе
В Node.js при обработке большого количества данных, которые передаются по сети, можно и нужно контролировать объем потребляемой памяти. Для этого при обработке Readable Stream необходимо использовать цикл for await … of. Можно использовать подписку на событие data, но придется вручную ставить поток на паузу с помощью readable.pause().
Под капотом Node.js использует механизм Back Pressure, основанный на механизме окон протокола TCP, а библиотека libuv управляет состоянием TCP-соединения. Размер окна зависит от свободного места в буфере. Если размер данных в буфере превысил значение highWaterMark, то потребление данных приостанавливается и продолжается, когда буфер очищается.
В начале я описывал интеграцию для передачи составных данных с помощью HTTP. Благодаря простому и удобному механизму для контроля потока в Node.js наша команда надежно обрабатывает сотни гигабайтов данных на машинах с лимитом памяти в 256 МБ. Главное — использовать цикл for await … of для обработки данных и корректно сконфигурировать highWaterMark в соответствии с выделенными ресурсами.
Если вы повышали надежность обработки данных в Node.js — делитесь своими практиками в комментариях!

