Если в кратце, то суть такая: 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. }