Node.js fs.writeFile () очищает файл

У меня есть метод обновления, который вызывается каждые 16-40 мс, и внутри у меня есть этот код:

this.fs.writeFile("./data.json", JSON.stringify({
    totalPlayersOnline: this.totalPlayersOnline,
    previousDay: this.previousDay,
    gamesToday: this.gamesToday
}), function (err) {
    if (err) {
        return console.log(err);
    }
});

Если сервер выдает ошибку, файл «data.json» иногда становится пустым. Как мне это предотвратить?


person coNNecTT    schedule 17.06.2015    source источник
comment
1. напишите новый файл с временным именем 2. переименуйте старый файл в 3. переименуйте новый файл в целевое имя 4. удалить старый файл   -  person Denys Séguret    schedule 17.06.2015
comment
записывайте его дважды каждый раз, сначала как data.json.bak, затем как data.json. один из них не будет пустым.   -  person dandavis    schedule 17.06.2015
comment
Разве не стоит обратить внимание на решение причины, по которой сервер выдает ошибки? Или, по крайней мере, добавьте некоторую правильную обработку ошибок, чтобы вы могли аккуратно выйти из процесса.   -  person robertklep    schedule 17.06.2015
comment
Да, я так и делаю, я всегда стараюсь избавить свои серверы от ошибок, но в случае возникновения неизвестной ошибки во время размещения сервера я стараюсь убедиться, что файл не приводит к полному сбою сервера.   -  person coNNecTT    schedule 17.06.2015
comment
@ DenysSéguret С fs.rename() вы можете пропустить шаги 2 и 4 и избежать состояния гонки / сбоя, когда имя назначения не существует.   -  person binki    schedule 08.08.2017


Ответы (3)


Проблема

fs.writeFile не является атомарной операцией. Вот пример программы, которую я буду запускать strace:

#!/usr/bin/env node
const { writeFile, } = require('fs');

// nodejs won’t exit until the Promise completes.
new Promise(function (resolve, reject) {
    writeFile('file.txt', 'content\n', function (err) {
        if (err) {
            reject(err);
        } else {
            resolve();
        }
    });
});

Когда я запускаю это под strace -f и убираю вывод, чтобы показать только системные вызовы из операции writeFile (, которая фактически охватывает несколько потоков ввода-вывода), я получаю:

open("file.txt", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0666) = 9
pwrite(9, "content\n", 8, 0)            = 8
close(9)                                = 0

Как видите, writeFile выполняется в три этапа.

  1. Это файл open() ed. Это атомарная операция, которая с предоставленными флагами либо создает пустой файл на диске, либо, если файл существует, обрезает его. Усечение файла - это простой способ убедиться, что в файл попадает только написанное вами содержимое. Если в файле есть существующие данные и файл длиннее, чем данные, которые вы впоследствии записываете в файл, дополнительные данные останутся. Чтобы избежать этого, вы обрезаете.
  2. Содержание написано. Поскольку я написал такую ​​короткую строку, это делается с помощью одного _9 _, но для больших объемов данных я предполагаю, что возможно, что nodejs будет записывать только фрагмент за раз.
  3. Ручка закрыта.

В моем strace каждый из этих шагов выполнялся в другом потоке ввода-вывода узла. Это наводит на мысль, что fs.writeFile() на самом деле может быть реализовано в терминах fs.open(), _ 13_ и _ 14_. Таким образом, nodejs не рассматривает эту сложную операцию как атомарную на любом уровне - потому что это не так. Поэтому, если процесс вашего узла завершается, даже изящно, не дожидаясь завершения операции, операция может быть выполнена на любом из описанных выше шагов. В вашем случае вы видите, что процесс завершается после того, как writeFile() завершит шаг 1, но до того, как он завершит шаг 2.

Решение

Распространенный шаблон для транзакционной замены содержимого файла слоем POSIX заключается в использовании следующих шагов:

  1. Запишите данные в файл с другим именем, fsync() файл (см. «Когда следует использовать fsync?» В «Обеспечение попадания данных на диск»), а затем close() это.
  2. rename() (или, в Windows, _ 19_ с MOVEFILE_REPLACE_EXISTING) файл с другим именем над тем, который вы хотите заменить.

Используя этот алгоритм, целевой файл либо обновляется, либо нет, независимо от того, когда ваша программа завершается. И, что еще лучше, журналируемые (современные) файловые системы гарантируют, что, пока вы fsync() файл на шаге 1 перед переходом к шагу 2, две операции будут выполняться по порядку. То есть, если ваша программа выполняет шаг 1, а затем шаг 2, но вы отключаете вилку, при загрузке вы обнаружите, что файловая система находится в одном из следующих состояний:

  • Ни один из двух шагов не завершен. Исходный файл не поврежден (или, если его раньше не было, то не существует). Файл замены либо не существует (шаг 1 алгоритма writeFile(), open(), фактически никогда не был успешным), существует, но пуст (шаг 1 алгоритма writeFile() завершен), либо существует с некоторыми данными (шаг 2 алгоритма writeFile() частично завершен).
  • Первый шаг выполнен. Исходный файл не поврежден (или, если он не существовал ранее, все еще не существует). Файл замены существует со всеми необходимыми данными.
  • Оба шага выполнены. По пути к исходному файлу теперь вы можете получить доступ к своим данным замены - всем, а не пустому файлу. Путь, по которому вы записали данные замены на первом шаге, больше не существует.

Код для использования этого шаблона может выглядеть следующим образом:

const { writeFile, rename, } = require('fs');

function writeFileTransactional (path, content, cb) {
    // The replacement file must be in the same directory as the
    // destination because rename() does not work across device
    // boundaries.

    // This simple choice of replacement filename means that this
    // function must never be called concurrently with itself for the
    // same path value. Also, properly guarding against other
    // processes trying to use the same temporary path would make this
    // function more complicated. If that is a concern, a proper
    // temporary file strategy should be used. However, this
    // implementation ensures that any files left behind during an 
    // unclean termination will be cleaned up on a future run.
    let temporaryPath = `${path}.new`;
    writeFile(temporaryPath, content, function (err) {
        if (err) {
            return cb(err);
        }

        rename(temporaryPath, path, cb);
    });
};

По сути, это то же самое решение, которое вы использовали бы для той же проблемы на любом языке / фреймворке.

person binki    schedule 21.08.2016
comment
Мне было интересно, будет ли let tFile = path + '.new' быстрее. Я тоже добавил случайное число, так что это не так, но в некотором смысле безопаснее. Также нет упоминания о том, как renameSync обрабатывает перезапись ~ без сомнения, но документация узла может упомянуть об этом (я также предлагаю вариант Sync для writeFileSync). - person Master James; 08.08.2017
comment
@MasterJames Уже есть вопрос, посвященный поведению перезаписи, но ни один из ответов не указывает на какую-либо документацию nodejs, объясняющую, почему это работает. Не уверен, что есть. По сути, libuv пытается имитировать POSIX даже в Windows и, таким образом, rename() libuv, который вызывает nodejs, реализует поведение перезаписи. - person binki; 08.08.2017
comment
@MasterJames Кроме того, если вы избегаете использования строковых шаблонов только из соображений производительности, вы, вероятно, упускаете смысл оптимизации. В этом коде единственное, что занимает какое-то время, - это ввод-вывод с диском. Таким образом, единственный способ повысить производительность - это реструктурировать все приложение, чтобы запись на диск происходила реже, или не дожидаясь завершения записи, прежде чем продолжить. Я полагаю, что любой хороший интерпретатор JavaScript должен внутренне создавать одинаковые результаты байт-кода / JIT для `${a}b` и a + 'b'. - person binki; 08.08.2017
comment
@MasterJames Кроме того, я знаю, что некоторые люди рекомендуют подход случайных чисел. Но лично мне это кажется сложнее, потому что, если ваша программа выйдет из строя, она каждый раз будет оставлять новый файл. Если он использует предсказуемое / производное имя файла, то при следующем запуске команды будет автоматически удалено старое, перезаписав его. Если вас беспокоят атаки по символическим ссылкам или что-то еще, то использования случайного числа недостаточно. Вместо этого вы должны вызвать что-то вроде mkstemp или использовать пакет, который использует безопасный узор. - person binki; 08.08.2017
comment
Я теперь кладу его в папку tmp вместо random. Тем не менее, иногда файл загружается пустым, поэтому я подозреваю, что плюсы и минусы потери файла и, возможно, просмотра зависят от пользователя. Я думаю, что бездельничание или чтение файла должны выглядеть снова, если он отсутствует или пуст / загружен, но иногда он кажется пустым при попытке переименования? Я думаю, что nodejs должен приостанавливать чтение при усечении перед записью изменений, а переименование выполняется немного быстрее, так как меньшее окно оказывается пустым в зависимости от размера файла. - person Master James; 10.08.2017
comment
Должен работать с несколькими экземплярами узлов на разных компьютерах Mac (hine). Может, требуется большая из двух загрузок / чтений? - person Master James; 10.08.2017
comment
Может быть, fs.writeFile() из-за отсутствия звонка fsync() этого недостаточно. Но потом все становится еще сложнее. Работает ли этот шаблон? Какую именно настройку вы используете? Удаленная / общая файловая система? - person binki; 10.08.2017
comment
@MasterJames См. Предыдущий комментарий - person binki; 10.08.2017
comment
@MasterJames О, и я вижу, что для обработки нескольких процессов потребуется что-то вроде случайного числа. Однако не забудьте использовать O_EXCL (передайте x в fs.open() и будьте готовы обработать состояние конфликта (которое будет заключаться в генерации нового случайного числа и повторной попытке и иметь некоторую политику для очистки старых временных файлов)). - person binki; 10.08.2017
comment
Я разместил здесь отдельное решение, чтобы помочь ответить на ваши вопросы в ответ на мой опыт. Спасибо за вашу постоянную поддержку! - person Master James; 11.08.2017
comment
Позвольте нам продолжить это обсуждение в чате. - person binki; 11.08.2017
comment
Привет, спасибо, сейчас я просто повторюсь. Я не пробовал все, что вы говорите. У меня есть ограниченный тест на любую проблему, так как изначально я заметил, что файлы были переписаны только в моей среде IDE, они выглядели пустыми. В моем коде, который читает эти файлы, теперь предполагается, что нужно сделать двойной дубль, если он отсутствует или пуст. Это кажется наименее навязчивым или сложным. Я просто использую nodejs 8+ сейчас, поэтому у меня есть более сложная оболочка для writeFileSync и readFileSync, я надеюсь, что лучше, чем переделывать ее с помощью fsync. Может быть, мне там не хватает флага опции? Противоположность этому, может быть, O_NONBLOCK? или выставить смещение 0? - person Master James; 13.08.2017
comment
@binki: спасибо за подробное объяснение, мне интересно, является ли это все еще действующим и рекомендуемым шаблоном, и мне любопытно узнать о системных вызовах для переименования, мне все еще не ясно, переименовывается ли файл из xyz.ext. temp to xyz.ext был внезапно прерван, что будет статусом этих двух файлов - person Sohail Faruqui; 25.08.2020
comment
@SohailFaruqui В современных операционных системах / файловых системах rename() в одной и той же файловой системе (обычно вы можете предположить, что переименование в каталоге находится в той же файловой системе) является «атомарной» операцией. Это означает, что операция либо завершается полностью, либо не завершается вовсе - она ​​не может быть завершена частично. Если вы вызываете rename() и в системе происходит сбой питания, возможны два возможных исхода: есть два файла, в которых xyz.ext.temp будут содержать ваши новые данные, а xyz.ext будет иметь старые данные ИЛИ есть один файл xyz.ext с новыми данными. - person binki; 26.08.2020
comment
@SohailFaruqui Я рекомендую прочитать lwn.net/Articles/457667 и lwn.net/Articles/322823. Это сложная история. Чтобы быть «правильным», нужно вызвать open() во временном файле, write(), fsync(), close(), а затем rename() - но многие люди пропускают вызов fsync(), и многие комбинации ОС / файловой системы будут работать правильно / безопасно без вызова fsync(). И вы должны понимать, что, даже если вызов rename() возвращается, может показаться, что rename() никогда не происходило, если у вас сбой питания, до тех пор, пока не вернется fsync() в самом каталоге. - person binki; 26.08.2020

если ошибка вызвана неправильным вводом (данные, которые вы хотите записать), убедитесь, что данные соответствуют им, а затем выполните writeFile. если ошибка вызвана отказом файла writeFile даже при условии, что ввод в порядке, вы можете проверить, выполняется ли функция до тех пор, пока файл не будет записан. Один из способов - использовать функцию async doWhilst.

async.doWhilst(
    writeFile(), //your function here but instead of err when fail callback success to loop again
    check_if_file_null, //a function that checks that the file is not null
    function (err) {
        //here the file is not null
    }
);
person cs04iz1    schedule 17.06.2015

Я не проводил реальных тестов с этим, я просто заметил, вручную перезагружая свой ide, что иногда файл был пуст. Сначала я попробовал метод переименования и заметил ту же проблему, но воссоздание нового файла было менее желательным (учитывая отслеживание файлов и т. Д.).

Мое предложение или то, что я делаю сейчас, находится в вашем собственном readFileSync. Я проверяю, отсутствует ли файл или возвращенные данные пусты, и сплю в течение 100 миллисекунд, прежде чем дать ему еще одну попытку. Я полагаю, что третья попытка с большей задержкой действительно подтолкнет сигму на ступеньку выше, но в настоящее время не собираюсь этого делать, поскольку добавленная задержка, надеюсь, является ненужным негативом (на этом этапе будет рассмотрено обещание). Есть и другие возможности восстановления относительно вашего собственного кода, которые, надеюсь, вы можете добавить на всякий случай. Файл не найден или пуст? по сути, это повторная попытка другим способом.

В моем настраиваемом файле writeFileSync есть добавленный флаг для переключения между использованием метода переименования (с созданием вложенного каталога записи '._new') или обычного прямого метода, поскольку потребности вашего кода могут отличаться. Моя рекомендация зависит от размера файла.

В этом случае файлы небольшие и обновляются только одним экземпляром узла / сервером за раз. Я вижу добавление случайного имени файла в качестве еще одной опции с переименованием, чтобы позволить нескольким машинам записать другой вариант позже, если это необходимо. Может быть, еще и аргумент ограничения повтора?

Я также думал, что вы можете записать в локальную временную папку, а затем скопировать ее в общую цель каким-либо образом (возможно, также переименовать цель для увеличения скорости), а затем, конечно же, очистить (отсоединить от локальной температуры). Я предполагаю, что эта идея как бы подталкивает ее к командам оболочки, так что не лучше. В любом случае, основная идея здесь - прочитать дважды, если он окажется пустым. Я уверен, что это безопасно от частичного написания через nodejs 8+ на общем монтировании NFS типа Ubuntu, верно?

person Master James    schedule 11.08.2017