Hållbar datalagring och Linux File API

Jag, som undersökte stabiliteten hos datalagring i molnsystem, bestämde mig för att testa mig själv för att vara säker på att jag förstår de grundläggande sakerna. jag började med att läsa NVMe-specifikationen för att förstå vilka garantier angående databeständighet (det vill säga garanterar att data kommer att finnas tillgängliga efter ett systemfel) ge oss NMVe-diskar. Jag drog följande huvudslutsatser: du måste överväga att data är skadade från det ögonblick då dataskrivkommandot ges och tills de skrivs till lagringsmediet. Men i de flesta program används systemanrop ganska säkert för att skriva data.

I den här artikeln utforskar jag uthållighetsmekanismerna som tillhandahålls av Linux-fil-API:erna. Det verkar som att allt borde vara enkelt här: programmet anropar kommandot write(), och efter att operationen av detta kommando har slutförts kommer data att lagras säkert på disken. Men write() kopierar endast programdata till kärncachen som finns i RAM. För att tvinga systemet att skriva data till disk måste några ytterligare mekanismer användas.

Hållbar datalagring och Linux File API

I allmänhet är detta material en uppsättning anteckningar som relaterar till vad jag har lärt mig om ett ämne som intresserar mig. Om vi ​​pratar mycket kort om det viktigaste, visar det sig att för att organisera hållbar datalagring måste du använda kommandot fdatasync() eller öppna filer med flagga O_DSYNC. Om du är intresserad av att lära dig mer om vad som händer med data på vägen från kod till disk, ta en titt på detta artikel.

Funktioner för att använda funktionen write().

Systemanrop write() definieras i standarden IEEE POSIX som ett försök att skriva data till en filbeskrivning. Efter framgångsrikt avslutat arbete write() dataläsoperationer måste returnera exakt de byte som tidigare skrevs, även om data nås från andra processer eller trådar (här motsvarande avsnitt i POSIX-standarden). Här, i avsnittet om växelverkan mellan trådar med normala filoperationer, finns det en notering som säger att om två trådar vardera anropar dessa funktioner, så måste varje anrop antingen se alla indikerade konsekvenser som exekveringen av det andra anropet leder till, eller ser inte alls inga konsekvenser. Detta leder till slutsatsen att alla fil-I/O-operationer måste låsa resursen som arbetas med.

Betyder detta att operationen write() är atomär? Ur teknisk synvinkel, ja. Dataläsoperationer måste returnera antingen allt eller inget av det som skrevs med write(). Men operationen write(), i enlighet med standarden, behöver inte sluta, efter att ha skrivit ner allt som hon blev ombedd att skriva ner. Det är tillåtet att bara skriva en del av datan. Till exempel kan vi ha två strömmar som var och en lägger till 1024 byte till en fil som beskrivs av samma filbeskrivning. Ur standardens synvinkel kommer resultatet att vara acceptabelt när var och en av skrivoperationerna bara kan lägga till en byte till filen. Dessa operationer kommer att förbli atomära, men efter att de är klara kommer data de skriver till filen att blandas ihop. Här mycket intressant diskussion om detta ämne på Stack Overflow.

funktionerna fsync() och fdatasync().

Det enklaste sättet att spola data till disken är att anropa funktionen fsync(). Denna funktion ber operativsystemet att flytta alla modifierade block från cachen till disken. Detta inkluderar all metadata för filen (åtkomsttid, filändringstid och så vidare). Jag tror att denna metadata sällan behövs, så om du vet att den inte är viktig för dig kan du använda funktionen fdatasync(). I hjälpfdatasync() det står att under driften av denna funktion sparas en sådan mängd metadata på disken, vilket är "nödvändigt för korrekt exekvering av följande dataläsningsoperationer." Och det är precis vad de flesta applikationer bryr sig om.

Ett problem som kan uppstå här är att dessa mekanismer inte garanterar att filen kan hittas efter ett eventuellt fel. I synnerhet när en ny fil skapas bör man anropa fsync() för katalogen som innehåller den. Annars, efter en krasch, kan det visa sig att den här filen inte finns. Anledningen till detta är att under UNIX, på grund av användningen av hårda länkar, kan en fil finnas i flera kataloger. Därför när du ringer fsync() det finns inget sätt för en fil att veta vilken katalogdata som också ska spolas till disken (här du kan läsa mer om detta). Det ser ut som att ext4-filsystemet kan automatiskt ansöka fsync() till kataloger som innehåller motsvarande filer, men detta kanske inte är fallet med andra filsystem.

Denna mekanism kan implementeras på olika sätt i olika filsystem. jag använde blktrace för att lära dig om vilka diskoperationer som används i filsystemen ext4 och XFS. Båda utfärdar de vanliga skrivkommandona till disken för både innehållet i filerna och filsystemets journal, spola cachen och avsluta genom att utföra en FUA (Force Unit Access, skriva data direkt till disken, kringgå cachen) skrivning till journalen. De gör förmodligen just det för att bekräfta transaktionens faktum. På enheter som inte stöder FUA orsakar detta två cache-tömningar. Mina experiment har visat det fdatasync() lite snabbare fsync(). Verktyg blktrace indikerar att fdatasync() skriver vanligtvis mindre data till disken (i ext4 fsync() skriver 20 KiB, och fdatasync() - 16 KiB). Jag fick också reda på att XFS är något snabbare än ext4. Och här med hjälp blktrace kunde ta reda på det fdatasync() spolar mindre data till disken (4 KiB i XFS).

Tvetydiga situationer när du använder fsync()

Jag kan tänka mig tre tvetydiga situationer angående fsync()som jag har stött på i praktiken.

Den första händelsen av detta slag inträffade 2008. Vid den tiden "fryste" Firefox 3-gränssnittet om ett stort antal filer skrevs till disken. Problemet var att implementeringen av gränssnittet använde en SQLite-databas för att lagra information om dess tillstånd. Efter varje ändring som skedde i gränssnittet anropades funktionen fsync(), vilket gav goda garantier för stabil datalagring. I det då använda ext3-filsystemet är funktionen fsync() spolade till disken alla "smutsiga" sidor i systemet, och inte bara de som var relaterade till motsvarande fil. Detta innebar att ett klick på en knapp i Firefox kunde få megabyte data att skrivas till en magnetisk disk, vilket kunde ta många sekunder. Lösningen på problemet, så vitt jag förstått från av detta material, var att flytta arbetet med databasen till asynkrona bakgrundsuppgifter. Detta betyder att Firefox brukade implementera strängare krav på lagringsbeständighet än vad som egentligen var nödvändigt, och ext3-filsystemets funktioner förvärrade bara detta problem.

Det andra problemet inträffade 2009. Sedan, efter en systemkrasch, upptäckte användare av det nya ext4-filsystemet att många nyskapade filer hade noll längd, men detta hände inte med det äldre ext3-filsystemet. I föregående stycke pratade jag om hur ext3 dumpade för mycket data på disken, vilket saktade ner mycket. fsync(). För att förbättra situationen rensar ext4 endast de "smutsiga" sidor som är relevanta för en viss fil. Och data från andra filer finns kvar i minnet mycket längre tid än med ext3. Detta gjordes för att förbättra prestandan (som standard förblir data i detta tillstånd i 30 sekunder, du kan konfigurera detta med dirty_expire_centisecs; här du kan hitta mer information om detta). Detta innebär att en stor mängd data kan förloras oåterkalleligt efter en krasch. Lösningen på detta problem är att använda fsync() i applikationer som behöver tillhandahålla stabil datalagring och skydda dem så mycket som möjligt från konsekvenserna av fel. Fungera fsync() fungerar mycket mer effektivt med ext4 än med ext3. Nackdelen med detta tillvägagångssätt är att dess användning, som tidigare, saktar ner vissa operationer, som att installera program. Se detaljer om detta här и här.

Det tredje problemet ang fsync(), uppstod 2018. Sedan fick man inom ramen för PostgreSQL-projektet reda på att om funktionen fsync() stöter på ett fel, markerar den "smutsiga" sidor som "rena". Som ett resultat ringer följande fsync() gör ingenting med sådana sidor. På grund av detta lagras modifierade sidor i minnet och skrivs aldrig till disken. Detta är en riktig katastrof, eftersom applikationen kommer att tro att vissa data skrivs till disken, men det kommer faktiskt inte att vara det. Sådana misslyckanden fsync() är sällsynta, kan applikationen i sådana situationer göra nästan ingenting för att bekämpa problemet. Dessa dagar, när detta händer, kraschar PostgreSQL och andra applikationer. Här, i artikeln "Kan applikationer återställas från fsync-fel?", utforskas detta problem i detalj. För närvarande är den bästa lösningen på detta problem att använda Direct I/O med flaggan O_SYNC eller med en flagga O_DSYNC. Med detta tillvägagångssätt kommer systemet att rapportera fel som kan uppstå när specifika dataskrivoperationer utförs, men detta tillvägagångssätt kräver att applikationen hanterar buffertarna själv. Läs mer om det här и här.

Öppna filer med flaggorna O_SYNC och O_DSYNC

Låt oss återgå till diskussionen om Linux-mekanismerna som tillhandahåller beständig datalagring. Vi pratar nämligen om användningen av flaggan O_SYNC eller flagga O_DSYNC när du öppnar filer med systemanrop öppna(). Med detta tillvägagångssätt utförs varje dataskrivoperation som efter varje kommando write() systemet ges respektive kommandon fsync() и fdatasync(). I POSIX-specifikationer detta kallas "Synchronized I/O File Integrity Completion" och "Data Integrity Completion". Den största fördelen med detta tillvägagångssätt är att endast ett systemanrop behöver utföras för att säkerställa dataintegritet, och inte två (till exempel − write() и fdatasync()). Den största nackdelen med detta tillvägagångssätt är att alla skrivoperationer som använder motsvarande filbeskrivning kommer att synkroniseras, vilket kan begränsa möjligheten att strukturera applikationskoden.

Använder direkt I/O med flaggan O_DIRECT

Systemanrop open() stöder flaggan O_DIRECT, som är utformad för att kringgå operativsystemets cache, utföra I/O-operationer, interagera direkt med disken. Detta innebär i många fall att skrivkommandon som utfärdas av programmet kommer att direkt översättas till kommandon som syftar till att arbeta med disken. Men i allmänhet är denna mekanism inte en ersättning för funktionerna fsync() eller fdatasync(). Faktum är att skivan själv kan fördröjning eller cache lämpliga kommandon för att skriva data. Och ännu värre, i vissa speciella fall, I/O-operationerna som utförs när flaggan används O_DIRECT, utsända i traditionell buffrad verksamhet. Det enklaste sättet att lösa detta problem är att använda flaggan för att öppna filer O_DSYNC, vilket kommer att innebära att varje skrivoperation kommer att följas av ett anrop fdatasync().

Det visade sig att XFS-filsystemet nyligen hade lagt till en "snabb sökväg" för O_DIRECT|O_DSYNC-dataposter. Om blocket skrivs över med O_DIRECT|O_DSYNC, då kommer XFS, istället för att tömma cachen, att utföra FUA-skrivkommandot om enheten stöder det. Jag verifierade detta med hjälp av verktyget blktrace på ett Linux 5.4/Ubuntu 20.04-system. Detta tillvägagångssätt borde vara mer effektivt, eftersom det skriver den minsta mängden data till disken och använder en operation, inte två (skriv och spola cachen). Jag hittade en länk till plåster 2018 kärna som implementerar denna mekanism. Det finns en del diskussion om att tillämpa denna optimering på andra filsystem, men så vitt jag vet är XFS det enda filsystemet som stöder det hittills.

sync_file_range() funktion

Linux har ett systemanrop sync_file_range(), vilket gör att du bara kan spola en del av filen till disken, inte hela filen. Detta anrop initierar en asynkron spolning och väntar inte på att den ska slutföras. Men i hänvisningen till sync_file_range() detta kommando sägs vara "mycket farligt". Det rekommenderas inte att använda det. Funktioner och faror sync_file_range() mycket väl beskrivet i detta material. Speciellt verkar det här anropet använda RocksDB för att kontrollera när kärnan spolar "smutsig" data till disken. Men samtidigt där, för att säkerställa stabil datalagring, används den också fdatasync(). I koda RocksDB har några intressanta kommentarer om detta ämne. Det ser till exempel ut som samtalet sync_file_range() när du använder ZFS spolas inte data till disken. Erfarenheten säger mig att sällan använd kod kan innehålla buggar. Därför skulle jag avråda från att använda detta systemsamtal om det inte är absolut nödvändigt.

Systemanrop för att säkerställa databeständighet

Jag har kommit till slutsatsen att det finns tre metoder som kan användas för att utföra beständiga I/O-operationer. De kräver alla ett funktionsanrop fsync() för katalogen där filen skapades. Dessa är tillvägagångssätten:

  1. Anropa en funktion fdatasync() eller fsync() efter funktion write() (bättre att använda fdatasync()).
  2. Arbeta med en filbeskrivning öppnad med en flagga O_DSYNC eller O_SYNC (bättre - med en flagga O_DSYNC).
  3. Kommandoanvändning pwritev2() med flagga RWF_DSYNC eller RWF_SYNC (helst med flagga RWF_DSYNC).

Prestandaanteckningar

Jag mätte inte noggrant prestandan för de olika mekanismerna jag undersökte. Skillnaderna jag märkte i hastigheten på deras arbete är mycket små. Det betyder att jag kan ha fel, och att under andra förhållanden kan samma sak visa olika resultat. Först kommer jag att prata om vad som påverkar prestation mer, och sedan om vad som påverkar prestation mindre.

  1. Att skriva över fildata är snabbare än att lägga till data till en fil (prestandavinsten kan vara 2-100%). Att bifoga data till en fil kräver ytterligare ändringar av filens metadata, även efter systemanropet fallocate(), men storleken på denna effekt kan variera. Jag rekommenderar, för bästa prestanda, att ringa fallocate() för att tilldela det nödvändiga utrymmet. Då måste detta utrymme uttryckligen fyllas med nollor och anropas fsync(). Detta kommer att göra att motsvarande block i filsystemet markeras som "allokerade" istället för "oallokerade". Detta ger en liten (cirka 2%) prestandaförbättring. Vissa diskar kan också ha en långsammare första blockåtkomstoperation än andra. Detta innebär att fyllning av utrymmet med nollor kan leda till en betydande (cirka 100 %) prestandaförbättring. I synnerhet kan detta hända med diskar. AWS EBS (detta är inofficiella data, jag kunde inte bekräfta dem). Detsamma gäller förvaring. GCP Persistent Disk (och detta är redan officiell information, bekräftad av tester). Andra experter har gjort detsamma observationerrelaterade till olika diskar.
  2. Ju färre systemanrop, desto högre prestanda (vinsten kan vara cirka 5%). Det ser ut som ett samtal open() med flagga O_DSYNC eller ring pwritev2() med flagga RWF_SYNC snabbare samtal fdatasync(). Jag misstänker att poängen här är att med detta tillvägagångssätt spelar det faktum att färre systemanrop måste utföras för att lösa samma uppgift (ett samtal istället för två) en roll. Men prestandaskillnaden är mycket liten, så du kan enkelt ignorera den och använda något i applikationen som inte leder till komplikationen av dess logik.

Om du är intresserad av ämnet hållbar datalagring, här är några användbara material:

  • I/O-åtkomstmetoder — En översikt över grunderna för in-/utmatningsmekanismer.
  • Se till att data når disken - en berättelse om vad som händer med datan på vägen från applikationen till disken.
  • När ska du fsynkronisera den innehållande katalogen - svaret på frågan om när man ska ansöka fsync() för kataloger. I ett nötskal visar det sig att du behöver göra detta när du skapar en ny fil, och anledningen till denna rekommendation är att det i Linux kan finnas många referenser till samma fil.
  • SQL Server på Linux: FUA Internals - här är en beskrivning av hur beständig datalagring implementeras i SQL Server på Linux-plattformen. Det finns några intressanta jämförelser mellan Windows- och Linux-systemanrop här. Jag är nästan säker på att det var tack vare detta material som jag lärde mig om FUA-optimeringen av XFS.

Har du någonsin förlorat data som du trodde var säkert lagrad på disken?

Hållbar datalagring och Linux File API

Hållbar datalagring och Linux File API

Källa: will.com