Что не так с функцией file_put_contents() в PHP?

Если в кратце, то суть такая: file_put_contents() не атомарен и не гарантирует записи данных файл, а может вообще очистить его содержимое так и не записав новое содержимое.

Синтаксический сахар это хорошо. Судя по описанию, функция file_put_contents() идентична последовательным успешным вызовам функций fopen(), fwrite() и fclose(), что влечёт за собой некоторые особенности, связанные с этими функциями. Сразу скажу, что пробовал воспроизводить это только внутри Docker контейнеров и утилизацией диска близкой к 100%, возможно в другом окружении такое поведение воспроизводиться не будет. Для начала давайте с умным видом  посмотрим исходник PHP 7.1.12.

Собственно, какие шаги можно увидеть?

  • открывается файл (php_stream_open_wrapper_ex) по-умолчанию в режиме w, что производит очистку файла! Флаг FILE_APPEND открывает файл в режиме a, но этот кейс не рассматриваем.
  • если передан флаг LOCK_EX, то файл открывается в режиме c и дополнительно очищается (php_stream_truncate_set_size)
  • что-то пишется в файл (php_stream_copy_to_stream_ex || php_stream_write)
  • файл закрывается (php_stream_close)

А что, если скрипт прервётся (например, kill, Control + C, reset) после открытия файл в в режиме w, либо в режиме c после php_stream_truncate_set_size, но не дойдя до php_stream_copy_to_stream_ex  или php_stream_write? Правильно, файл останется абсолютно пустым! Т.е. вызывая функцию file_put_contents() существует вероятность получить абсолютно пустой файл, вместо его предшествующего содержимого.

Такую же ситуацию можно получить, если вызывать поочерёдно fopen(), fwrite() и fclose(). На вскидку приходит несколько способов защиты от такого побочного эффекта.

Во-первых, не пользоваться для сохранения значимых данных файлами. Гораздо надёжнее использовать полноценные СУБД с их атомарностью в рамках транзакций, что защитит даже от внезапной остановки сервера, при условии использования журналируемой файловой системы, либо InnoDB Doublewrite Buffer в MySQL и его аналогов в других СУБД. Как вариант, можно рассмотреть NoSQL и Key-Value решения, в случаях остановки PHP скрипта они защитят от потери данных. Но, для случаев внезапной остановки сервера нужно изучить их работу.

Во-вторых, если уж всё-таки хочется писать в файл, то лучше каждую новую запись писать в новый файл, с последующей подменой его на старый.

В-третьих, можно воспользоваться таким кодом:

$fp = fopen($file, 'c');
$result = fwrite($fp, $value. '$');
ftruncate($fp, strlen($offset) );
fclose($fp);

Обратите внимание на открытие файла в режиме c.

Если файл существует, то он не обрезается (в отличие от ‘w’), и вызов этой функции не вызывает ошибку (также как и в случае с ‘x’). Указатель на файл будет установлен на начало файла.

Затем поверх старого содержимого пишется новое содержимое. Т.к. новое содержимое может быть короче, чем старое, нужно как-то дополнительно пометить конец данных, для этого в моём примере используется ‘$’, что является последовательностью символов 100% не входящую в полезные данные. После чего файл обрезается до длины полезных данных. А теперь рассмотрим этот алгоритм пошагово.

  • файл содержит данные: 1234567
  • открывает файл на запись и помещаем указатель на первый символ
  • пишем в файл новые данные: 890 + $
  • в файле оказывается содержание: 890$567
  • обрезаем файл до длины полезных данных, т.е. до 3-х символов
  • в файле оказывается содержимое 890
  • закрываем файл

Такой подход защищён от резкого останова скрипта. Даже если он будет остановлен между 4 и 5 шагами, можно будет в момент чтения отсечь лишние данные начиная с $ символа. При резком отключении всего сервера возникает 2 ситуации, в зависимости от журналируемости файловой системы. Если она не поддерживается или отключена, то в файле может оказаться неконсистентное состояние. Но если журналирование включено, то в файле останется последнее консистентное состояние.

В одной из библиотек на github нашёл такой вариант решения проблемы:

/**
 * Atomic filewriter.
 *
 * Safely writes new contents to a file using an atomic two-step process.
 * If the script is killed before the write is complete, only the temporary
 * trash file will be corrupted.
 *
 * The algorithm also ensures that 100% of the bytes were written to disk.
 *
 * @param string $filename     Filename to write the data to.
 * @param string $data         Data to write to file.
 * @param string $atomicSuffix Lets you optionally provide a different
 *                             suffix for the temporary file.
 *
 * @return int|bool Number of bytes written on success, otherwise `FALSE`.
 */
public static function atomicWrite($filename, $data, $atomicSuffix = 'atomictmp') {
    // Perform an exclusive (locked) overwrite to a temporary file.
    $filenameTmp = sprintf('%s.%s', $filename, $atomicSuffix);
    $writeResult = @file_put_contents($filenameTmp, $data, LOCK_EX);
    // Only proceed if we wrote 100% of the data bytes to disk.
    if ($writeResult !== false && $writeResult === strlen($data)) {
        // Now move the file to its real destination (replaces if exists).
        $moveResult = @rename($filenameTmp, $filename);
        if ($moveResult === true) {
            // Successful write and move. Return number of bytes written.
            return $writeResult;
        }
    }
    // We've failed. Remove the temporary file if it exists.
    if (is_file($filenameTmp)) {
        @unlink($filenameTmp);
    }
    return false; // Failed.
}