Проблемы с вводом-выводом на диск: что может пойти не так
Разработка приложений, особенно тех, которые работают с файлами в транзакционных контекстах, требует учета различных сценариев, которые могут привести к ошибкам. Это особенно важно, когда речь идет о целостности данных, например, при редактировании данных на месте, а не при использовании подхода "копирование при записи". Рассмотрим несколько ситуаций, которые могут возникнуть:
- Данные, которые вы записываете, никогда не достигают диска.
- Данные записываются не в то место на диске.
- Данные считываются не с того места на диске.
- Данные повреждаются на диске.
Также стоит рассмотреть, как реальные системы управления данными справляются с этими сценариями (если вообще справляются).
Если не указано иное, речь идет о поведении в Linux.
Терминология
Некоторые термины могут использоваться в разных контекстах, и иногда они означают одно и то же в определённой конфигурации. Постараюсь быть ясным, чтобы избежать путаницы.
Сектор
Наименьший объём данных, который может быть прочитан и записан атомарно аппаратурой. Раньше это было 512 байт, но на современных дисках это часто 4 КиБ. Нельзя делать никаких предположений о размере сектора, несмотря на стандартные значения файловых систем. Необходимо проверять диски, чтобы узнать размер сектора.
Блок (видение файловой системы/ядра)
Обычно устанавливается равным размеру сектора, так как только этот размер блока является атомарным. По умолчанию в ext4 это 4 КиБ.
Страница (видение ядра)
Блок диска, который находится в памяти. Любые чтения/записи меньше размера блока будут читать весь блок в память ядра, даже если меньше данных отправляется обратно в пользовательское пространство.
Страница (видение базы данных/приложения)
Наименьший объём данных, с которым работает система (база данных, приложение и т.д.) при чтении или записи или при хранении в памяти. Размер страницы является кратным размеру блока файловой системы/ядра (включая кратность, равную 1). Размер страницы по умолчанию в SQLite — 4 КиБ, в MySQL — 16 КиБ, в Postgres — 8 КиБ.
Что может пойти не так
Данные не достигли диска
По умолчанию, запись файлов считается успешной, когда данные скопированы в память ядра (буферизованный ввод-вывод). Страница справочника для write(2)
говорит:
Успешное возвращение из write()
не гарантирует, что данные были зафиксированы на диске. На некоторых файловых системах, включая NFS, это не гарантирует даже успешного резервирования места для данных. В этом случае некоторые ошибки могут быть отложены до будущего вызова write()
, fsync(2)
или даже close(2)
. Единственный способ быть уверенным — вызвать fsync(2)
после завершения записи всех данных.
Если вы не вызываете fsync
на Linux, данные не обязательно будут надёжно записаны на диск, и если система сбоит или перезагрузится до того, как диск запишет данные в неизменяемое хранилище, вы можете потерять данные.
С O_DIRECT
запись файлов считается успешной, когда данные скопированы хотя бы в кэш диска. Альтернативно можно открыть файл с O_DIRECT|O_SYNC
(или O_DIRECT|O_DSYNC
) и отказаться от вызовов fsync
.
fsync
на macOS является пустышкой (no-op).
Postgres, SQLite, MongoDB, MySQL по умолчанию выполняют fsync
данных перед тем, как считать транзакцию успешной. RocksDB этого не делает.
fsync
был вызван, но не удался
fsync
не гарантирует успех. И когда он терпит неудачу, нельзя сказать, какая именно запись не удалась. Это может быть даже не ошибка записи в файл, который открыл ваш процесс:
В идеале ядро должно сообщать об ошибках только на файловых дескрипторах, на которых были выполнены записи, которые затем не удалось записать обратно. Однако общая инфраструктура pagecache не отслеживает файловые дескрипторы, которые загрязнили каждую отдельную страницу, поэтому определить, какие файловые дескрипторы должны получить ошибку, невозможно.
Вместо этого общая инфраструктура обработки ошибок записи в ядре довольствуется сообщением об ошибках fsync
на всех файловых дескрипторах, которые были открыты в момент возникновения ошибки. В ситуации с несколькими процессами записи все они получат ошибку при последующем fsync
, даже если все записи, выполненные через этот конкретный файловый дескриптор, были успешными (или даже если на этом файловом дескрипторе не было записей).
Единственный способ узнать, какая именно запись не удалась, — это открыть файл с O_DIRECT|O_SYNC
(или O_DIRECT|O_DSYNC
), хотя это не единственный способ обработки неудач fsync
.
Данные были повреждены
Если вы не проверяете контрольные суммы данных при записи и не проверяете их при чтении (а также периодически не проводите проверку, как это делает ZFS), вы никогда не узнаете, если и когда данные были повреждены, и вам придётся восстанавливать их из резервных копий, если вы вообще заметите проблему.
ZFS, MongoDB (WiredTiger), MySQL (InnoDB) и RocksDB по умолчанию проверяют контрольные суммы данных. Postgres и SQLite этого не делают (хотя базы данных, созданные из Postgres 18+, будут).
Вероятно, стоит включить проверку контрольных сумм на любой системе, которая это поддерживает, независимо от значения по умолчанию.