Устойчиво съхранение на данни и файлови API на Linux

Изучавайки устойчивостта на съхранението на данни в облачните системи, реших да се проверя сам, да се уверя, че разбирам основните неща. Аз започнах с прочитането на спецификацията NVMe за да разбера какви гаранции относно устойчивото съхранение на данни (тоест – гаранции, че данните ще бъдат достъпни след системен срив) ни предоставят дисковете NVMe. Направих следните основни заключения: трябва да се смятат данните за повреждани от момента, в който е дадена команда за запис на данни, и до момента, в който завърши техният запис на носителя. Въпреки това, в повечето програми за запис на данни спокойно се използват системни извиквания.

В този материал изследвам механизмите за устойчиво съхранение на данни, предоставяни от файловите 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 KiB, а fdatasync() — 16 KiB). Освен това, разбрах, че XFS е малко по-бърз от ext4. И с помощта на blktrace успях да разбера, че fdatasync() записва на диска по-малко данни (4 KiB в 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 и други приложения аварийно прекратяват работа. Тук, в материала «Могат ли приложенията да се възстановят от сривове на fsync?», този проблем се изследва в детайли. В момента най-доброто решение на този проблем е използването на Direct I/O с флаг O_SYNC или с флаг O_DSYNC. При такъв подход системата ще съобщи за грешките, които могат да възникнат при изпълнение на конкретни операции за запис на данни, но този подход изисква приложението да управлява буферите само. Подробности по темата можете да прочетете тук. и тук..

Отваряне на файлове с използване на флаговете O_SYNC и O_DSYNC

Да се върнем към разглеждането на механизмите на Linux, които осигуряват устойчиво съхранение на данни. По-конкретно, става въпрос за използването на флага O_SYNC или флага O_DSYNC при отваряне на файлове с използване на системния повик open(). При такъв подход всяка операция за запис на данни се изпълнява така, сякаш след всяка команда write() системата получава, съответно, команди fsync() и fdatasync(). В спецификацията POSIX това се нарича «Завършване на синхронизирано I/O файловата цялост» и «Завършване на целостта на данните». Основното предимство на този подход е, че за осигуряване на целостта на данните е необходимо да се извърши само едно системно повикване, а не две (например — 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 — преглед на основите на механизмите за вход/изход.
  • Гарантиране на достигане на данни до диска — разказ за това, което се случва с данните на пътя от приложението до диска.
  • Кога трябва да освежите директориятa с fsync — отговор на въпроса кога е необходимо да се прилага fsync() за директории. Ако да се разкаже за това с две думи, то се оказва, че трябва да го направите при създаването на нов файл, а причината за това е, че в Linux може да има много препратки към един и същ файл.
  • SQL Server на Linux: Вътрешности на FUA — тук е представено описание на това как устойчивото съхранение на данни е реализирано в SQL Server на платформата Linux. Има някои интересни сравнения между системните повиквания на Windows и Linux. Почти съм сигурен, че именно благодарение на този материал узнах за FUA-оптимизацията на XFS.

Загубили ли сте данни, които смятате, че са надеждно запазени на диска?

Устойчиво съхранение на данни и файлови API на Linux

Устойчиво съхранение на данни и файлови API на Linux

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster