Stocare durabilă a datelor și API-uri pentru fișiere Linux

Eu, cercetând stabilitatea stocării datelor în sistemele cloud, am decis să mă testez, pentru a mă asigura că înțeleg lucrurile de bază. eu a început prin a citi specificațiile NVMe pentru a înțelege ce garanții privind persistența datelor (adică garanții că datele vor fi disponibile după o defecțiune a sistemului) ne oferă discuri NMVe. Am făcut următoarele concluzii principale: trebuie să luați în considerare datele deteriorate din momentul în care este dată comanda de scriere a datelor și până în momentul în care sunt scrise pe mediul de stocare. Cu toate acestea, în majoritatea programelor, apelurile de sistem sunt folosite destul de sigur pentru a scrie date.

În acest articol, explorez mecanismele de persistență oferite de API-urile de fișiere Linux. Se pare că totul ar trebui să fie simplu aici: programul apelează comanda write(), iar după finalizarea operațiunii acestei comenzi, datele vor fi stocate în siguranță pe disc. Dar write() copiează numai datele aplicației în memoria cache a nucleului aflat în RAM. Pentru a forța sistemul să scrie date pe disc, trebuie utilizate unele mecanisme suplimentare.

Stocare durabilă a datelor și API-uri pentru fișiere Linux

În general, acest material este un set de note referitoare la ceea ce am învățat pe un subiect de interes pentru mine. Dacă vorbim foarte pe scurt despre cele mai importante, se dovedește că pentru a organiza stocarea durabilă a datelor, trebuie să utilizați comanda fdatasync() sau deschideți fișiere cu steag O_DSYNC. Dacă sunteți interesat să aflați mai multe despre ce se întâmplă cu datele pe drumul de la cod la disc, aruncați o privire la acest articol.

Caracteristici ale utilizării funcției write().

Apel de sistem write() definite în standard IEEE POSIX ca o încercare de a scrie date într-un descriptor de fișier. După finalizarea cu succes a lucrărilor write() operațiunile de citire a datelor trebuie să returneze exact octeții care au fost scriși anterior, făcând acest lucru chiar dacă datele sunt accesate din alte procese sau fire (aici secțiunea corespunzătoare a standardului POSIX). Aici, în secțiunea despre interacțiunea firelor de execuție cu operațiunile normale de fișiere, există o notă care spune că dacă două fire de execuție apelează fiecare aceste funcții, atunci fiecare apel trebuie fie să vadă toate consecințele indicate la care duce execuția celuilalt apel, fie nu văd deloc consecințe. Acest lucru duce la concluzia că toate operațiunile I/O de fișiere trebuie să dețină o blocare asupra resursei la care se lucrează.

Înseamnă asta că operația write() este atomic? Din punct de vedere tehnic, da. Operațiile de citire a datelor trebuie să returneze fie tot, fie nimic din ceea ce a fost scris write(). Dar operațiunea write(), în conformitate cu standardul, nu trebuie să se încheie, având notat tot ceea ce i s-a cerut să noteze. Este permis să scrieți doar o parte din date. De exemplu, am putea avea două fluxuri care adaugă fiecare 1024 de octeți la un fișier descris de același descriptor de fișier. Din punctul de vedere al standardului, rezultatul va fi acceptabil atunci când fiecare dintre operațiile de scriere poate adăuga doar un octet la fișier. Aceste operațiuni vor rămâne atomice, dar după finalizare, datele pe care le scriu în fișier vor fi amestecate. Aici discuție foarte interesantă pe acest subiect pe Stack Overflow.

funcțiile fsync() și fdatasync().

Cel mai simplu mod de a șterge datele pe disc este apelarea funcției fsync(). Această funcție cere sistemului de operare să mute toate blocurile modificate din cache pe disc. Aceasta include toate metadatele fișierului (timpul de acces, timpul de modificare a fișierului și așa mai departe). Cred că aceste metadate sunt rareori necesare, așa că dacă știi că nu este importantă pentru tine, poți folosi funcția fdatasync(). În Ajutor pe fdatasync() se spune că în timpul funcționării acestei funcții, o astfel de cantitate de metadate este salvată pe disc, ceea ce este „necesar pentru executarea corectă a următoarelor operațiuni de citire a datelor”. Și asta este exact ceea ce interesează majoritatea aplicațiilor.

O problemă care poate apărea aici este că aceste mecanisme nu garantează că fișierul poate fi găsit după o posibilă defecțiune. În special, atunci când este creat un fișier nou, ar trebui să apelați fsync() pentru directorul care îl conține. În caz contrar, după un accident, se poate dovedi că acest fișier nu există. Motivul pentru aceasta este că sub UNIX, datorită utilizării de link-uri hard, un fișier poate exista în mai multe directoare. Prin urmare, atunci când suni fsync() nu există nicio modalitate ca un fișier să știe ce date director ar trebui să fie, de asemenea, spălate pe disc (aici puteți citi mai multe despre asta). Se pare că sistemul de fișiere ext4 este capabil în mod automat aplica fsync() către directoare care conțin fișierele corespunzătoare, dar acest lucru poate să nu fie cazul altor sisteme de fișiere.

Acest mecanism poate fi implementat diferit în diferite sisteme de fișiere. obisnuiam blktrace pentru a afla ce operațiuni pe disc sunt utilizate în sistemele de fișiere ext4 și XFS. Ambele lansează comenzile obișnuite de scriere pe disc atât pentru conținutul fișierelor, cât și pentru jurnalul sistemului de fișiere, șterg memoria cache și ies prin efectuarea unei scriere FUA (Force Unit Access, scrierea datelor direct pe disc, ocolirea memoriei cache) de scriere în jurnal. Probabil că fac exact asta pentru a confirma faptul tranzacției. Pe unitățile care nu acceptă FUA, acest lucru provoacă două spălări de cache. Experimentele mele au arătat asta fdatasync() putin mai repede fsync(). Utilitate blktrace indică faptul că fdatasync() de obicei scrie mai puține date pe disc (în ext4 fsync() scrie 20 KiB, și fdatasync() - 16 KiB). De asemenea, am aflat că XFS este puțin mai rapid decât ext4. Și aici cu ajutorul blktrace a putut afla că fdatasync() elimină mai puține date pe disc (4 KiB în XFS).

Situații ambigue când utilizați fsync()

Mă pot gândi la trei situații ambigue referitoare la fsync()pe care le-am întâlnit în practică.

Primul incident de acest gen a avut loc în 2008. La acel moment, interfața Firefox 3 „îngheța” dacă un număr mare de fișiere erau scrise pe disc. Problema a fost că implementarea interfeței a folosit o bază de date SQLite pentru a stoca informații despre starea acesteia. După fiecare modificare care a avut loc în interfață, funcția a fost apelată fsync(), care a oferit garanții bune de stocare stabilă a datelor. În sistemul de fișiere ext3 utilizat atunci, funcția fsync() a șters pe disc toate paginile „murdare” din sistem și nu doar cele care au fost legate de fișierul corespunzător. Acest lucru însemna că făcând clic pe un buton în Firefox ar putea duce la scrierea de megaocteți de date pe un disc magnetic, ceea ce ar putea dura multe secunde. Soluția problemei, din câte am înțeles ea material, a fost de a muta munca cu baza de date la sarcini de fundal asincrone. Aceasta înseamnă că Firefox obișnuia să implementeze cerințe de persistență de stocare mai stricte decât era cu adevărat necesar, iar caracteristicile sistemului de fișiere ext3 nu făceau decât să agraveze această problemă.

A doua problemă a apărut în 2009. Apoi, după o prăbușire a sistemului, utilizatorii noului sistem de fișiere ext4 au descoperit că multe fișiere nou create aveau lungime zero, dar acest lucru nu s-a întâmplat cu sistemul de fișiere ext3 mai vechi. În paragraful anterior, am vorbit despre cum ext3 a aruncat prea multe date pe disc, ceea ce a încetinit foarte mult lucrurile. fsync(). Pentru a îmbunătăți situația, ext4 șterge numai acele pagini „murdare” care sunt relevante pentru un anumit fișier. Și datele altor fișiere rămân în memorie mult mai mult timp decât cu ext3. Acest lucru a fost făcut pentru a îmbunătăți performanța (în mod implicit, datele rămân în această stare timp de 30 de secunde, puteți configura acest lucru folosind dirty_expire_centisecs; aici puteți găsi mai multe informații despre aceasta). Aceasta înseamnă că o cantitate mare de date poate fi pierdută iremediabil după un accident. Soluția la această problemă este utilizarea fsync() în aplicațiile care trebuie să asigure stocarea stabilă a datelor și să le protejeze cât mai mult posibil de consecințele defecțiunilor. Funcţie fsync() funcționează mult mai bine cu ext4 decât cu ext3. Dezavantajul acestei abordări este că utilizarea ei, ca și până acum, încetinește unele operațiuni, precum instalarea de programe. Vezi detalii despre asta aici и aici.

A treia problemă referitoare la fsync(), a apărut în 2018. Apoi, în cadrul proiectului PostgreSQL, s-a aflat că dacă funcția fsync() întâmpină o eroare, marchează paginile „murdare” ca „curate”. Ca urmare, următoarele apeluri fsync() nu faci nimic cu astfel de pagini. Din acest motiv, paginile modificate sunt stocate în memorie și nu sunt niciodată scrise pe disc. Acesta este un adevărat dezastru, deoarece aplicația va crede că unele date sunt scrise pe disc, dar de fapt nu va fi. Astfel de eșecuri fsync() sunt rare, aplicarea în astfel de situații nu poate face aproape nimic pentru a combate problema. În aceste zile, când se întâmplă acest lucru, PostgreSQL și alte aplicații se blochează. Aici, în articolul „Pot aplicațiile să se recupereze din eșecurile fsync?”, această problemă este explorată în detaliu. În prezent, cea mai bună soluție la această problemă este utilizarea Direct I/O cu steag O_SYNC sau cu un steag O_DSYNC. Cu această abordare, sistemul va raporta erorile care pot apărea la efectuarea unor operațiuni specifice de scriere a datelor, dar această abordare necesită ca aplicația să gestioneze ea însăși bufferele. Citiți mai multe despre asta aici и aici.

Deschiderea fișierelor folosind steagurile O_SYNC și O_DSYNC

Să revenim la discuția despre mecanismele Linux care asigură stocarea persistentă a datelor. Și anume, vorbim despre utilizarea steagului O_SYNC sau steag O_DSYNC la deschiderea fișierelor folosind apelul de sistem deschis(). Cu această abordare, fiecare operație de scriere a datelor este efectuată ca după fiecare comandă write() sistemului i se dau, respectiv, comenzi fsync() и fdatasync(). În Specificațiile POSIX aceasta se numește „Finalizarea integrității fișierului I/O sincronizat” și „Finalizarea integrității datelor”. Principalul avantaj al acestei abordări este că trebuie executat doar un apel de sistem pentru a asigura integritatea datelor, și nu două (de exemplu - write() и fdatasync()). Principalul dezavantaj al acestei abordări este că toate operațiunile de scriere folosind descriptorul de fișier corespunzător vor fi sincronizate, ceea ce poate limita capacitatea de a structura codul aplicației.

Folosind Direct I/O cu flag-ul O_DIRECT

Apel de sistem open() susține steagul O_DIRECT, care este conceput pentru a ocoli memoria cache a sistemului de operare, pentru a efectua operațiuni I/O, interacționând direct cu discul. Acest lucru, în multe cazuri, înseamnă că comenzile de scriere emise de program vor fi traduse direct în comenzi care vizează lucrul cu discul. Dar, în general, acest mecanism nu este un înlocuitor pentru funcții fsync() sau fdatasync(). Faptul este că discul în sine poate întârziere sau cache comenzi adecvate pentru scrierea datelor. Și, și mai rău, în unele cazuri speciale, operațiunile I/O efectuate la utilizarea steagului O_DIRECT, difuzat în operațiunile tampon tradiționale. Cel mai simplu mod de a rezolva această problemă este să folosiți steag pentru a deschide fișiere O_DSYNC, ceea ce va însemna că fiecare operație de scriere va fi urmată de un apel fdatasync().

S-a dovedit că sistemul de fișiere XFS a adăugat recent o „cale rapidă” pentru O_DIRECT|O_DSYNC-inregistrari de date. Dacă blocul este suprascris folosind O_DIRECT|O_DSYNC, apoi XFS, în loc să golească memoria cache, va executa comanda de scriere FUA dacă dispozitivul o acceptă. Am verificat acest lucru folosind utilitarul blktrace pe un sistem Linux 5.4/Ubuntu 20.04. Această abordare ar trebui să fie mai eficientă, deoarece scrie cantitatea minimă de date pe disc și utilizează o operație, nu două (scrieți și goliți memoria cache). Am gasit un link catre plasture kernel 2018 care implementează acest mecanism. Există unele discuții despre aplicarea acestei optimizări la alte sisteme de fișiere, dar din câte știu, XFS este singurul sistem de fișiere care o acceptă până acum.

funcția sync_file_range().

Linux are un apel de sistem sync_file_range(), care vă permite să ștergeți doar o parte a fișierului pe disc, nu întregul fișier. Acest apel inițiază o spălare asincronă și nu așteaptă să se finalizeze. Dar în referirea la sync_file_range() se spune că această comandă este „foarte periculoasă”. Nu se recomandă utilizarea acestuia. Caracteristici și pericole sync_file_range() foarte bine descris în acest material. În special, acest apel pare să folosească RocksDB pentru a controla când nucleul șterge datele „murdare” pe disc. Dar, în același timp, acolo, pentru a asigura stocarea stabilă a datelor, este și el folosit fdatasync(). În cod RocksDB are câteva comentarii interesante pe acest subiect. De exemplu, seamănă cu apelul sync_file_range() atunci când utilizați ZFS nu șterge datele pe disc. Experiența îmi spune că codul folosit rar poate conține erori. Prin urmare, aș sfătui să nu folosiți acest apel de sistem decât dacă este absolut necesar.

Apeluri de sistem pentru a asigura persistența datelor

Am ajuns la concluzia că există trei abordări care pot fi utilizate pentru a efectua operațiuni I/O persistente. Toate necesită un apel de funcție fsync() pentru directorul în care a fost creat fișierul. Acestea sunt abordările:

  1. Apel de funcție fdatasync() sau fsync() după funcție write() (mai bine de folosit fdatasync()).
  2. Lucrul cu un descriptor de fișier deschis cu un steag O_DSYNC sau O_SYNC (mai bine - cu un steag O_DSYNC).
  3. Utilizarea comenzilor pwritev2() cu steag RWF_DSYNC sau RWF_SYNC (de preferință cu un steag RWF_DSYNC).

Note de performanță

Nu am măsurat cu atenție performanța diferitelor mecanisme pe care le-am investigat. Diferențele pe care le-am observat în viteza muncii lor sunt foarte mici. Asta înseamnă că pot greși și că în alte condiții același lucru poate arăta rezultate diferite. În primul rând, voi vorbi despre ceea ce afectează mai mult performanța și apoi despre ceea ce afectează mai puțin performanța.

  1. Suprascrierea datelor fișierului este mai rapidă decât adăugarea datelor la un fișier (câștigul de performanță poate fi de 2-100%). Atașarea datelor la un fișier necesită modificări suplimentare ale metadatelor fișierului, chiar și după apelul de sistem fallocate(), dar amploarea acestui efect poate varia. Recomand, pentru cea mai bună performanță, să sunați fallocate() pentru a prealoca spațiul necesar. Apoi, acest spațiu trebuie să fie umplut explicit cu zerouri și numit fsync(). Acest lucru va face ca blocurile corespunzătoare din sistemul de fișiere să fie marcate ca „alocate” în loc de „nealocate”. Acest lucru oferă o îmbunătățire mică (aproximativ 2%) a performanței. De asemenea, unele discuri pot avea o operațiune de acces la primul bloc mai lentă decât altele. Aceasta înseamnă că umplerea spațiului cu zerouri poate duce la o îmbunătățire semnificativă (aproximativ 100%) a performanței. În special, acest lucru se poate întâmpla cu discuri. AWS EBS (acestea sunt date neoficiale, nu le-am putut confirma). Același lucru este valabil și pentru depozitare. Disc persistent GCP (și aceasta este deja o informație oficială, confirmată de teste). Alți experți au făcut același lucru observarelegate de diferite discuri.
  2. Cu cât sunt mai puține apeluri de sistem, cu atât performanța este mai mare (câștigul poate fi de aproximativ 5%). Pare un apel open() cu steag O_DSYNC sau suna pwritev2() cu steag RWF_SYNC apel mai rapid fdatasync(). Bănuiesc că ideea aici este că, cu această abordare, faptul că trebuie efectuate mai puține apeluri de sistem pentru a rezolva aceeași sarcină (un apel în loc de două) joacă un rol. Dar diferența de performanță este foarte mică, așa că o puteți ignora cu ușurință și puteți utiliza ceva în aplicație care nu duce la complicarea logicii acesteia.

Dacă sunteți interesat de subiectul stocării durabile a datelor, iată câteva materiale utile:

  • Metode de acces I/O — o privire de ansamblu asupra elementelor de bază ale mecanismelor de intrare/ieșire.
  • Asigurarea că datele ajung pe disc - o poveste despre ce se întâmplă cu datele pe drumul de la aplicație la disc.
  • Când ar trebui să fsync directorul care conține - răspunsul la întrebarea când să aplici fsync() pentru directoare. Pe scurt, se dovedește că trebuie să faceți acest lucru atunci când creați un fișier nou, iar motivul acestei recomandări este că în Linux pot exista multe referințe la același fișier.
  • SQL Server pe Linux: FUA Internals - aici este o descriere a modului în care stocarea persistentă a datelor este implementată în SQL Server pe platforma Linux. Există câteva comparații interesante între apelurile de sistem Windows și Linux aici. Sunt aproape sigur că datorită acestui material am aflat despre optimizarea FUA a XFS.

Ați pierdut vreodată date despre care credeați că sunt stocate în siguranță pe disc?

Stocare durabilă a datelor și API-uri pentru fișiere Linux

Stocare durabilă a datelor și API-uri pentru fișiere Linux

Sursa: www.habr.com