Стійке зберігання даних та файлові API Linux

Я, досліджуючи стійкість зберігання даних у хмарних системах, вирішив перевірити себе, переконатися, що розумію базові речі. Я почав з читання специфікації NVMe для того, щоб розібратися з тим, які гарантії щодо стійкого зберігання даних (тобто гарантії того, що дані будуть доступні після збою системи), дають нам NMVe-диски. Я зробив такі основні висновки: потрібно вважати дані пошкодженими з того моменту, як віддано команду запису даних, і до того моменту, як завершиться запис на носій інформації. Однак у більшості програм для запису даних спокійно використовуються системні виклики.

У цьому матеріалі я досліджую механізми стійкого зберігання даних, які надають файлові API Linux. Здається, тут усе має бути просто: програма викликає команду write(), а після завершення роботи цієї команди дані будуть надійно збережені на диску. Але write() лише копіює дані додатки в кеш ядра, що у оперативної пам'яті. Для того, щоб примусити систему до запису даних на диск, потрібно використовувати деякі додаткові механізми.

Стійке зберігання даних та файлові API Linux

В цілому, цей матеріал являє собою набір нотаток, що стосуються того, що я дізнався по темі, що мене цікавить. Якщо дуже коротко розповісти про найважливіше, то вийде, що для організації стійкого зберігання даних треба скористатися командою 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 під час відкриття файлів з використанням системного виклику відчинено(). При такому підході кожна операція запису даних виконується так, начебто після кожної команди 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() для директорії, де створено файл. Ось ці підходи:

  1. Виклик функції fdatasync() або fsync() після функції write() (краще користуватися fdatasync()).
  2. Робота з файловим дескриптором, відкритим із прапором O_DSYNC або O_SYNC (краще - з прапором O_DSYNC).
  3. Використання команди pwritev2() з прапором RWF_DSYNC або RWF_SYNC (переважно - з прапором RWF_DSYNC).

Нотатки про продуктивність

Я не займався ретельним виміром продуктивності різних досліджених мною механізмів. Помічені мною відмінності у швидкості роботи дуже невеликі. Це означає, що я можу помилятися, і те, що в інших умовах те саме може показати інші результати. Спочатку я розповім про те, що сильніше впливає на продуктивність, а потім про те, що впливає на продуктивність менше.

  1. Перезапис даних файлу швидше, ніж приєднання даних файлу (виграш у продуктивності може становити 2-100%). Приєднання даних до файлу вимагає внесення додаткових змін до метаданих файлів, навіть після системного виклику fallocate()але масштаби цього ефекту можуть змінюватися. Я рекомендую, для забезпечення найкращої продуктивності, викликати fallocate() для попереднього виділення необхідного простору. Потім цей простір потрібно явно заповнити нулями і викликати fsync(). Завдяки цьому відповідні блоки у файловій системі будуть позначені як «виділені», а не як «невиділені». Це дає невелике (близько 2%) покращення продуктивності. Крім того, у деяких дисків перша операція доступу до блоку може виконуватися повільніше за інші. Це означає, що заповнення простору нулями може призвести до значного (близько 100%) поліпшення продуктивності. Зокрема, подібне може статися з дисками AWS EBS (це неофіційні дані, підтвердити їх я не зміг). Те саме стосується і сховищ GCP Persistent Disk (а це вже офіційна інформація, підтверджена випробуваннями). Інші фахівці зробили такі ж спостереження, що стосуються різних дисків.
  2. Що менше системних викликів — то вища продуктивність (виграш може становити близько 5%). Схоже, що виклик open() з прапором O_DSYNC або виклик pwritev2() з прапором RWF_SYNC швидше виклику fdatasync(). Підозрюю, що тут у тому, що з такому підході роль грає те, що з вирішення однієї й тієї завдання доводиться виконувати менше системних викликів (один виклик замість двох). Але різниця в продуктивності дуже мала, тому ви можете не звертати на неї уваги і використовувати в додатку те, що не призведе до ускладнення його логіки.

Якщо вам цікава тема стійкого зберігання даних — кілька корисних матеріалів:

  • I/O Access methods - Огляд основ механізмів введення/виводу.
  • Ensuring data reaches disk - Розповідь про те, що відбувається з даними на шляху від додатка до диска.
  • When should you fsync the containing directory - Відповідь на питання про те, коли потрібно застосовувати fsync() для директорій. Якщо розповісти про це в двох словах, то виявиться, що робити це потрібно при створенні нового файлу, а причина цієї рекомендації в тому, що в Linux може бути багато посилань на той самий файл.
  • SQL Server on Linux: FUA Internals - Тут наведено опис того, як стійке зберігання даних реалізовано SQL Server на платформі Linux. Тут є деякі цікаві порівняння між системними викликами Windows та Linux. Я майже впевнений, що саме завдяки цьому матеріалу дізнався про FUA-оптимізацію XFS.

Чи ви втрачали дані, які вважали надійно збереженими на диску?

Стійке зберігання даних та файлові API Linux

Стійке зберігання даних та файлові API Linux

Джерело: habr.com