Avatar ·

Как правильно записывать данные в файл в Node.js: синхронно, асинхронно и через потоки?

Всем привет! Я новичок в Node.js и пытаюсь разобраться с записью данных в файлы. Перечитал кучу документации, но голова идет кругом от количества методов: `writeFile`, `writeFileSync`, `appendFile`, `createWriteStream`... Когда что использовать? У меня есть простое приложение для логирования действий пользователя. Я хочу записывать каждое действие в файл `log.txt`. Сейчас я делаю так: ```javascript const fs = require('fs'); function logAction(action) { const data = `${new Date().toISOString()} - ${action}\n`; fs.writeFile('log.txt', data, (err) => { if (err) console.error('Ошибка записи:', err); }); } logAction('Пользователь вошел в систему'); logAction('Пользователь нажал кнопку "Купить"'); ``` Проблема в том, что иногда в файле оказывается только последняя запись, а иногда — вообще пусто. Я подозреваю, что это из-за асинхронности, но не понимаю, как правильно организовать последовательную запись. Также я слышал про `fs.promises`, про `writeFileSync`, но боюсь, что синхронная запись заблокирует весь сервер. А про потоки (streams) вообще молчу — кажется, это для каких-то гигабайтных файлов. Подскажите, пожалуйста, какой подход выбрать для простого логгера, а какой — для продакшена с большими объемами данных? И как избежать гонки данных (race condition) при множественных вызовах? <<>>

Понимание проблемы: почему теряются данные?

Ваша проблема классическая для начинающих. Дело в том, что fs.writeFile() по умолчанию перезаписывает файл каждый раз, когда вы его вызываете. Если два вызова происходят почти одновременно, второй может начать запись раньше, чем завершится первый — отсюда и потеря данных. Более того, callback срабатывает после завершения записи, но Node.js не ждет его, если вы не используете цепочку промисов.

Решение 1: Асинхронная запись с промисами (для небольших объемов)

Самый современный и безопасный способ для большинства задач — использовать fs.promises. Он позволяет избежать "ада колбэков" и гарантирует последовательность операций через async/await:

const fs = require('fs').promises;

async function logAction(action) {
  const data = `${new Date().toISOString()} - ${action}\n`;
  try {
    // 'a' — флаг append (добавление в конец), а не перезапись!
    await fs.writeFile('log.txt', data, { flag: 'a' });
  } catch (err) {
    console.error('Ошибка записи:', err);
  }
}

// Использование:
(async () => {
  await logAction('Пользователь вошел');
  await logAction('Пользователь нажал кнопку');
  console.log('Все записи сохранены');
})();

Важно: флаг { flag: 'a' } открывает файл для добавления (append), а не для перезаписи. Это решает проблему с потерей предыдущих записей.

Решение 2: Синхронная запись (только для старта/конфигов)

fs.writeFileSync() блокирует event loop, поэтому его нельзя использовать в обработчиках HTTP-запросов или в циклах с большим количеством операций. Но он идеален для:

  • Записи конфигурационных файлов при запуске приложения
  • Создания файлов логов перед завершением процесса (process.on('exit'))
  • Скриптов инициализации
const fs = require('fs');
try {
  fs.writeFileSync('config.json', JSON.stringify({ port: 3000 }), 'utf8');
} catch (err) {
  console.error('Не удалось записать конфиг:', err);
}

Решение 3: Потоки (Streams) для больших объемов

Если вы пишете мегабайты данных (например, логи веб-сервера с тысячами запросов в секунду), используйте fs.createWriteStream(). Поток накапливает данные в буфере и сбрасывает их на диск одной операцией, что в 10-100 раз быстрее множественных вызовов writeFile.

const fs = require('fs');
const logStream = fs.createWriteStream('access.log', { flags: 'a' });

function logRequest(method, url) {
  const line = `${new Date().toISOString()} ${method} ${url}\n`;
  // Возвращает true, если данные поместились в буфер
  const canContinue = logStream.write(line);
  if (!canContinue) {
    // Если буфер переполнен — ждем, пока освободится
    logStream.once('drain', () => {});
  }
}

// Не забудьте закрыть поток при завершении приложения
process.on('SIGINT', () => {
  logStream.end();
  process.exit();
});

Как избежать гонки данных (race condition)?

При использовании потоков гонки не возникает — данные добавляются последовательно в один и тот же файловый дескриптор. Если вам нужно писать из разных модулей или процессов, используйте:

  • Один поток на весь процесс (передавайте его через require или dependency injection)
  • Библиотеку winston для продвинутого логирования с ротацией файлов
  • Очередь сообщений (RabbitMQ, Redis) для распределенных систем

Итоговая рекомендация:

  • Для простого логгера из примера — используйте fs.promises.writeFile с флагом 'a'
  • Для высоконагруженного сервера — fs.createWriteStream
  • Для конфигурационных файлов — writeFileSync
  • Никогда не используйте writeFile без промисов в продакшене — это антипаттерн
<<>>

Альтернативный подход: использование модуля fs.promises с appendFile

Многие разработчики не знают, что существует специальный метод fs.promises.appendFile, который делает код еще чище. Он автоматически открывает файл в режиме добавления, без необходимости указывать флаг flag: 'a'.

const { appendFile } = require('fs').promises;

async function logAction(action) {
  const data = `${new Date().toISOString()} - ${action}\n`;
  try {
    await appendFile('log.txt', data);
  } catch (err) {
    console.error('Ошибка:', err);
  }
}

Этот метод идеален для логов небольшого размера (до 100 МБ в день). Он атомарен для одного процесса — данные не перемешаются, даже если вызвать appendFile 1000 раз подряд в цикле for. Однако для распределенных систем (несколько экземпляров Node.js) все равно потребуется синхронизация через внешнюю систему.

Когда это удобно:

  • Микросервисы с низкой нагрузкой
  • Сбор метрик в dev-среде
  • Прототипирование и MVP

Недостаток: при очень высокой частоте вызовов (более 10 000 в секунду) накладные расходы на открытие/закрытие файлового дескриптора становятся заметными. В этом случае потоки (streams) или буферизация с периодическим сбросом на диск будут эффективнее.