Archiviazione dati durevole e API di file Linux

Durante la ricerca sulla sostenibilità dell'archiviazione dei dati nei sistemi cloud, ho deciso di mettermi alla prova per assicurarmi di aver compreso le cose di base. IO iniziato leggendo le specifiche NVMe per capire quali garanzie riguardo all'archiviazione sostenibile dei dati (ovvero garanzie che i dati saranno disponibili dopo un guasto del sistema) ci vengono fornite dai dischi NMVe. Sono giunto alle seguenti conclusioni principali: i dati devono essere considerati danneggiati dal momento in cui viene dato il comando di scrittura fino al momento in cui vengono scritti sul supporto di memorizzazione. Tuttavia, la maggior parte dei programmi utilizza abbastanza volentieri le chiamate di sistema per registrare i dati.

In questo post esploro i meccanismi di archiviazione persistente forniti dalle API dei file Linux. Sembra che qui tutto dovrebbe essere semplice: il programma chiama il comando write()e una volta completato questo comando, i dati verranno salvati in modo sicuro su disco. Ma write() copia solo i dati dell'applicazione nella cache del kernel situata nella RAM. Per forzare il sistema a scrivere i dati su disco, è necessario utilizzare alcuni meccanismi aggiuntivi.

Archiviazione dati durevole e API di file Linux

Nel complesso, questo materiale è una raccolta di appunti relativi a ciò che ho imparato su un argomento di mio interesse. Se parliamo molto brevemente della cosa più importante, si scopre che per organizzare l'archiviazione sostenibile dei dati è necessario utilizzare il comando fdatasync() o aprire file con il flag O_DSYNC. Se sei interessato a saperne di più su cosa succede ai dati nel loro percorso dal codice al disco, dai un'occhiata a questo articolo.

Caratteristiche dell'utilizzo della funzione write()

Chiamata di sistema write() definito nella norma IEEE POSIX come tentativo di scrivere dati in un descrittore di file. Dopo il completamento con successo write() Le operazioni di lettura dei dati devono restituire esattamente i byte scritti in precedenza, anche se si accede ai dati da altri processi o thread (qui sezione pertinente dello standard POSIX). Qui, nella sezione su come i thread interagiscono con le normali operazioni sui file, c'è una nota che dice che se due thread chiamano ciascuna queste funzioni, allora ciascuna chiamata deve vedere tutte le conseguenze designate dell'altra chiamata, o nessuna. conseguenze. Ciò porta alla conclusione che tutte le operazioni di I/O su file devono mantenere un blocco sulla risorsa su cui stanno operando.

Questo significa che l'operazione write() è atomico? Dal punto di vista tecnico sì. Le operazioni di lettura dei dati devono restituire tutto o niente di ciò che è stato scritto write(). Ma l'operazione write(), secondo lo standard, non deve necessariamente finire con l'annotare tutto ciò che gli è stato chiesto di annotare. Le è consentito scrivere solo una parte dei dati. Ad esempio, potremmo avere due thread ciascuno che aggiunge 1024 byte a un file descritto dallo stesso descrittore di file. Dal punto di vista dello standard, un risultato accettabile sarà quando ciascuna operazione di scrittura può aggiungere solo un byte al file. Queste operazioni rimarranno atomiche, ma una volta completate, i dati scritti nel file verranno confusi. Qui discussione molto interessante su questo argomento su Stack Overflow.

funzioni fsync() e fdatasync()

Il modo più semplice per trasferire i dati su disco è chiamare la funzione fsync(). Questa funzione chiede al sistema operativo di trasferire tutti i blocchi modificati dalla cache al disco. Ciò include tutti i metadati del file (ora di accesso, ora di modifica del file e così via). Credo che questi metadati siano necessari raramente, quindi se sai che non sono importanti per te, puoi utilizzare la funzione fdatasync(). In Aiuto su fdatasync() si dice che durante il funzionamento di questa funzione venga salvata su disco una quantità di metadati “necessaria per la corretta esecuzione delle successive operazioni di lettura dei dati”. E questo è esattamente ciò che interessa alla maggior parte delle applicazioni.

Un problema che può sorgere in questo caso è che questi meccanismi non garantiscono che il file sarà rilevabile dopo un possibile errore. In particolare, quando si crea un nuovo file, è necessario chiamare fsync() per la directory che lo contiene. Altrimenti, dopo un errore, potrebbe risultare che questo file non esiste. La ragione di ciò è che in UNIX, a causa dell'uso degli hard link, un file può esistere in più directory. Pertanto, quando si chiama fsync() non è possibile per un file sapere quali dati della directory dovrebbero essere scaricati sul disco (qui Puoi leggere di più a riguardo). Sembra che il file system ext4 sia in grado di farlo автоматически applicare fsync() alle directory contenenti i file corrispondenti, ma questo potrebbe non essere il caso con altri file system.

Questo meccanismo può essere implementato in modo diverso su file system diversi. ero solito traccia nera per conoscere quali operazioni del disco vengono utilizzate nei file system ext4 e XFS. Entrambi inviano regolari comandi di scrittura su disco sia per il contenuto del file che per il journal del file system, svuotano la cache ed escono eseguendo una scrittura FUA (Force Unit Access, scrittura dei dati direttamente sul disco, bypassando la cache) sul journal. Probabilmente lo fanno per confermare che la transazione è avvenuta. Sulle unità che non supportano FUA, ciò provoca due svuotamenti della cache. I miei esperimenti lo hanno dimostrato fdatasync() un po' più veloce fsync(). Utilità blktrace indica che fdatasync() di solito scrive meno dati su disco (in ext4 fsync() scrive 20 KiB e fdatasync() - 16 KiB). Inoltre, ho scoperto che XFS è leggermente più veloce di ext4. E qui con l'aiuto blktrace è riuscito a scoprirlo fdatasync() scarica meno dati sul disco (4 KiB in XFS).

Situazioni ambigue che si verificano quando si utilizza fsync()

Mi vengono in mente tre situazioni ambigue riguardo fsync()che ho riscontrato nella pratica.

Il primo caso del genere si è verificato nel 2008. Quindi l'interfaccia di Firefox 3 si bloccava se un numero elevato di file veniva scritto su disco. Il problema era che l'implementazione dell'interfaccia utilizzava un database SQLite per archiviare informazioni sul suo stato. Dopo ogni modifica avvenuta nell'interfaccia, veniva chiamata la funzione fsync(), che dava buone garanzie di conservazione stabile dei dati. Nel file system ext3 allora utilizzato, la funzione fsync() ha scaricato su disco tutte le pagine "sporche" del sistema e non solo quelle correlate al file corrispondente. Ciò significava che facendo clic su un pulsante in Firefox si poteva attivare la scrittura di megabyte di dati su un disco magnetico, il che poteva richiedere molti secondi. La soluzione al problema, per quanto ho capito esso il materiale consisteva nel trasferire il lavoro con il database ad attività in background asincrone. Ciò significa che Firefox in precedenza implementava requisiti di archiviazione più rigorosi di quelli realmente necessari e le funzionalità del file system ext3 non hanno fatto altro che esacerbare questo problema.

Il secondo problema si è verificato nel 2009. Quindi, dopo un arresto anomalo del sistema, gli utenti del nuovo file system ext4 si sono trovati di fronte al fatto che molti file appena creati avevano lunghezza zero, ma ciò non è accaduto con il vecchio file system ext3. Nel paragrafo precedente, ho parlato di come ext3 abbia scaricato troppi dati su disco, il che ha rallentato molto le cose. fsync(). Per migliorare la situazione, in ext4 vengono scaricate sul disco solo le pagine sporche rilevanti per un particolare file. E i dati di altri file rimangono in memoria per un tempo molto più lungo rispetto a ext3. Ciò è stato fatto per migliorare le prestazioni (per impostazione predefinita, i dati rimangono in questo stato per 30 secondi, puoi configurarlo utilizzando dirty_expire_centisecs; qui Puoi trovare materiale aggiuntivo a riguardo). Ciò significa che una grande quantità di dati può andare irrimediabilmente persa dopo un guasto. La soluzione a questo problema è usare fsync() in applicazioni che necessitano di garantire un'archiviazione stabile dei dati e proteggerli il più possibile dalle conseguenze di guasti. Funzione fsync() funziona in modo molto più efficiente quando si utilizza ext4 rispetto a quando si utilizza ext3. Lo svantaggio di questo approccio è che il suo utilizzo, come prima, rallenta l'esecuzione di alcune operazioni, come l'installazione dei programmi. Vedi i dettagli a riguardo qui и qui.

Il terzo problema riguarda fsync(), nato nel 2018. Quindi, nell'ambito del progetto PostgreSQL, si è scoperto che se la funzione fsync() rileva un errore, contrassegna le pagine "sporche" come "pulite". Di conseguenza, le seguenti chiamate fsync() Non fanno nulla con tali pagine. Per questo motivo, le pagine modificate vengono archiviate in memoria e non vengono mai scritte su disco. Questo è un vero disastro, poiché l'applicazione penserà che alcuni dati siano scritti sul disco, ma in realtà non sarà così. Tali fallimenti fsync() sono rari, l'applicazione in tali situazioni non può fare quasi nulla per combattere il problema. Al giorno d'oggi, quando ciò accade, PostgreSQL e altre applicazioni si bloccano. Qui, nel materiale “Le applicazioni possono eseguire il ripristino da errori fsync?”, questo problema viene esplorato in dettaglio. Attualmente la migliore soluzione a questo problema è utilizzare Direct I/O con il flag O_SYNC o con una bandiera O_DSYNC. Con questo approccio, il sistema segnalerà gli errori che potrebbero verificarsi durante specifiche operazioni di scrittura, ma questo approccio richiede che sia l'applicazione stessa a gestire i buffer. Leggi di più su questo argomento qui и qui.

Apertura di file utilizzando i flag O_SYNC e O_DSYNC

Torniamo alla discussione sui meccanismi Linux che forniscono un'archiviazione stabile dei dati. Vale a dire, stiamo parlando dell'uso della bandiera O_SYNC o bandiera O_DSYNC quando si aprono file utilizzando la chiamata di sistema Aperto(). Con questo approccio, ogni operazione di scrittura dei dati viene eseguita come dopo ciascun comando write() il sistema riceve i comandi di conseguenza fsync() и fdatasync(). In Specifiche POSIX questo è chiamato "Completamento dell'integrità dei file di I/O sincronizzato" e "Completamento dell'integrità dei dati". Il vantaggio principale di questo approccio è che per garantire l'integrità dei dati è sufficiente effettuare una sola chiamata di sistema anziché due (ad esempio: write() и fdatasync()). Lo svantaggio principale di questo approccio è che tutte le scritture utilizzando il descrittore di file corrispondente verranno sincronizzate, il che può limitare la capacità di strutturare il codice dell'applicazione.

Utilizzo dell'I/O diretto con il flag O_DIRECT

Chiamata di sistema open() supporta la bandiera O_DIRECT, progettato per ignorare la cache del sistema operativo per eseguire operazioni di I/O interagendo direttamente con il disco. Ciò, in molti casi, significa che i comandi di scrittura emessi dal programma verranno tradotti direttamente in comandi volti a lavorare con il disco. Ma, in generale, questo meccanismo non sostituisce le funzioni fsync() o fdatasync(). Il fatto è che il disco stesso può farlo rinviare o memorizzare nella cache corrispondenti comandi di scrittura dati. E, a peggiorare le cose, in alcuni casi speciali le operazioni di I/O vengono eseguite quando si utilizza il flag O_DIRECT, trasmissione nelle tradizionali operazioni bufferizzate. Il modo più semplice per risolvere questo problema è utilizzare il flag per aprire i file O_DSYNC, il che significa che ogni operazione di scrittura sarà seguita da una chiamata fdatasync().

Si è scoperto che il file system XFS aveva recentemente aggiunto un "percorso veloce" per O_DIRECT|O_DSYNC-registrazione dei dati. Se un blocco viene riscritto utilizzando O_DIRECT|O_DSYNC, quindi XFS, invece di svuotare la cache, eseguirà il comando di scrittura FUA se il dispositivo lo supporta. L'ho verificato utilizzando l'utility blktrace su un sistema Linux 5.4/Ubuntu 20.04. Questo approccio dovrebbe essere più efficiente poiché, quando utilizzato, viene scritta una quantità minima di dati sul disco e viene utilizzata un'operazione anziché due (scrittura e svuotamento della cache). Ho trovato un collegamento a toppa kernel 2018, che implementa questo meccanismo. Si discute sull'applicazione di questa ottimizzazione ad altri file system, ma per quanto ne so, XFS è l'unico file system che lo supporta finora.

funzione sync_file_range()

Linux ha una chiamata di sistema intervallo_file_sincronizzazione(), che consente di scaricare solo una parte del file su disco, anziché l'intero file. Questa chiamata avvia uno svuotamento asincrono dei dati e non ne attende il completamento. Ma nel certificato sync_file_range() si dice che la squadra sia "molto pericolosa". Non è consigliabile utilizzarlo. Caratteristiche e pericoli sync_file_range() molto ben descritto in Questo Materiale. Nello specifico, questa chiamata sembra utilizzare RocksDB per controllare quando il kernel scarica i dati sporchi sul disco. Ma allo stesso tempo, viene utilizzato anche per garantire un'archiviazione stabile dei dati fdatasync(). In codice RocksDB ha alcuni commenti interessanti su questo argomento. Ad esempio, sembra che la chiamata sync_file_range() Quando si utilizza ZFS, non scarica i dati sul disco. L'esperienza mi dice che il codice utilizzato raramente può contenere bug. Pertanto, sconsiglio di utilizzare questa chiamata di sistema a meno che non sia assolutamente necessario.

Chiamate di sistema che aiutano a garantire la persistenza dei dati

Sono giunto alla conclusione che esistono tre approcci che possono essere utilizzati per eseguire operazioni di I/O che garantiscono la persistenza dei dati. Richiedono tutti una chiamata di funzione fsync() per la directory in cui è stato creato il file. Questi gli approcci:

  1. Chiamata di funzione fdatasync() o fsync() dopo la funzione write() (è meglio usare fdatasync()).
  2. Lavorare con un descrittore di file aperto con un flag O_DSYNC o O_SYNC (meglio - con una bandiera O_DSYNC).
  3. Uso del comando pwritev2() con bandiera RWF_DSYNC o RWF_SYNC (preferibilmente con una bandiera RWF_DSYNC).

Note sulle prestazioni

Non ho misurato attentamente le prestazioni dei vari meccanismi che ho esaminato. Le differenze che ho notato nella velocità del loro lavoro sono molto piccole. Ciò significa che potrei sbagliarmi e che in condizioni diverse la stessa cosa può produrre risultati diversi. Per prima cosa parlerò di ciò che influisce maggiormente sulle prestazioni e poi di ciò che influisce meno sulle prestazioni.

  1. La sovrascrittura dei dati di un file è più rapida rispetto all'aggiunta di dati a un file (il vantaggio in termini di prestazioni può essere compreso tra il 2 e il 100%). L'aggiunta di dati a un file richiede ulteriori modifiche ai metadati del file, anche dopo una chiamata di sistema fallocate(), ma l’entità di questo effetto può variare. Consiglio, per una migliore prestazione, di chiamare fallocate() per preassegnare lo spazio richiesto. Quindi questo spazio deve essere riempito esplicitamente con zeri e chiamato fsync(). Ciò garantirà che i blocchi corrispondenti nel file system siano contrassegnati come "allocati" anziché "non allocati". Ciò fornisce un piccolo miglioramento delle prestazioni (circa il 2%). Inoltre, alcuni dischi potrebbero avere un primo accesso a un blocco più lento rispetto ad altri. Ciò significa che riempire lo spazio con zeri può portare a un miglioramento significativo (circa il 100%) delle prestazioni. In particolare, ciò può accadere con i dischi AWSEBS (questi sono dati non ufficiali, non ho potuto confermarli). Lo stesso vale per lo stoccaggio Disco permanente GCP (e queste sono già informazioni ufficiali, confermate dai test). Altri esperti hanno fatto lo stesso osservazioni, relativo a vari dischi.
  2. Meno chiamate di sistema, maggiore sarà la prestazione (il guadagno può essere di circa il 5%). Sembra una sfida open() con bandiera O_DSYNC oppure chiama pwritev2() con bandiera RWF_SYNC più veloce di una chiamata fdatasync(). Ho il sospetto che il punto qui sia che questo approccio gioca un ruolo nel fatto che è necessario eseguire meno chiamate di sistema per risolvere lo stesso problema (una chiamata invece di due). Ma la differenza di prestazioni è molto piccola, quindi puoi ignorarla completamente e utilizzare qualcosa nell'applicazione che non ne complichi la logica.

Se sei interessato al tema dell’archiviazione sostenibile dei dati, ecco alcuni materiali utili:

  • Metodi di accesso I/O — panoramica delle basi dei meccanismi di input/output.
  • Garantire che i dati raggiungano il disco — una storia su cosa succede ai dati nel percorso dall'applicazione al disco.
  • Quando dovresti sincronizzare la directory che lo contiene - la risposta alla domanda su quando utilizzare fsync() per le directory. Per dirla in poche parole, risulta che è necessario farlo quando si crea un nuovo file e il motivo di questa raccomandazione è che in Linux possono esserci molti riferimenti allo stesso file.
  • SQL Server su Linux: componenti interni FUA — ecco una descrizione di come viene implementata l'archiviazione persistente dei dati in SQL Server sulla piattaforma Linux. Ci sono alcuni confronti interessanti tra le chiamate di sistema Windows e Linux qui. Sono quasi sicuro che sia stato grazie a questo materiale che ho appreso dell'ottimizzazione FUA di XFS.

Hai perso dati che pensavi fossero archiviati in modo sicuro su un disco?

Archiviazione dati durevole e API di file Linux

Archiviazione dati durevole e API di file Linux

Fonte: habr.com