Я, исследуя устойчивость хранения данных в облачных системах, решил проверить себя, убедиться в том, что понимаю базовые вещи. Я начал с чтения спецификации NVMe для того чтобы разобраться с тем, какие гарантии, касающиеся устойчивого хранения данных (то есть — гарантии того, что данные будут доступны после сбоя системы), дают нам NMVe-диски. Я сделал следующие основные выводы: нужно считать данные повреждёнными с того момента, как отдана команда записи данных, и до того момента, как завершится их запись на носитель информации. Однако в большинстве программ для записи данных совершенно спокойно используются системные вызовы.
В этом материале я исследую механизмы устойчивого хранения данных, предоставляемые файловыми API Linux. Кажется, что тут всё должно быть просто: программа вызывает команду write(), а после того, как работа этой команды завершится, данные будут надёжно сохранены на диске. Но write() лишь копирует данные приложения в кеш ядра, расположенный в оперативной памяти. Для того чтобы принудить систему к записи данных на диск, нужно использовать некоторые дополнительные механизмы.
В целом, этот материал представляет собой набор заметок, касающихся того, что я узнал по интересующей меня теме. Если очень кратко рассказать о самом важном, то получится, что для организации устойчивого хранения данных надо пользоваться командой fdatasync() или открывать файлы с флагом O_DSYNC. Если вам интересно в подробностях узнать о том, что происходит с данными на пути от программного кода к диску, взгляните на эту статью.
Особенности использования функции write()
Системный вызов write() определён в стандарте IEEE POSIX как попытка записи данных в файловый дескриптор. После успешного завершения работы write() операции чтения данных должны возвращать именно те байты, которые были до этого записаны, делая это даже в том случае, если к данным обращаются из других процессов или потоков (вот соответствующий раздел стандарта POSIX). Здесь, в разделе, посвящённом взаимодействию потоков с обычными файловыми операциями, имеется примечание, в котором говорится, что если каждый из двух потоков вызывает эти функции, то каждый вызов должен видеть либо все обозначенные последствия, к которым приводит выполнение другого вызова, либо не видеть вообще никаких последствий. Это позволяет сделать вывод о том, что все файловые операции ввода/вывода должны удерживать блокировку ресурса, с которым работают.
Означает ли это, что операция write() является атомарной? С технической точки зрения — да. Операции чтения данных должны возвращать либо всё, либо ничего из того, что было записано с помощью write(). Но операция write(), в соответствии со стандартом, не обязательно должна завершаться, записав всё то, что ей предложено было записать. Ей позволено выполнить запись лишь части данных. Например, у нас может быть два потока, каждый из которых присоединяет 1024 байта к файлу, описываемому одним и тем же файловым дескриптором. С точки зрения стандарта приемлемым будет результат, когда каждая из операций записи сможет присоединить к файлу лишь по одному байту. Операции эти останутся атомарными, но после того, как они завершатся, данные, записанные ими в файл, окажутся перемешанными. Вот очень интересная дискуссия на эту тему на Stack Overflow.
Функции fsync() и fdatasync()
Самый простой способ сброса данных на диск заключается в вызове функции fsync(). Эта функция запрашивает у операционной системы перенос всех модифицированных блоков из кеша на диск. Сюда входя и все метаданные файла (время доступа, время модификации файла и так далее). Я полагаю, что необходимость в этих метаданных возникает редко, поэтому, если вы знаете о том, что для вас они не важны, вы можете пользоваться функцией fdatasync(). В справке по fdatasync() говорится, что в ходе работы этой функции производится сохранение на диск такого объёма метаданных, который «необходим для корректного выполнения следующих операций чтения данных». А это — именно то, что заботит большинство приложений.
Одна из проблем, которая тут может возникнуть, заключается в том, что эти механизмы не гарантируют того, что файл можно будет обнаружить после возможного сбоя. В частности, когда создают новый файл, нужно вызвать fsync() для директории, которая его содержит. Иначе после сбоя может оказаться так, что этого файла не существует. Причина этого заключается в том, что в UNIX, из-за применения жёстких ссылок, файл может существовать в нескольких директориях. Поэтому при вызове fsync() для файла нет способа узнать о том, данные какой именно директории тоже надо сбросить на диск (тут об этом можно почитать подробнее). Похоже, что файловая система ext4 способна автоматически применять fsync() к директориям, содержащим соответствующие файлы, но в случае с другими файловыми системами это может быть не так.
Этот механизм может быть по-разному реализован в разных файловых системах. Я использовал blktrace для того чтобы узнать о том, какие дисковые операции используются в файловых системах ext4 и XFS. И та и другая выдают обычные команды записи на диск и для содержимого файлов, и для журнала файловой системы, сбрасывают кеш и завершают работу, выполняя FUA-запись (Force Unit Access, запись данных прямо на диск, минуя кеш) в журнал. Вероятно, они поступают именно так для того чтобы подтвердить факт совершения операции. На дисках, которые не поддерживают FUA, это вызывает два сброса кеша. Мои эксперименты показали, что fdatasync() немного быстрее fsync(). Утилита blktrace указывает на то, что fdatasync() обычно пишет на диск меньше данных (в ext4 fsync() записывает 20 КиБ, а fdatasync() — 16 КиБ). Кроме того, я выяснил, что XFS немного быстрее, чем ext4. И тут с помощью blktrace удалось узнать о том, что fdatasync() сбрасывает на диск меньше данных (4 КиБ в XFS).
Неоднозначные ситуации, возникающие при использовании fsync()
Я могу вспомнить три неоднозначных ситуации, касающихися fsync(), с которыми я столкнулся на практике.
Первый такой случай произошёл в 2008 году. Тогда интерфейс Firefox 3 «подвисал» в том случае, если выполнялась запись на диск большого количества файлов. Проблема заключалась в том, что в реализации интерфейса для хранения сведений о его состоянии использовалась база данных SQLite. После каждого изменения, произошедшего в интерфейсе, вызывалась функция fsync(), что давало хорошие гарантии устойчивого хранения данных. В используемой тогда файловой системе ext3 функция fsync() сбрасывала на диск все «грязные» страницы в системе, а не только те, которые имели отношение к соответствующему файлу. Это означало, что щелчок по кнопке в Firefox мог инициировать запись мегабайтов данных на магнитный диск, что могло занять многие секунды. Решение проблемы, насколько я понял из этого материала, заключалось в том, чтобы перенести работу с базой данных в асинхронные фоновые задачи. Это означает, что раньше в Firefox были реализованы более жёсткие требования к устойчивости хранения данных, чем это было реально нужно, а особенности файловой системы ext3 лишь усугубили эту проблему.
Вторая неувязка случилась в 2009 году. Тогда, после сбоя системы, пользователи новой файловой системы ext4 столкнулись с тем, что многие недавно созданные файлы имеют нулевую длину, а вот с более старой файловой системой ext3 подобного не произошло. В предыдущем абзаце я говорил о том, что ext3 сбрасывала на диск слишком много данных, что сильно замедляло работу fsync(). Для того чтобы улучшить ситуацию, в ext4 на диск сбрасываются только те «грязные» страницы, которые имеют отношение к конкретному файлу. А данные других файлов остаются в памяти в течение гораздо более длительного времени, чем при применении ext3. Это было сделано ради улучшения производительности (по умолчанию данные пребывают в таком состоянии 30 секунд, настраивать это можно с помощью dirty_expire_centisecs; тут можно найти дополнительные материалы об этом). Это означает, что большой объём данных может быть безвозвратно утерян после сбоя. Решение этой проблемы заключается в использовании fsync() в приложениях, которым нужно обеспечить устойчивое хранение данных и максимально обезопасить их от последствий сбоев. Функция fsync() работает при применении ext4 гораздо эффективнее, чем при применении ext3. Минус такого подхода заключается в том, что его применение, как и прежде, замедляет выполнение некоторых операций, вроде установки программ. Подробности об этом смотрите здесь и здесь.
Третья проблема, касающаяся fsync(), возникла в 2018 году. Тогда, в рамках проекта PostgreSQL, было выяснено, что если функция fsync() сталкивается с ошибкой, она помечает «грязные» страницы как «чистые». В результате следующие вызовы fsync() ничего с такими страницами не делают. Из-за этого модифицированные страницы хранятся в памяти и никогда не записываются на диск. Это — настоящая катастрофа, так как приложение будет считать, что какие-то данные записаны на диск, а на самом деле это будет не так. Такие сбои fsync() бывают редко, приложение в таких ситуациях почти ничего не может сделать для борьбы с проблемой. В наши дни, когда это происходит, PostgreSQL и другие приложения аварийно завершают работу. Здесь, в материале «Can Applications Recover from fsync Failures?», эта проблема исследуется во всех деталях. В настоящее время лучшим решением этой проблемы является использование Direct I/O с флагом O_SYNC или с флагом O_DSYNC. При таком подходе система сообщит об ошибках, которые могут возникнуть при выполнении конкретных операций записи данных, но этот подход требует того, чтобы приложение управляло бы буферами самостоятельно. Подробности об этом читайте здесь и здесь.
Открытие файлов с использованием флагов O_SYNC и O_DSYNC
Вернёмся к обсуждению механизмов Linux, обеспечивающих устойчивое хранение данных. А именно, речь идёт об использовании флага O_SYNC или флага O_DSYNC при открытии файлов с использованием системного вызова open(). При таком подходе каждая операция записи данных выполняется так, как будто после каждой команды write() системе дают, соответственно, команды fsync() и fdatasync(). В спецификации POSIX это называется «Synchronized I/O File Integrity Completion» и « Data Integrity Completion». Главное преимущество такого подхода заключается в том, что для обеспечения целостности данных нужно выполнить лишь один системный вызов, а не два (например — write() и fdatasync()). Главный недостаток этого подхода в том, что все операции записи, использующие соответствующий файловый дескриптор, будут синхронизированы, что может ограничить возможности по структурированию кода приложения.
Использование Direct I/O с флагом O_DIRECT
Системный вызов open() поддерживает флаг O_DIRECT, который предназначен для того, чтобы, обходя кеш операционной системы, выполнять операции ввода-вывода, взаимодействуя непосредственно с диском. Это, во многих случаях, означает, что команды записи, выдаваемые программой, будут напрямую транслироваться в команды, направленные на работу с диском. Но, в общем случае, этот механизм не является заменой функций fsync() или fdatasync(). Дело в том, что сам диск может отложить или кешировать соответствующие команды записи данных. И, что ещё хуже, в некоторых особых случаях операции ввода-вывода, выполняемые при использовании флага O_DIRECT, транслируются в традиционные буферизованные операции. Легче всего решить эту проблему можно, используя для открытия файлов ещё и флаг O_DSYNC, что будет означать, что за каждой операцией записи будет идти вызов fdatasync().
Оказалось, что в файловой системе XFS недавно был добавлен «быстрый путь» для O_DIRECT|O_DSYNC-записи данных. Если перезаписывают блок с использованием O_DIRECT|O_DSYNC, то XFS, вместо сброса кеша, выполнит команду FUA-записи в том случае, если устройство это поддерживает. Я в этом убедился, пользуясь утилитой blktrace в системе Linux 5.4/Ubuntu 20.04. Такой подход должен быть эффективнее, так как при его использовании на диск записывается минимальное количество данных и при этом применяется одна операция, а не две (запись и сброс кеша). Я нашёл ссылку на патч ядра 2018 года, в котором реализован этот механизм. Там есть обсуждение, касающееся применения этой оптимизации и в других файловых системах, но, насколько мне известно, XFS — это пока единственная файловая система, которая это поддерживает.
Функция sync_file_range()
В Linux есть системный вызов sync_file_range(), который позволяет сбросить на диск лишь часть файла, а не весь файл. Этот вызов инициирует асинхронный сброс данных и не ожидает его завершения. Но в справке к sync_file_range() говорится, что эта команда «очень опасна». Пользоваться ей не рекомендуется. Особенности и опасности sync_file_range() очень хорошо описаны в этом материале. В частности, по видимому, этот вызов использует RocksDB для управления тем, когда ядро сбрасывает «грязные» данные на диск. Но при этом там, для обеспечения устойчивого хранения данных, используется и fdatasync(). В коде RocksDB есть интересные комментарии на эту тему. Например, похоже, что вызов sync_file_range() при использовании ZFS не приводит к сбросу данных на диск. Опыт подсказывает мне, что код, которым пользуются редко, возможно, содержит ошибки. Поэтому я посоветовал бы не пользоваться этим системным вызовом без крайней необходимости.
Системные вызовы, помогающие обеспечить устойчивое хранение данных
Я пришёл к выводу о том, что для выполнения операций ввода/вывода, обеспечивающих устойчивое хранение данных, можно пользоваться тремя подходами. Все они требуют вызова функции fsync() для директории, в которой создан файл. Вот эти подходы:
Вызов функции fdatasync() или fsync() после функции write() (лучше пользоваться fdatasync()).
Работа с файловым дескриптором, открытым с флагом O_DSYNC или O_SYNC (лучше — с флагом O_DSYNC).
Использование команды pwritev2() с флагом RWF_DSYNC или RWF_SYNC (предпочтительнее — с флагом RWF_DSYNC).
Заметки о производительности
Я не занимался тщательным измерением производительности различных исследованных мной механизмов. Замеченные мной отличия в скорости их работы весьма невелики. Это означает, что я могу ошибаться, и то, что в других условиях то же самое может показать другие результаты. Сначала я расскажу о том, что сильнее влияет на производительность, а потом, о том, что влияет на производительность меньше.
Перезапись данных файла быстрее, чем присоединение данных к файлу (выигрыш в производительности может составлять 2-100%). Присоединение данных к файлу требует внесения дополнительных изменений в метаданные файла, даже после системного вызова fallocate(), но масштабы этого эффекта могут меняться. Я рекомендую, для обеспечения наилучшей производительности, вызывать fallocate() для предварительного выделения необходимого пространства. Потом это пространство нужно явным образом заполнить нулями и вызвать fsync(). Благодаря этому соответствующие блоки в файловой системе будут помечены как «выделенные», а не как «невыделенные». Это даёт небольшое (около 2%) улучшение производительности. Кроме того, у некоторых дисков первая операция доступа к блоку может выполняться медленнее других. Это означает, что заполнение пространства нулями способно привести к значительному (около 100%) улучшению производительности. В частности, подобное может произойти с дисками AWS EBS (это — неофициальные данные, подтвердить их я не смог). То же самое касается и хранилищ GCP Persistent Disk (а это — уже официальная информация, подтверждённая испытаниями). Другие специалисты сделали такие же наблюдения, относящиеся к различным дискам.
Чем меньше системных вызовов — тем выше производительность (выигрыш может составлять около 5%). Похоже, что вызов open() с флагом O_DSYNC или вызов pwritev2() с флагом RWF_SYNC быстрее вызова fdatasync(). Подозреваю, что дело тут в том, что при таком подходе роль играет то, что для решения одной и той же задачи приходится выполнять меньше системных вызовов (один вызов вместо двух). Но разница в производительности очень мала, поэтому вы вполне можете не обращать на неё внимания и использовать в приложении то, что не приведёт к усложнению его логики.
Если вам интересна тема устойчивого хранения данных — вот несколько полезных материалов:
When should you fsync the containing directory — ответ на вопрос о том, когда нужно применять fsync() для директорий. Если рассказать об этом в двух словах, то окажется, что делать это нужно при создании нового файла, а причина этой рекомендации в том, что в Linux может быть много ссылок на один и тот же файл.
SQL Server on Linux: FUA Internals — тут приведено описание того, как устойчивое хранение данных реализовано в SQL Server на платформе Linux. Здесь имеются некоторые интересные сравнения между системными вызовами Windows и Linux. Я почти уверен, что именно благодаря этому материалу узнал о FUA-оптимизации XFS.
Теряли ли вы данные, которые считали надёжно сохранёнными на диске?