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

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

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

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

  1. открывается файл (php_stream_open_wrapper_ex) по-умолчанию в режиме w, что производит очистку файла! Флаг FILE_APPEND открывает файл в режиме a, но этот кейс не рассматриваем.
  2. если передан флаг LOCK_EX, то файл открывается в режиме c и дополнительно очищается (php_stream_truncate_set_size)
  3. что-то пишется в файл (php_stream_copy_to_stream_ex || php_stream_write)
  4. файл закрывается (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% не входящую в полезные данные. После чего файл обрезается до длины полезных данных. А теперь рассмотрим этот алгоритм пошагово.

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

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

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *