Deep Engineering
Средний·Опубликовано·25 МИН

Page cache и fsync: почему «записали» и «не потеряется» — разные утверждения

`write` возвращается до того, как данные окажутся на диске: они попадают в кеш ядра. Отсюда всё остальное — почему записанное переживает падение процесса, но при потере питания может быть потеряно, почему сброс дороже записи в десятки раз и почему база данных фиксирует транзакции пачками.

Полное техническое изложение

TL;DR

write доводит данные до кеша ядра и возвращается — на этом его работа кончается: A successful return from write() does not make any guarantee that data has been committed to disk (Успешный возврат из write() никак не гарантирует, что данные зафиксированы на диске). Кеш при этом — память ядра, а не процесса: данные в нём уже общие, но ещё не устойчивые. Устойчивость просят отдельно, вызовом сброса.

Отсюда главное следствие: две аварии различаются. Измерено: процесс записал без сброса и был убит SIGKILL — данные на месте и видны другому процессу, то есть падение процесса не теряет ничего. А при потере питания данные, ещё не ставшие устойчивыми, могут быть потеряны — вместе с памятью, в которой кеш и жил. Устойчивость и стоит соответственно: измерено на этой машине, запись 4 КиБ — 1,8 мкс, запись со сбросом — 164,7 мкс, отношение 90,1. Числа у вас будут другими, разрыв в два порядка — тем же.

Дальше — то, что отличает знающего от читавшего. fsync и fdatasync здесь неразличимы, и это тоже результат: разрыв 0,9 мкс при разбросе 51,1 мкс между повторами одного и того же режима, а последняя строка блока, difference resolvable on this machine, говорит no — заявлять кратность, которой не видно, нельзя. Один сброс на сто записей дешевле ста сбросов в разы: измерено 17,2 мс против 1,0 мс, и ровно поэтому базы данных фиксируют транзакции группами. fsync файла не делает долговечным его имя: создание файла — изменение каталога, а каталог синхронизируется отдельно, For that an explicit fsync() on a file descriptor for the directory is also needed (для этого нужен отдельный вызов fsync() на дескрипторе каталога). И закрытие файла не сбрасывает ничего: Typically, filesystems do not flush buffers when a file is closed (Как правило, файловые системы не сбрасывают буферы при закрытии файла) — привычка «закрыл, значит сохранил» не имеет основания.

Порог входа
Перед уроком достаточно понимать
  • программа записывает данные в файл и получает от этой операции успех или ошибку;
  • у машины есть оперативная память, которая исчезает при выключении питания, и есть устройство хранения, которое не исчезает;
  • процесс может умереть, а машина при этом продолжит работать, — и наоборот.
Заранее знать не нужно
  • что такое кеш страниц, фоновая запись ядра и чем кеш ядра отличается от буфера в самой программе;
  • fsync, fdatasync, O_DIRECT, групповая фиксация, сброс каталога.

Что здесь на самом деле спрашивают

Лестница обычно такая:

  1. «Что делает write — разминка, на которой выясняется, думает ли собеседник, что данные ушли на диск.
  2. «Данные потеряются, если процесс упадёт сразу после write — здесь начинается содержание.
  3. «А если упадёт машина?» — тот же вопрос, другой ответ, и разница объясняет весь механизм.
  4. «Чем fsync отличается от fdatasync — знаете ли вы, что различаются они метаданными.
  5. «Почему база данных не делает fsync на каждую запись?» — вопрос про цену.
  6. «Достаточно ли fsync файла, чтобы файл точно существовал?» — вопрос, где долговечность содержимого и долговечность имени расходятся.

Числа получены прогоном bench/durability/fsync.py и bench/durability/practice.py. Времена зависят от диска сильно: у вас будут другие. От машины не зависят наблюдения: что переживает падение процесса и что обещает каждый вызов.

База: где данные после того, как запись вернула успех

Прежде чем говорить о кеше и о сбросе, стоит назвать обычными словами путь, по которому идут данные. У него четыре участка:

  1. приложение вызвало запись — передало ядру кусок памяти и сказало, в какой файл его положить;
  2. память ядра — данные приняты и лежат в области, которой распоряжается ядро; вызов на этом возвращается;
  3. файловая система — она решает, куда именно на устройстве эти байты лягут и какие служебные записи при этом надо поправить;
  4. устройство хранения — то место, где байты остаются, когда питания нет.

Ключевое здесь — на каком участке заканчивается вызов записи. Он заканчивается на втором, а не на четвёртом: приложение получает успех, когда данные приняло ядро, а не когда их принял диск. Дальше их несёт ядро — само, в своём темпе, и приложение при этом ничего не ждёт и ни о чём не узнаёт.

И вот главный вопрос урока: запись вернула успех — где на самом деле данные? От ответа зависит всё остальное: что с ними случится, если сейчас умрёт процесс, что случится, если пропадёт питание, и во что обходится требование, чтобы они уже точно были на устройстве.

Короткий ответ такой: данные в памяти ядра. Эта память общая — она не принадлежит записавшему процессу и переживает его смерть. Но она же и исчезает вместе с питанием, поэтому «данные приняты» и «данные устойчивы» — два разных утверждения, и второе нужно просить отдельным вызовом.

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

Механизм 1: write доводит данные до кеша, а не до диска

контракт языкаГарантии, записанные в write(2), fsync(2) и close(2). От файловой системы зависит многое, но не эти три утверждения.

Начнём с того, что write(2) говорит о себе прямо:

A successful return from write() does not make any guarantee that data has been committed to disk. On some filesystems, including NFS, it does not even guarantee that space has successfully been reserved for the data. In this case, some errors might be delayed until a future write(2), fsync(2), or even close(2).

перевод

Успешный возврат из write() никак не гарантирует, что данные зафиксированы на диске. На некоторых файловых системах, включая NFS, он не гарантирует даже того, что под данные успешно зарезервировано место. В этом случае часть ошибок может отложиться до следующего write(2), fsync(2) или даже close(2).

write(2)

Обратите внимание на вторую половину: ошибка может прийти позже, и прийти она может в close. Это первое практическое следствие: код, который игнорирует результат close, теряет ошибки записи молча.

Что делает write на самом деле — переносит данные в page cache, кеш страниц файлов, живущий в памяти ядра. Оттуда их когда-нибудь вынесет на диск фоновая запись ядра; когда именно, приложение не знает и не решает.

И раз кеш — это память ядра, привычка «закрыл файл, значит сохранил» тоже не имеет оснований, о чём close(2) говорит отдельно:

A successful close does not guarantee that the data has been successfully saved to disk, as the kernel uses the buffer cache to defer writes. Typically, filesystems do not flush buffers when a file is closed.

перевод

Успешное закрытие не гарантирует, что данные удалось сохранить на диск, поскольку ядро откладывает запись через буферный кеш. Как правило, файловые системы не сбрасывают буферы при закрытии файла.

close(2)

Механизм 2: падение процесса и потеря питания — разные события

Самый полезный вопрос темы: если данные ещё не на диске, что их теряет? Проверяем самым грубым способом. Процесс пишет в файл, fsync не зовёт — и получает SIGKILL, то есть умирает без единой строки уборки:

2. WHOSE MEMORY THE PAGE CACHE IS
---------------------------------
  child wrote without fsync, then SIGKILL      exit -9
  file content after the kill                  survived the kill
наблюдение замераbench/durability/fsync.py, Linux 6.18.44. Наблюдение не зависит от машины: кеш принадлежит ядру, а не процессу.

exit -9 — так о смерти по сигналу сообщает subprocess: отрицательное число здесь и есть номер сигнала. Оболочка на том же месте напечатала бы 137, то есть 128 + 9 (урок про сигналы).

Данные на месте. Более того, их видит другой процесс: bench/durability/practice.py печатает содержимое файла дважды — глазами того, кто прочитал его после смерти автора, и глазами постороннего процесса. Обе строки одинаковы, потому что чтение идёт через тот же кеш.

Отсюда различение, которым стоит отвечать:

  • Падение процесса не теряет ничего, что дошло до write. Кеш переживёт и падение, и убийство, и OOM-killer.
  • Потеря питания — другое дело: данные, ещё не ставшие устойчивыми, при ней могут быть потеряны, потому что память, в которой кеш и жил, выключается вместе с машиной.

Обратите внимание на «могут быть». Гарантии, что несброшенное уцелеет, нет никакой; гарантии, что оно непременно пропадёт, тоже нет — часть страниц ядро могло вынести на устройство фоновой записью раньше, и какие именно, приложение не знает. Планировать на этом нельзя: с точки зрения приложения всё несброшенное — это данные, о судьбе которых после отключения питания ничего не известно.

Именно поэтому «у нас всё в порядке, приложение просто перезапустилось» — честная фраза, а «у нас всё в порядке, машина просто перезагрузилась» — нет.

Механизм 3: что обещает fsync и сколько это стоит

Обещание записано в fsync(2):

fsync() transfers ("flushes") all modified in-core data of (i.e., modified buffer cache pages for) the file referred to by the file descriptor fd to the disk device.

перевод

fsync() переносит («сбрасывает») на дисковое устройство все изменённые данные файла, находящиеся в памяти ядра, — то есть изменённые страницы буферного кеша для файла, на который ссылается дескриптор fd.

fsync(2)

Цена этого обещания и есть главное измерение урока:

1. THE PRICE OF DURABILITY, PER WRITE
-------------------------------------
  write only (4 KiB), median                   1.4 us
  write + fdatasync, median                    142.9 us
  write + fsync, median                        143.9 us
  fsync over plain write                       104x
  gap between fsync and fdatasync              0.9 us
  run-to-run spread of the same mode           51.1 us
  difference resolvable on this machine        no
наблюдение замераbench/durability/fsync.py, Linux 6.18.44, виртуальный диск контейнера. Абсолютные микросекунды у вас будут другими; содержательна кратность и её порядок.

Этот блок снят прогоном bench/durability/fsync.py. В задачах ниже цитируется второй прогон, bench/durability/practice.py: там та же пара получилась 1,8 и 164,7 мкс, а кратность — 90,1. Два прогона на одной машине расходятся именно так, как обещает строка run-to-run spread: содержателен порядок кратности, а не её цифра.

Две вещи в этом блоке важны по-разному.

Кратность — содержательна. Сотня раз: столько стоит разница между «данные в памяти ядра» и «данные на устройстве». Это не свойство Linux и не недоработка, а физика: во втором случае в дело вступает устройство и его подтверждение.

Разница между fsync и fdatasync — не различима здесь, и скрипт это печатает сам. Разрыв 0,9 мкс при разбросе 51,1 мкс между повторами одного и того же режима: скрипт меряет каждый режим пятью кругами, и это строка run-to-run spread of the same mode. Заявлять кратность в такой ситуации нельзя, и последняя строка блока, difference resolvable on this machine, говорит no. Различие между вызовами при этом реальное и записано в документации:

fdatasync() is similar to fsync(), but does not flush modified metadata unless that metadata is needed in order to allow a subsequent data retrieval to be correctly handled.

перевод

fdatasync() подобен fsync(), но не сбрасывает изменённые метаданные, если только они не нужны для корректного последующего чтения данных.

fsync(2)

То есть fdatasync экономит на записи метаданных — и выигрыш появляется там, где эти метаданные дороги: при росте файла, на файловых системах с журналом метаданных, на медленных устройствах. На виртуальном диске этого контейнера экономия утонула в разбросе, и честный ответ здесь — «не измерено», а не «одинаково».

Механизм 4: почему база данных не делает fsync на каждую запись

Кратность из прошлого блока — это цена одного обещания. Дальше вопрос арифметики: сколько раз его требовать.

3. ONE FSYNC FOR MANY RECORDS, OR ONE EACH
------------------------------------------
  100 records, fsync after each                17.2 ms
  100 records, one fsync at the end            1.0 ms
  ratio                                        17x
наблюдение замераbench/durability/fsync.py. Те же сто записей и тот же диск; разница только в числе требований долговечности.

Байты те же, диск тот же, и после последней строки данные одинаково долговечны в обоих случаях. Разница — в числе точек, в которых мы потребовали подтверждения.

Отсюда групповая фиксация, которую в базах называют group commit: транзакции, пришедшие почти одновременно, ждут общего сброса. Каждая ждёт дольше, чем ждала бы в одиночку, но одна задержка обслуживает всю группу, а не каждую транзакцию по отдельности.

И отсюда же ответ на вопрос «почему нельзя просто делать fsync на каждый запрос»: можно, и это будет корректно. Цена — та самая кратность, помноженная на число запросов, и она превращается в предел пропускной способности.

Глубже: имя файла живёт не в файле

Последняя ступень — про место, где долговечность содержимого и долговечность имени расходятся. Вы создали файл, записали, вызвали fsync, машина упала. Гарантированно ли файл существует?

fsync(2) отвечает недвусмысленно:

Calling fsync() does not necessarily ensure that the entry in the directory containing the file has also reached disk. For that an explicit fsync() on a file descriptor for the directory is also needed.

перевод

Вызов fsync() не гарантирует, что запись о файле в содержащем его каталоге тоже дошла до диска. Для этого нужен отдельный вызов fsync() на дескрипторе каталога.

fsync(2)
4. WHAT FSYNC IS FOR: THE FILE, NOT THE DIRECTORY
-------------------------------------------------
  file synced                                  yes
  directory sync cost                          0.11 ms
наблюдение замераbench/durability/fsync.py. Сброс каталога — отдельный вызов и отдельная цена, здесь малая: каталог маленький.

Замер здесь отвечает не на вопрос «нужен ли сброс каталога» — на него отвечает документация, — а на вопрос «сколько он стоит».

Содержимое файла и факт его существования под этим именем — два разных объекта, и синхронизируются они отдельно. Отсюда обязательная последовательность для атомарной замены файла, которую и спрашивают: записать во временный файл, сбросить его, переименовать поверх целевого, сбросить каталог. Пропуск последнего шага — это код, который работает до первого отключения питания и теряет ровно то, что считал записанным.

Как отвечать на собеседовании

Короткий ответ: write доводит данные только до кеша ядра и возвращается; долговечность обещает fsync, и стоит она на два порядка дороже. Поэтому падение процесса не теряет записанного — кеш принадлежит ядру, — а при потере питания всё несброшенное может быть потеряно.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Хороший ответ отличают три вещи. Первая — вы различаете две аварии: смерть процесса и отключение питания, и говорите, почему ответ разный. Вторая — вы называете цену числом и знаете, что из неё следует: групповая фиксация существует не ради красоты, а потому что сброс на каждую запись упирается в устройство. Третья — вы помните про каталог: fsync файла делает долговечным содержимое, а не имя, и при атомарной замене файла синхронизировать нужно оба.

И одна аккуратность в формулировке, которую замечают. «Падение машины теряет данные» — слишком сильно сказано: правильно говорить, что данные, ещё не ставшие устойчивыми, при потере питания могут быть потеряны. Часть их ядро могло успеть вынести фоновой записью, и какая именно часть — приложению неизвестно. Практический вывод от этого не меняется, а вот утверждение становится верным: на несброшенное нельзя рассчитывать ни в одну, ни в другую сторону.

Дальше спросят

Спросят дальше

Если fsync такой дорогой, можно ли просто полагаться на кеш?

Короткий ответ

Можно — ровно до первого отключения питания. Всё, что не сброшено, живёт в памяти ядра, и в этом смысле «данные записаны» означает «данные записаны, пока машина включена».

Практический выбор поэтому не «сбрасывать или нет», а где поставить границу: какие данные обязаны пережить отключение питания, а какие можно потерять. Журнал транзакций — обязаны; кеш отрисованных картинок — нет. Эта граница и определяет, где в коде стоит fsync.

Спросят дальше

Ошибка записи придёт в write?

Короткий ответ

Не обязательно. write(2) прямо предупреждает, что часть ошибок откладывается до следующего write, до fsync или даже до close. Причина та же: во время write данные до устройства не доходили, и об устройстве ещё ничего не известно.

Отсюда правило, которое в коде нарушают чаще всего: результат close проверять обязательно, как и результат fsync. Код, который закрывает файл без проверки, теряет ровно те ошибки, ради которых всё и затевалось.

Спросят дальше

Помогает ли fsync, если диск врёт о завершении записи?

Короткий ответ

Нет, и это граница обещания. fsync доводит данные до устройства и ждёт его подтверждения; если устройство подтверждает раньше, чем данные оказались в энергонезависимой памяти, гарантия рушится на уровне, куда ядро не достаёт.

Практически это означает, что долговечность — свойство всей цепочки: приложение, ядро, файловая система, контроллер, диск. Проверяют её не рассуждением, а испытанием с внезапным отключением питания — и по этой причине такие испытания входят в приёмку систем хранения.

Спросят дальше

Что даёт O_DIRECT?

Короткий ответ

O_DIRECT — не про долговечность, а про обход кеша: данные идут мимо page cache, и приложение само отвечает за буферизацию и выравнивание. Долговечность из этого не следует — устройство всё ещё может держать запись в своём кеше, поэтому базы данных сочетают прямой ввод-вывод со сбросом.

Смысл O_DIRECT в другом: когда приложение уже держит собственный кеш (а база данных держит), кеш ядра дублирует его и тратит память дважды.

Частые заблуждения

Утверждение

если write вернулся успешно, данные на диске

На самом деле

Нет: A successful return from write() does not make any guarantee that data has been committed to disk (Успешный возврат из write() никак не гарантирует, что данные зафиксированы на диске). Данные дошли до кеша ядра, и когда они попадут на устройство, решает не приложение. Обещание даёт только fsync — и стоит оно на два порядка дороже: измерено 1,8 мкс против 164,7.

Утверждение

закрыл файл — значит сохранил

На самом деле

close(2) отвечает прямо: Typically, filesystems do not flush buffers when a file is closed (Как правило, файловые системы не сбрасывают буферы при закрытии файла). Более того, именно в close может прийти ошибка предыдущей записи — поэтому его результат обязателен к проверке.

Утверждение

если процесс упадёт до fsync, данные потеряются

На самом деле

Не потеряются. Измерено: процесс записал без сброса и был убит SIGKILL — содержимое на месте и видно другому процессу. Кеш принадлежит ядру, а не процессу. А вот при потере питания несброшенные данные могут быть потеряны — вместе с памятью, в которой кеш и жил, — и в этом вся разница между двумя авариями.

Утверждение

fdatasync заметно быстрее fsync

На самом деле

На этой машине разница не различима: разрыв 0,9 мкс при разбросе между кругами 51,1 мкс, и скрипт сам печатает в строке difference resolvable on this machine значение no. Различие реально и записано в документации — fdatasync не сбрасывает метаданные, если они не нужны для чтения, — но выигрыш появляется там, где метаданные дороги, а не всегда.

Утверждение

fsync на каждую запись — это правильно, просто медленно

На самом деле

Это корректно и стоит измеримо: сто записей со сбросом после каждой — 17,2 мс, те же сто с одним сбросом в конце — 1,0 мс, разница в семнадцать раз. Долговечность после последней строки одинаковая. Разница — только в числе требований подтверждения, и на этом стоит групповая фиксация в базах данных.

Утверждение

fsync файла гарантирует, что файл существует

На самом деле

Гарантирует, что долговечно его содержимое. Имя живёт в каталоге, и это отдельный объект: For that an explicit fsync() on a file descriptor for the directory is also needed (Для этого нужен отдельный вызов fsync() на дескрипторе каталога). Отсюда обязательный последний шаг атомарной замены — сброс каталога.

Утверждение

O_DIRECT делает запись долговечной

На самом деле

O_DIRECT обходит кеш ядра, но не отменяет кеш устройства: подтверждение может прийти раньше, чем данные окажутся в энергонезависимой памяти. Поэтому базы данных сочетают прямой ввод-вывод со сбросом, а не заменяют одно другим.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Процесс открыл файл, записал в него строку, fsync не вызывал — и был убит SIGKILL. Печатаются две вещи: что лежит в файле после его смерти и что видит в этом файле совершенно другой процесс. Что напечатает этот код?
print(content_after_kill(path) or "(empty)")
print(seen_by_another_process(path) or "(empty)")

Практика · оцените

Во сколько раз запись 4 КиБ со сбросом на диск дороже такой же записи без сброса?
раз

Проверка знаний

Вопрос 1 из 6

Процесс записал данные в файл и был убит SIGKILL до того, как вызвал fsync. Что с данными?

Источники и что читать дальше

3 ИСТОЧНИКА

  1. fsync(2), Linux man-pages 6.7Официальная документация. Что именно обещает сброс: «fsync() transfers ("flushes") all modified in-core data of (i.e., modified buffer cache pages for) the file referred to by the file descriptor fd to the disk device» (fsync() переносит („сбрасывает“) на дисковое устройство все изменённые данные файла, находящиеся в памяти ядра, — то есть изменённые страницы буферного кеша для файла, на который ссылается дескриптор fd). И чего он НЕ обещает — это половина урока: «Calling fsync() does not necessarily ensure that the entry in the directory containing the file has also reached disk. For that an explicit fsync() on a file descriptor for the directory is also needed» (Вызов fsync() не гарантирует, что запись о файле в содержащем его каталоге тоже дошла до диска. Для этого нужен отдельный вызов fsync() на дескрипторе каталога). Разница с fdatasync: «fdatasync() is similar to fsync(), but does not flush modified metadata unless that metadata is needed in order to allow a subsequent data retrieval to be correctly handled» (fdatasync() подобен fsync(), но не сбрасывает изменённые метаданные, если только они не нужны для корректного последующего чтения данных).https://man7.org/linux/man-pages/man2/fsync.2.html
  2. write(2), Linux man-pages 6.7Официальная документация. Почему возврат из write ничего не говорит о диске: «A successful return from write() does not make any guarantee that data has been committed to disk. On some filesystems, including NFS, it does not even guarantee that space has successfully been reserved for the data. In this case, some errors might be delayed until a future write(2), fsync(2), or even close(2)» (Успешный возврат из write() никак не гарантирует, что данные зафиксированы на диске. На некоторых файловых системах, включая NFS, он не гарантирует даже того, что под данные успешно зарезервировано место. В этом случае часть ошибок может отложиться до следующего write(2), fsync(2) или даже close(2)).https://man7.org/linux/man-pages/man2/write.2.html
  3. close(2), Linux man-pages 6.7Официальная документация. Против привычки «закрыл файл — значит сохранил»: «A successful close does not guarantee that the data has been successfully saved to disk, as the kernel uses the buffer cache to defer writes. Typically, filesystems do not flush buffers when a file is closed» (Успешное закрытие не гарантирует, что данные удалось сохранить на диск, поскольку ядро откладывает запись через буферный кеш. Как правило, файловые системы не сбрасывают буферы при закрытии файла).https://man7.org/linux/man-pages/man2/close.2.html