Как правильно записывать данные в файл в Node.js: синхронно, асинхронно и через потоки?
Понимание проблемы: почему теряются данные?
Ваша проблема классическая для начинающих. Дело в том, что 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) или буферизация с периодическим сбросом на диск будут эффективнее.