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 — кэш в памяти процесса:
- TTL 15 минут. Данные меняются ночью по крону, чаще перечитывать нечего.
- stale-while-revalidate. По истечении TTL запрос получает старый список немедленно, а пересборка идёт фоном. Пользователь никогда не ждёт чтения диска.
- Инвалидация при появлении новой компании — чтобы карточка, которую только что нашли через поиск, сразу попала в каталог и в sitemap, не дожидаясь пятнадцати минут.
- Прогрев при старте. Первый посетитель после деплоя не должен оплачивать сборку.
- Чтение —
fs.promises, пачками. Сама пересборка тоже не блокирует поток: файлы читаются асинхронно, батчами, и между батчами событийный цикл свободен.
Результат: файлы на пути запроса не читаются вообще. Каталог, главная и эндпоинт чипов берут данные из одной сборки в памяти. Время ответа на проде сейчас — каталог ~47 мс, карточка ~60 мс, главная ~43 мс, и оно не растёт от параллельных запросов.
Правило, которое из этого выросло и записано в архитектурных заметках отдельной строкой: синхронный fs на путях запроса не использовать. Не «по возможности», а не использовать. Синхронное чтение допустимо ровно в двух местах: при загрузке модуля и в скриптах, которые запускаются кроном отдельным процессом.
Один процесс — это осознанно
Напрашивается вопрос: зачем городить кэш в памяти, если есть Redis, и почему один процесс, если есть cluster и PM2.
Ответ конкретный. В памяти процесса живёт критичное состояние: предохранитель бюджета платного API (сколько новых компаний куплено за час, сутки, год), кэш каталога и счётчики rate-limit. Наивный запуск в четыре процесса умножит все лимиты на четыре — то есть сломает защиту квоты, ту самую, которая появилась после инцидента с краулерами. Четыре процесса, каждый со своим суточным счётчиком новых компаний, дадут четырёхкратный потолок вместо заданного.
При этом нагрузка не упирается в процессор: проект I/O-bound, тяжёлого счёта на главном потоке нет (JSON.parse выписки — 0,15–2,35 мс), а nginx перед Node сам многопроцессный и забирает на себя TLS, gzip и статику. Простаивающих ядер по сути нет.
Порядок масштабирования, когда понадобится, тоже записан заранее — и он не начинается с
cluster:
- Сейчас — один процесс, не трогать.
- Упрётся процессор — точечно
worker_threadsпод конкретную тяжёлую задачу, общее состояние остаётся в главном потоке. - Реального трафика станет больше одного ядра — сначала вынести общее состояние в Redis (счётчики предохранителя, rate-limit, инвалидация каталога через pub/sub), потом включать несколько процессов. Порядок критичен.
- Несколько машин — Redis или БД для состояния плюс балансировщик.
Триггер для перехода — не ощущение «пора бы», а метрика: лаг событийного цикла под нагрузкой. Пока лага нет, добавление процессов только ухудшит защиту.
Что забирать с собой
- Синхронный
fsв обработчике — это глобальная пауза сервера, а не локальная задержка запроса. Ищите его первым делом, когда «под нагрузкой всё тормозит», особенно если тормозит не тот эндпоинт, который вы оптимизировали. - Замеряйте под конкуренцией. Одиночный запрос покажет 21 мс и соврёт. Пять параллельных показали 1005 мс — и это была правда о продакшене.
- Кэш в памяти процесса — законный инструмент, пока процесс один. Он же становится ловушкой в тот момент, когда процессов становится два, и об этом надо знать заранее, а не выяснять после включения кластера.
- Файлы вместо базы — нормально, если данные редко меняются, объём известен, а чтение идёт через асинхронный API и кэшируется. Проблемой была не файловая система, а
Syncв имени функции.