руИНН
руИНН

660 мс блокировки: как синхронный fs душит однопоточный Node

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

Откуда взялись 400 файлов

руИНН хранит данные компаний в файлах: data/{ИНН}.json — выписка из ЕГРЮЛ,

{ИНН}_fin.json — бухотчётность, плюс проверка и арбитраж. Такой кэш выбран сознательно: данные меняются раз в месяц, запросов немного, а платный API за каждую новую компанию берёт из годовой квоты. Файл на диске в этой задаче — не «пока не поставили базу», а нормальное решение, и мы к нему ещё вернёмся в конце.

База растёт по запросам: карточка появляется, когда её кто-то ищет. Полного слепка ЕГРЮЛ у нас нет и не планируется — каждая новая компания стоит единицу годовой квоты, и это отдельный сюжет, про который есть своя статья.

Каталог /company показывает список компаний с фильтрами по городу, статусу и виду деятельности. Чтобы собрать список, нужно знать про каждую компанию название, город, статус и выручку — то есть заглянуть в каждый файл. На момент, о котором идёт речь, в базе было 139 компаний, а файлов — около 400: выписка плюс отчётность, где она есть.

Первая версия делала это прямо в обработчике:

// как было: обработчик читает диск синхронно
const files = fs.readdirSync(DATA_DIR).filter(f => f.endsWith('.json'));
const companies = files.map(f => {
  const raw = fs.readFileSync(path.join(DATA_DIR, f), 'utf-8');
  return parseCompany(JSON.parse(raw));
});

Работает. Открывается. Проблема не в том, что медленно, — проблема в том, кто в это время стоит.

Почему это не «просто медленно»

Node — один поток. Пока readFileSync держит поток, событийный цикл не крутится: не принимаются соединения, не отдаются ответы уже подключённым, не срабатывают таймеры. Это не «текущий запрос идёт 200 мс», а «весь сервер выключен на 200 мс».

Разница вылезает под конкуренцией. Замер до правки: на холодном кэше сборка каталога занимала 660 мс блокировки событийного цикла. Одиночный запрос карточки компании отвечал за 21 мс, но стоило пустить пять параллельных — и карточка отвечала 1005 мс. Карточка при этом никакого отношения к каталогу не имеет: она просто стояла в очереди за чужим синхронным чтением.

Обратите внимание, что дело не в количестве компаний. Сто тридцать девять записей — не та величина, на которой что-то обязано тормозить; тормозили четыреста синхронных обращений к диску на каждый запрос. С тех пор база выросла примерно в десять раз, а время ответа осталось прежним — потому что чтения ушли с пути запроса, а не потому что данных стало меньше.

Это и есть характерная подпись проблемы: медленно становится не тому, кто виноват. В логах при этом всё хорошо — 200, ошибок нет, просто время ответа поплыло у всех сразу.

Что заменили

Список компаний переехал в модуль services/catalog.js — кэш в памяти процесса:

Результат: файлы на пути запроса не читаются вообще. Каталог, главная и эндпоинт чипов берут данные из одной сборки в памяти. Время ответа на проде сейчас — каталог ~47 мс, карточка ~60 мс, главная ~43 мс, и оно не растёт от параллельных запросов.

Правило, которое из этого выросло и записано в архитектурных заметках отдельной строкой: синхронный fs на путях запроса не использовать. Не «по возможности», а не использовать. Синхронное чтение допустимо ровно в двух местах: при загрузке модуля и в скриптах, которые запускаются кроном отдельным процессом.

Один процесс — это осознанно

Напрашивается вопрос: зачем городить кэш в памяти, если есть Redis, и почему один процесс, если есть cluster и PM2.

Ответ конкретный. В памяти процесса живёт критичное состояние: предохранитель бюджета платного API (сколько новых компаний куплено за час, сутки, год), кэш каталога и счётчики rate-limit. Наивный запуск в четыре процесса умножит все лимиты на четыре — то есть сломает защиту квоты, ту самую, которая появилась после инцидента с краулерами. Четыре процесса, каждый со своим суточным счётчиком новых компаний, дадут четырёхкратный потолок вместо заданного.

При этом нагрузка не упирается в процессор: проект I/O-bound, тяжёлого счёта на главном потоке нет (JSON.parse выписки — 0,15–2,35 мс), а nginx перед Node сам многопроцессный и забирает на себя TLS, gzip и статику. Простаивающих ядер по сути нет.

Порядок масштабирования, когда понадобится, тоже записан заранее — и он не начинается с

cluster:

  1. Сейчас — один процесс, не трогать.
  2. Упрётся процессор — точечно worker_threads под конкретную тяжёлую задачу, общее состояние остаётся в главном потоке.
  3. Реального трафика станет больше одного ядра — сначала вынести общее состояние в Redis (счётчики предохранителя, rate-limit, инвалидация каталога через pub/sub), потом включать несколько процессов. Порядок критичен.
  4. Несколько машин — Redis или БД для состояния плюс балансировщик.

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

Что забирать с собой