Duurzame gegevensopslag en Linux-bestands-API's

Ik, die onderzoek deed naar de stabiliteit van gegevensopslag in cloudsystemen, besloot mezelf te testen om er zeker van te zijn dat ik de basiszaken begrijp. I begonnen met het lezen van de NVMe-specificatie om te begrijpen welke garanties met betrekking tot datapersistentie (dat wil zeggen, garanties dat gegevens beschikbaar zullen zijn na een systeemfout) geven ons NMVe-schijven. Ik heb de volgende hoofdconclusies getrokken: je moet de gegevens als beschadigd beschouwen vanaf het moment dat het dataschrijfcommando wordt gegeven, tot het moment dat ze naar het opslagmedium worden geschreven. In de meeste programma's worden systeemaanroepen echter vrij veilig gebruikt om gegevens te schrijven.

In dit bericht onderzoek ik de persistente opslagmechanismen die worden geboden door de Linux-bestands-API's. Het lijkt erop dat alles hier eenvoudig moet zijn: het programma roept de opdracht aan write()en nadat de bewerking van deze opdracht is voltooid, worden de gegevens veilig op schijf opgeslagen. Maar write() kopieert alleen applicatiegegevens naar de kernelcache in het RAM. Om het systeem te dwingen gegevens naar schijf te schrijven, moeten enkele aanvullende mechanismen worden gebruikt.

Duurzame gegevensopslag en Linux-bestands-API's

Over het algemeen bestaat dit materiaal uit een reeks aantekeningen die verband houden met wat ik heb geleerd over een onderwerp dat voor mij interessant is. Als we het heel kort hebben over het belangrijkste, blijkt dat je, om duurzame gegevensopslag te organiseren, het commando moet gebruiken fdatasync() of open bestanden met vlag O_DSYNC. Als u meer wilt weten over wat er met gegevens gebeurt op de weg van code naar schijf, kijk dan eens naar deze artikel.

Kenmerken van het gebruik van de write()-functie

Systeemoproep write() gedefinieerd in de standaard IEEE POSIX als een poging om gegevens naar een bestandsdescriptor te schrijven. Na succesvolle afronding van de werkzaamheden write() gegevensleesbewerkingen moeten exact de bytes retourneren die eerder zijn geschreven, zelfs als de gegevens worden benaderd vanuit andere processen of threads (hier overeenkomstige sectie van de POSIX-standaard). Hier, in de sectie over de interactie van threads met normale bestandsbewerkingen, staat een opmerking die zegt dat als twee threads elk deze functies aanroepen, elke aanroep ofwel alle aangegeven gevolgen moet zien waartoe de uitvoering van de andere aanroep leidt, of zie helemaal geen gevolgen. Dit leidt tot de conclusie dat alle I/O-bewerkingen van bestanden een vergrendeling moeten hebben op de bron waaraan wordt gewerkt.

Betekent dit dat de operatie write() is atomair? Technisch gezien wel. Gegevensleesbewerkingen moeten alles of niets retourneren van waarmee is geschreven write(). Maar de operatie write(), in overeenstemming met de standaard, hoeft niet te eindigen door alles op te schrijven wat haar werd gevraagd op te schrijven. Het is toegestaan ​​om slechts een deel van de gegevens te schrijven. We kunnen bijvoorbeeld twee stromen hebben die elk 1024 bytes toevoegen aan een bestand dat wordt beschreven door dezelfde bestandsdescriptor. Vanuit het oogpunt van de standaard zal het resultaat acceptabel zijn als elk van de schrijfbewerkingen slechts één byte aan het bestand kan toevoegen. Deze bewerkingen blijven atomair, maar nadat ze zijn voltooid, zullen de gegevens die ze naar het bestand schrijven door elkaar worden gehaald. Hier zeer interessante discussie over dit onderwerp op Stack Overflow.

fsync() en fdatasync() functies

De eenvoudigste manier om gegevens naar schijf te spoelen is door de functie aan te roepen fsynchronisatie(). Deze functie vraagt ​​het besturingssysteem om alle gewijzigde blokken van de cache naar schijf te verplaatsen. Dit omvat alle metagegevens van het bestand (toegangstijd, tijd voor bestandswijziging, enzovoort). Ik geloof dat deze metadata zelden nodig is, dus als je weet dat het niet belangrijk voor je is, kun je de functie gebruiken fdatasync(). In helpen op fdatasync() er staat dat tijdens de werking van deze functie een dergelijke hoeveelheid metagegevens op schijf wordt opgeslagen, wat "noodzakelijk is voor de correcte uitvoering van de volgende gegevensleesbewerkingen." En dit is precies waar de meeste applicaties om geven.

Een probleem dat zich hierbij kan voordoen, is dat deze mechanismen niet garanderen dat het bestand na een mogelijke fout kan worden gevonden. In het bijzonder moet men bellen als er een nieuw bestand wordt aangemaakt fsync() voor de directory waarin het zich bevindt. Anders kan het na een crash blijken dat dit bestand niet bestaat. De reden hiervoor is dat onder UNIX, door het gebruik van harde links, een bestand in meerdere mappen kan bestaan. Daarom tijdens het bellen fsync() er is geen manier voor een bestand om te weten welke mapgegevens ook naar schijf moeten worden gespoeld (hier Hierover kunt u meer lezen). Het lijkt erop dat het ext4-bestandssysteem daartoe in staat is automatisch van toepassing zijn fsync() naar mappen die de overeenkomstige bestanden bevatten, maar dit is mogelijk niet het geval bij andere bestandssystemen.

Dit mechanisme kan in verschillende bestandssystemen op verschillende manieren worden geïmplementeerd. ik gebruikte blauwtrace voor meer informatie over welke schijfbewerkingen worden gebruikt in ext4- en XFS-bestandssystemen. Beiden geven de gebruikelijke schrijfopdrachten naar schijf voor zowel de inhoud van de bestanden als het bestandssysteemjournaal, spoelen de cache leeg en sluiten af ​​door een FUA-schrijfopdracht (Force Unit Access, data direct naar schijf schrijven, omzeilen van de cache) naar het journaal uit te voeren. Waarschijnlijk doen ze dat om het feit van de transactie te bevestigen. Op schijven die FUA niet ondersteunen, veroorzaakt dit twee cache-opruimingen. Mijn experimenten hebben dat aangetoond fdatasync() een beetje sneller fsync(). Nutsvoorziening blktrace geeft aan dat fdatasync() schrijft gewoonlijk minder gegevens naar schijf (in ext4 fsync() schrijft 20 KiB, en fdatasync() - 16 KiB). Ook kwam ik erachter dat XFS iets sneller is dan ext4. En hier met de hulp blktrace heb dat kunnen achterhalen fdatasync() spoelt minder gegevens naar schijf (4 KiB in XFS).

Dubbelzinnige situaties bij het gebruik van fsync()

Ik kan drie dubbelzinnige situaties bedenken fsync()die ik in de praktijk ben tegengekomen.

Het eerste dergelijke incident vond plaats in 2008. Op dat moment “bevroor” de interface van Firefox 3 als een groot aantal bestanden naar schijf werd geschreven. Het probleem was dat de implementatie van de interface een SQLite-database gebruikte om informatie over de status ervan op te slaan. Na elke wijziging die in de interface plaatsvond, werd de functie aangeroepen fsync(), wat goede garanties gaf voor een stabiele gegevensopslag. In het toenmalige ext3-bestandssysteem wordt de functie fsync() alle "vuile" pagina's in het systeem op schijf gezet, en niet alleen de pagina's die verband hielden met het overeenkomstige bestand. Dit betekende dat het klikken op een knop in Firefox ertoe kon leiden dat megabytes aan gegevens naar een magnetische schijf werden geschreven, wat vele seconden kon duren. De oplossing voor het probleem, voor zover ik het begrepen heb het materiaal, was om het werk met de database te verplaatsen naar asynchrone achtergrondtaken. Dit betekent dat Firefox strengere vereisten voor opslagpersistentie implementeerde dan werkelijk nodig was, en de functies van het ext3-bestandssysteem hebben dit probleem alleen maar verergerd.

Het tweede probleem deed zich voor in 2009. Vervolgens ontdekten gebruikers van het nieuwe ext4-bestandssysteem na een systeemcrash dat veel nieuw gemaakte bestanden een lengte van nul hadden, maar dit gebeurde niet met het oudere ext3-bestandssysteem. In de vorige paragraaf had ik het over hoe ext3 te veel gegevens op de schijf dumpte, wat de zaken enorm vertraagde. fsync(). Om de situatie te verbeteren, verwijdert ext4 alleen die "vuile" pagina's die relevant zijn voor een bepaald bestand. En de gegevens van andere bestanden blijven veel langer in het geheugen dan bij ext3. Dit is gedaan om de prestaties te verbeteren (standaard blijven de gegevens 30 seconden in deze staat, u kunt dit configureren met dirty_expire_centisecs; hier meer informatie hierover vindt u). Dit betekent dat een grote hoeveelheid gegevens onherstelbaar verloren kan gaan na een crash. De oplossing voor dit probleem is gebruik fsync() in applicaties die stabiele gegevensopslag moeten bieden en deze zoveel mogelijk moeten beschermen tegen de gevolgen van storingen. Functie fsync() werkt veel efficiënter met ext4 dan met ext3. Het nadeel van deze aanpak is dat het gebruik ervan, net als voorheen, sommige handelingen vertraagt, zoals het installeren van programma's. Zie details hierover hier и hier.

Het derde probleem met betrekking tot fsync(), ontstaan ​​in 2018. Vervolgens werd in het kader van het PostgreSQL-project ontdekt dat als de functie fsync() een fout tegenkomt, markeert het "vuile" pagina's als "schoon". Naar aanleiding hiervan de volgende oproepen fsync() doe niets met zulke pagina's. Hierdoor worden gewijzigde pagina's in het geheugen opgeslagen en nooit naar schijf geschreven. Dit is een echte ramp, omdat de applicatie denkt dat sommige gegevens naar schijf worden geschreven, maar dat is in werkelijkheid niet het geval. Dergelijke mislukkingen fsync() zeldzaam zijn, kan de toepassing in dergelijke situaties vrijwel niets doen om het probleem te bestrijden. Wanneer dit tegenwoordig gebeurt, crashen PostgreSQL en andere applicaties. HierIn het artikel "Can Applications Recover from fsync Failures?" wordt dit probleem in detail onderzocht. Momenteel is de beste oplossing voor dit probleem het gebruik van Direct I/O met de vlag O_SYNC of met een vlag O_DSYNC. Met deze aanpak rapporteert het systeem fouten die kunnen optreden bij het uitvoeren van specifieke gegevensschrijfbewerkingen, maar deze aanpak vereist dat de applicatie de buffers zelf beheert. Lees er meer over hier и hier.

Bestanden openen met de vlaggen O_SYNC en O_DSYNC

Laten we terugkeren naar de discussie over de Linux-mechanismen die zorgen voor persistente gegevensopslag. We hebben het namelijk over het gebruik van de vlag O_SYNC of vlag O_DSYNC bij het openen van bestanden met behulp van een systeemoproep Open(). Met deze aanpak wordt elke dataschrijfbewerking uitgevoerd alsof na elke opdracht write() het systeem krijgt respectievelijk opdrachten fsync() и fdatasync(). In POSIX-specificaties dit wordt "Synchronized I/O File Integrity Completion" en "Data Integrity Completion" genoemd. Het belangrijkste voordeel van deze aanpak is dat er slechts één systeemoproep hoeft te worden uitgevoerd om de gegevensintegriteit te garanderen, en niet twee (bijvoorbeeld - write() и fdatasync()). Het belangrijkste nadeel van deze aanpak is dat alle schrijfbewerkingen die de corresponderende bestandsdescriptor gebruiken, worden gesynchroniseerd, wat de mogelijkheid om de applicatiecode te structureren kan beperken.

Directe I/O gebruiken met de vlag O_DIRECT

Systeemoproep open() ondersteunt de vlag O_DIRECT, dat is ontworpen om de cache van het besturingssysteem te omzeilen, I / O-bewerkingen uit te voeren en rechtstreeks met de schijf te communiceren. Dit betekent in veel gevallen dat de schrijfopdrachten van het programma direct worden vertaald in opdrachten die gericht zijn op het werken met de schijf. Maar over het algemeen is dit mechanisme geen vervanging voor de functies fsync() of fdatasync(). Het feit is dat de schijf zelf dat kan vertraging of cache geschikte opdrachten voor het schrijven van gegevens. En erger nog: in sommige speciale gevallen worden I/O-bewerkingen uitgevoerd bij gebruik van de vlag O_DIRECT, uitzending in traditionele gebufferde operaties. De eenvoudigste manier om dit probleem op te lossen is door de vlag te gebruiken om bestanden te openen O_DSYNC, wat betekent dat elke schrijfbewerking wordt gevolgd door een aanroep fdatasync().

Het bleek dat het XFS-bestandssysteem onlangs een "snel pad" had toegevoegd voor O_DIRECT|O_DSYNC-gegevensrecords. Als het blok wordt overschreven met O_DIRECT|O_DSYNC, dan zal XFS, in plaats van de cache leeg te maken, de FUA-schrijfopdracht uitvoeren als het apparaat dit ondersteunt. Ik heb dit geverifieerd met behulp van het hulpprogramma blktrace op een Linux 5.4/Ubuntu 20.04-systeem. Deze aanpak zou efficiënter moeten zijn, omdat de minimale hoeveelheid gegevens naar de schijf wordt geschreven en één bewerking wordt gebruikt, en niet twee (de cache schrijven en leegmaken). Ik heb een link gevonden naar lapje 2018-kernel die dit mechanisme implementeert. Er is enige discussie over het toepassen van deze optimalisatie op andere bestandssystemen, maar voor zover ik weet is XFS tot nu toe het enige bestandssysteem dat dit ondersteunt.

sync_file_range() functie

Linux heeft een systeemaanroep sync_file_range(), waarmee u slechts een deel van het bestand naar schijf kunt wissen, en niet het hele bestand. Deze oproep initieert een asynchrone spoeling en wacht niet tot deze is voltooid. Maar in de verwijzing naar sync_file_range() het team zou "zeer gevaarlijk" zijn. Het wordt niet aanbevolen om het te gebruiken. Kenmerken en gevaren sync_file_range() heel goed beschreven in deze materiaal. In het bijzonder lijkt deze aanroep RocksDB te gebruiken om te bepalen wanneer de kernel "vuile" gegevens naar de schijf spoelt. Maar tegelijkertijd wordt het daar ook gebruikt om een ​​stabiele gegevensopslag te garanderen fdatasync(). In code RocksDB heeft een aantal interessante opmerkingen over dit onderwerp. Het lijkt er bijvoorbeeld op dat de oproep sync_file_range() Wanneer u ZFS gebruikt, worden gegevens niet naar schijf gewist. De ervaring leert mij dat zelden gebruikte code bugs kan bevatten. Daarom zou ik het gebruik van deze systeemoproep afraden, tenzij dit absoluut noodzakelijk is.

Systeemaanroepen om de persistentie van gegevens te helpen garanderen

Ik ben tot de conclusie gekomen dat er drie benaderingen zijn die kunnen worden gebruikt om persistente I/O-bewerkingen uit te voeren. Ze vereisen allemaal een functieaanroep fsync() voor de map waarin het bestand is gemaakt. Dit zijn de benaderingen:

  1. Functie oproep fdatasync() of fsync() na functie write() (beter te gebruiken fdatasync()).
  2. Werken met een bestandsdescriptor geopend met een vlag O_DSYNC of O_SYNC (beter - met een vlag O_DSYNC).
  3. Opdracht gebruik pwritev2() met vlag RWF_DSYNC of RWF_SYNC (bij voorkeur met een vlag RWF_DSYNC).

Prestatienotities

Ik heb de prestaties van de verschillende mechanismen die ik onderzocht niet zorgvuldig gemeten. De verschillen die ik merkte in de snelheid van hun werk zijn erg klein. Dit betekent dat ik het mis kan hebben, en dat hetzelfde onder andere omstandigheden andere resultaten kan opleveren. Eerst zal ik het hebben over wat de prestaties meer beïnvloedt, en vervolgens over wat de prestaties minder beïnvloedt.

  1. Het overschrijven van bestandsgegevens gaat sneller dan het toevoegen van gegevens aan een bestand (de prestatiewinst kan 2-100%) bedragen. Het bijvoegen van gegevens aan een bestand vereist aanvullende wijzigingen in de metagegevens van het bestand, zelfs na de systeemaanroep fallocate(), maar de omvang van dit effect kan variëren. Voor de beste prestaties raad ik aan om te bellen fallocate() om de benodigde ruimte vooraf toe te wijzen. Vervolgens moet deze ruimte expliciet met nullen worden gevuld en aangeroepen fsync(). Dit zorgt ervoor dat de corresponderende blokken in het bestandssysteem worden gemarkeerd als "toegewezen" in plaats van "niet-toegewezen". Dit geeft een kleine (ongeveer 2%) prestatieverbetering. Bovendien kunnen sommige schijven een langzamere eerste bloktoegangsbewerking hebben dan andere. Dit betekent dat het vullen van de ruimte met nullen kan leiden tot een aanzienlijke prestatieverbetering (ongeveer 100%). Dit kan vooral gebeuren bij schijven. AWS EBS (dit zijn niet-officiële gegevens, ik kon ze niet bevestigen). Hetzelfde geldt voor opslag. GCP persistente schijf (en dit is al officiële informatie, bevestigd door tests). Andere deskundigen hebben hetzelfde gedaan waarnemingengerelateerd aan verschillende schijven.
  2. Hoe minder systeemoproepen, hoe hoger de prestaties (de winst kan ongeveer 5%) zijn. Het lijkt op een oproep open() met vlag O_DSYNC of bel pwritev2() met vlag RWF_SYNC sneller bellen fdatasync(). Ik vermoed dat het punt hier is dat bij deze aanpak het feit dat er minder systeemaanroepen moeten worden uitgevoerd om dezelfde taak op te lossen (één oproep in plaats van twee) een rol speelt. Maar het prestatieverschil is erg klein, dus je kunt het gemakkelijk negeren en iets in de applicatie gebruiken dat niet tot de complicatie van de logica ervan leidt.

Als u geïnteresseerd bent in het onderwerp duurzame gegevensopslag, vindt u hier enkele nuttige materialen:

  • I/O-toegangsmethoden — een overzicht van de basisprincipes van input-/outputmechanismen.
  • Ervoor zorgen dat gegevens de schijf bereiken - een verhaal over wat er met de gegevens gebeurt op weg van de applicatie naar de schijf.
  • Wanneer moet u de map die deze bevat fsynchroniseren - het antwoord op de vraag wanneer te solliciteren fsync() voor mappen. Kortom, het blijkt dat je dit moet doen wanneer je een nieuw bestand maakt, en de reden voor deze aanbeveling is dat er in Linux veel verwijzingen naar hetzelfde bestand kunnen zijn.
  • SQL Server op Linux: FUA-interne onderdelen - hier is een beschrijving van hoe persistente gegevensopslag wordt geïmplementeerd in SQL Server op het Linux-platform. Er zijn hier enkele interessante vergelijkingen tussen Windows- en Linux-systeemoproepen. Ik ben er bijna zeker van dat ik dankzij dit materiaal heb geleerd over de FUA-optimalisatie van XFS.

Bent u ooit gegevens kwijtgeraakt waarvan u dacht dat ze veilig op schijf waren opgeslagen?

Duurzame gegevensopslag en Linux-bestands-API's

Duurzame gegevensopslag en Linux-bestands-API's

Bron: www.habr.com