Устойлівае захоўванне дадзеных і файлавыя 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/XNUMX. Такі падыход павінен быць эфектыўней, бо пры ім выкарыстанні на дыск запісваецца мінімальная колькасць дадзеных і пры гэтым ужываецца адна аперацыя, а не дзве (запіс і скід кэша). Я знайшоў спасылку на патч ядра 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 - аповяд аб тым, што адбываецца з дадзенымі на шляху ад прыкладання да дыска.
  • Калі вы маеце на мэце пазначэнне directory - адказ на пытанне аб тым, калі трэба ўжываць fsync() для дырэкторый. Калі распавесці пра гэта ў двух словах, тое апынецца, што рабіць гэта трэба пры стварэнні новага файла, а чыннік гэтай рэкамендацыі ў тым, што ў Linux можа быць шмат спасылак на адзін і той жа файл.
  • SQL Server на Linux: FUA Internals - тут прыведзена апісанне таго, як устойлівае захоўванне дадзеных рэалізавана ў SQL Server на платформе Linux. Тут маюцца некаторыя цікавыя параўнанні паміж сістэмнымі выклікамі Windows і Linux. Я амаль упэўнены, што менавіта дзякуючы гэтаму матэрыялу даведаўся аб FUA-аптымізацыі XFS.

Ці гублялі вы дадзеныя, якія лічылі надзейна захаванымі на дыску?

Устойлівае захоўванне дадзеных і файлавыя API Linux

Устойлівае захоўванне дадзеных і файлавыя API Linux

Крыніца: habr.com