Mentre esploravo la resilienza dello storage nei sistemi cloud, ho deciso di mettermi alla prova, per assicurarmi di comprendere le basi. Ho per capire quali garanzie riguardanti la resilienza dei dati (cioè le garanzie che i dati saranno disponibili dopo un guasto del sistema) ci offrono i dischi NVMe. Ho tratto le seguenti conclusioni principali: i dati devono essere considerati danneggiati dal momento in cui viene dato l'ordine di scrittura e fino a quando la scrittura non è completata sul supporto. Tuttavia, nella maggior parte dei programmi di scrittura dei dati, vengono spesso utilizzate tranquillamente chiamate di sistema.
In questo materiale, esaminerò i meccanismi di resilienza dei dati forniti dalle API di file di Linux. Sembra che qui tutto dovrebbe essere semplice: il programma invoca il comando write(), e dopo che il comando ha terminato la sua esecuzione, i dati dovrebbero essere salvati in modo affidabile sul disco. Ma write() esso copia solo i dati dell'applicazione nella cache del kernel, che si trova nella memoria principale. Per costringere il sistema a scrivere i dati sul disco, è necessario utilizzare alcuni meccanismi aggiuntivi.
In generale, questo materiale rappresenta un insieme di appunti riguardo a ciò che ho appreso sull'argomento che mi interessa. Se si desidera riassumere brevemente il punto più importante, si può affermare che per garantire la resilienza dei dati è necessario utilizzare il comando fdatasync() o aprire i file con il flag O_DSYNC. Se sei interessato a scoprire nei dettagli ciò che accade ai dati nel percorso dal codice sorgente al disco, dai un'occhiata a articolo.
Caratteristiche dell'uso della funzione write()
Chiamata di sistema write() definito nello standard come un tentativo di scrivere dati in un descrittore di file. Dopo il completamento con successo, write() le operazioni di lettura dei dati devono restituire esattamente quei byte che erano stati precedentemente scritti, anche se i dati vengono accessibili da altri processi o thread ( la sezione corrispondente dello standard POSIX). , nella sezione dedicata all'interazione dei thread con le normali operazioni sui file, c'è una nota che afferma che se entrambi i thread chiamano queste funzioni, ogni chiamata deve vedere o tutte le conseguenze designate derivanti dall'esecuzione di un'altra chiamata, oppure non deve vedere alcuna conseguenza. Questo porta alla conclusione che tutte le operazioni di input/output sui file devono mantenere il blocco della risorsa su cui operano.
Significa questo che l'operazione write() è atomica? Dal punto di vista tecnico — sì. Le operazioni di lettura dei dati devono restituire tutto o nulla di ciò che è stato scritto tramite write(). Ma l'operazione write(), secondo lo standard, non deve necessariamente completarsi scrivendo tutto ciò che le è stato proposto di scrivere. È permesso scrivere solo una parte dei dati. Ad esempio, possiamo avere due thread, ognuno dei quali aggiunge 1024 byte a un file identificato dallo stesso descrittore di file. Dal punto di vista dello standard, è accettabile che ogni operazione di scrittura possa aggiungere solo un byte al file. Queste operazioni rimarranno atomiche, ma una volta completate, i dati scritti nel file risulteranno mescolati. c'è una discussione molto interessante su questo argomento su Stack Overflow.
Le funzioni fsync() e fdatasync()
Il modo più semplice per scrivere i dati su disco è invocare la funzione . Questa funzione richiede al sistema operativo di trasferire tutti i blocchi modificati dalla cache al disco. Questo include anche tutti i metadati del file (tempo di accesso, tempo di modifica del file e così via). Penso che la necessità di questi metadati si verifichi raramente, quindi se sai che non ti sono importanti, puoi utilizzare la funzione fdatasync(). In da fdatasync() afferma che nel corso di questa funzione viene eseguito il salvataggio su disco di un volume di metadati che è “necessario per l'esecuzione corretta delle operazioni di lettura successiva sui dati”. E questo è esattamente ciò che preoccupa la maggior parte delle applicazioni.
Uno dei problemi che possono sorgere qui è che questi meccanismi non garantiscono che il file possa essere rilevato dopo un possibile guasto. In particolare, quando si crea un nuovo file, è necessario invocare fsync() per la directory che lo contiene. Altrimenti, dopo un guasto, potrebbe risultare che questo file non esiste. La ragione di ciò è che in UNIX, a causa dell'uso di collegamenti rigidi, un file può esistere in più directory. Quindi, quando viene chiamato fsync() non c'è modo per il file di sapere quali dati di quale directory devono essere scritti su disco ( si può leggere di più al riguardo). Sembra che il file system ext4 sia in grado di applicare fsync() alle directory che contengono i file corrispondenti, ma nel caso di altri file system, potrebbe non essere così.
Questo meccanismo può essere implementato in modo diverso nei vari file system. Ho usato per scoprire quali operazioni su disco vengono utilizzate nei file system ext4 e XFS. Entrambi emettono normali comandi di scrittura su disco sia per i contenuti dei file che per il journaling del file system, svuotano la cache e terminano l'operazione eseguendo una scrittura FUA (Force Unit Access, scrittura dei dati direttamente su disco, bypassando la cache) nel journal. È probabile che procedano in questo modo per confermare l'operazione eseguita. Su dischi che non supportano FUA, ciò provoca due svuotamenti della cache. I miei esperimenti hanno dimostrato che fdatasync() è un po' più veloce fsync(). L'utilità blktrace indica che fdatasync() scrive normalmente meno dati su disco (in ext4 fsync() scrive 20 KiB, mentre fdatasync() ne scrive 16 KiB). Inoltre, ho scoperto che XFS è leggermente più veloce di ext4. E qui sono riuscito a scoprire che blktrace svuota su disco meno dati (4 KiB in XFS). fdatasync() Situazioni ambigue che sorgono con l'uso di fsync()
Posso ricordare tre situazioni ambigue riguardo
, con cui mi sono imbattuto nella pratica. fsync()Il primo di questi casi è avvenuto nel 2008. In quel momento, l'interfaccia di Firefox 3 'si bloccava' quando veniva scritta su disco una grande quantità di file. Il problema era che nell'implementazione dell'interfaccia per memorizzare informazioni sul suo stato veniva utilizzato un database SQLite. Dopo ogni modifica avvenuta nell'interfaccia, veniva chiamata la funzione
, il che garantiva buone garanzie di persistenza dei dati. Nella file system utilizzata all'epoca, ext3, la funzione fsync(), che forniva buone garanzie di archiviazione stabile dei dati. Nella filesystem ext3 utilizzata allora, la funzione fsync() reindirizzava su disco tutte le pagine "sporche" nel sistema, e non solo quelle correlate al file pertinente. Ciò significava che un clic su un pulsante in Firefox poteva avviare la scrittura di megabyte di dati su disco rigido, cosa che poteva richiedere molti secondi. Ho compreso la soluzione al problema da materiale che era quella di spostare il lavoro con il database in attività di fondo asincrone. Ciò implica che in precedenza in Firefox erano stati implementati requisiti di robustezza del salvataggio dei dati molto più severi di quanto fosse realmente necessario, e le peculiarità del file system ext3 hanno solo aggravato questo problema.
Il secondo inconveniente si è verificato nel 2009. In quell'anno, dopo un guasto del sistema, gli utenti del nuovo file system ext4 si sono trovati di fronte al fatto che molti file di recente creazione avevano una lunghezza zero, mentre con il file system più vecchio ext3 non si è verificato nulla di simile. Nel paragrafo precedente accennavo al fatto che l'ext3 scriveva troppi dati su disco, il che rallentava notevolmente le operazioni fsync(). Per migliorare la situazione, in ext4 vengono scritte su disco solo le "pagine sporche" che sono correlate a un file specifico. I dati degli altri file rimangono in memoria per un tempo molto più lungo rispetto a quanto avveniva con l'uso dell'ext3. Questo è stato fatto per migliorare le prestazioni (per impostazione predefinita, i dati rimangono in questo stato per 30 secondi, e questo può essere configurato tramite ; , si possono trovare ulteriori materiali a riguardo). Ciò significa che una grande quantità di dati può essere permanentemente persa dopo un guasto. La soluzione a questo problema consiste nell'utilizzo di fsync() applicazioni che necessitano di garantire una conservazione affidabile dei dati e proteggere al massimo dalle conseguenze dei guasti. La funzione fsync() opera con l'ext4 in modo significativamente più efficace rispetto all'ext3. Il rovescio della medaglia di questo approccio è che la sua applicazione, come in passato, rallenta l'esecuzione di alcune operazioni, come l'installazione di programmi. Maggiori dettagli su questo si possono trovare e .
Il terzo problema riguardante fsync(), è emerso nel 2018. In quell'anno, nell'ambito del progetto PostgreSQL, è stato scoperto che se la funzione fsync() incontra un errore, contrassegna le "pagine sporche" come "pulite". Di conseguenza, le chiamate successive fsync() non si fa niente con queste pagine. A causa di ciò, le pagine modificate rimangono in memoria e non vengono mai scritte su disco. Questa è una vera catastrofe, poiché l'applicazione penserà che alcuni dati siano stati scritti su disco, ma in realtà così non è. Questi malfunzionamenti fsync() si verificano raramente, l'applicazione in queste situazioni può fare quasi nulla per affrontare il problema. Al giorno d'oggi, quando ciò accade, PostgreSQL e altre applicazioni terminano improvvisamente. , nel materiale "Can Applications Recover from fsync Failures?", questo problema viene studiato in tutti i dettagli. Attualmente, la miglior soluzione per questo problema è utilizzare Direct I/O con il flag O_SYNC o con il flag O_DSYNC. Con questo approccio, il sistema segnalerà gli errori che possono verificarsi durante l'esecuzione di operazioni specifiche di scrittura dei dati, ma questo approccio richiede che l'applicazione gestisca i buffer autonomamente. Maggiori dettagli su questo possono essere letti e .
Apertura di file con i flag O_SYNC e O_DSYNC
Torniamo a discutere dei meccanismi di Linux che garantiscono l'integrità dei dati. In particolare, si tratta dell'uso del flag O_SYNC o del flag O_DSYNC quando si aprono file usando la chiamata di sistema . Con questo approccio, ogni operazione di scrittura di dati viene eseguita come se dopo ogni comando write() al sistema venissero date, rispettivamente, le istruzioni fsync() e fdatasync(). In questo è chiamato "Synchronized I/O File Integrity Completion" e "Data Integrity Completion". Il principale vantaggio di questo approccio è che per garantire l'integrità dei dati è necessario eseguire solo una chiamata di sistema, e non due (ad esempio - write() e fdatasync()). Lo svantaggio principale di questo approccio è che tutte le operazioni di scrittura che utilizzano il relativo descrittore di file saranno sincronizzate, il che può limitare le possibilità di strutturare il codice dell'applicazione.
L'uso di Direct I/O con il flag O_DIRECT
Chiamata di sistema open() supporta il flag O_DIRECT, che è progettato per eseguire operazioni di input/output bypassando la cache del sistema operativo, interagendo direttamente con il disco. Questo, in molti casi, significa che i comandi di scrittura emessi dal programma verranno direttamente tradotti in comandi destinati a lavorare con il disco. Ma, in generale, questo meccanismo non sostituisce le funzioni fsync() o fdatasync(). Il fatto è che il disco stesso può i comandi corretti per la scrittura dei dati. E, peggio ancora, in alcuni casi particolari, le operazioni di input/output eseguite utilizzando il flag O_DIRECT, in operazioni di buffering tradizionali. Il modo più semplice per risolvere questo problema è utilizzare anche il flag per aprire i file O_DSYNC, il che significherà che ad ogni operazione di scrittura seguirà una chiamata fdatasync().
È emerso che nel file system XFS è stato recentemente aggiunto un 'percorso veloce' per O_DIRECT|O_DSYNC-la scrittura dei dati. Se si riporta un blocco utilizzando O_DIRECT|O_DSYNC, XFS, invece di svuotare la cache, eseguirà il comando di scrittura FUA se l'hardware lo supporta. Ne ho avuto conferma utilizzando l'utilità blktrace nel sistema Linux 5.4/Ubuntu 20.04. Questo approccio dovrebbe essere più efficace, poiché riduce al minimo la quantità di dati scritti su disco e utilizza un'unica operazione anziché due (scrittura e svuotamento della cache). Ho trovato un collegamento al kernel del 2018, in cui è stato implementato questo meccanismo. Lì c'è una discussione relativa all'applicazione di questa ottimizzazione anche in altri file system, ma, per quanto ne so, XFS è l'unico file system che attualmente lo supporta.
La funzione sync_file_range()
In Linux esiste una chiamata di sistema , che consente di svuotare su disco solo una parte di un file, non l'intero file. Questa chiamata avvia uno svuotamento asincrono dei dati e non ne attende il completamento. Ma nella documentazione sync_file_range() si afferma che questo comando è 'molto pericoloso'. Non è consigliabile usarlo. Le caratteristiche e i pericoli sync_file_range() sono descritti molto bene nel materiale. In particolare, sembra che questa chiamata utilizzi RocksDB per gestire quando il kernel svuota i dati 'sporchi' su disco. Ma al contempo, per garantire una memorizzazione persistente dei dati, utilizza anche fdatasync(). In RocksDB ci sono commenti interessanti su questo argomento. Ad esempio, sembra che la chiamata sync_file_range() quando si utilizza ZFS non comporti lo svuotamento dei dati su disco. L'esperienza mi dice che il codice che viene usato raramente potrebbe contenere errori. Pertanto, consiglio di non utilizzare questa chiamata di sistema senza un'urgente necessità.
Le chiamate di sistema che aiutano a garantire una conservazione persistente dei dati
Sono giunto alla conclusione che per effettuare operazioni di input/output che garantiscano una conservazione persistente dei dati, si possono adottare tre approcci. Tutti richiedono la chiamata della funzione fsync() per la directory in cui è stato creato il file. Ecco questi approcci:
- Chiamata della funzione
fdatasync()ofsync()dopo la funzionewrite()(è meglio utilizzarefdatasync()). - Lavorare con il descrittore di file, aperto con il flag
O_DSYNCoO_SYNC(meglio — con il flagO_DSYNC). - Utilizzo del comando
pwritev2()con il flagRWF_DSYNCoRWF_SYNC(preferibilmente — con il flagRWF_DSYNC).
Note sulle prestazioni
Non ho effettuato misurazioni accurate delle prestazioni dei vari meccanismi che ho esaminato. Le differenze di velocità che ho notato sono piuttosto minime. Questo significa che potrei sbagliarmi, e che in altre condizioni potrebbe mostrare risultati diversi. Inizierò a parlare di ciò che influisce maggiormente sulle prestazioni, e poi di ciò che influisce meno.
- La riscrittura dei dati di un file è più veloce dell'aggiunta di dati a un file (il guadagno in prestazioni può variare dal 2% al 100%). L'aggiunta di dati a un file richiede modifiche aggiuntive ai metadati del file, anche dopo la chiamata di sistema
fallocate(), ma l'entità di questo effetto può variare. Raccomando, per garantire le migliori prestazioni, di chiamarefallocate()per l'allocazione anticipata dello spazio necessario. Dopo, questo spazio deve essere esplicitamente riempito di zeri e chiamarefsync(). Questo consente ai relativi blocchi nel file system di essere contrassegnati come "allocati" e non come "non allocati". Questo apporta un piccolo (circa il 2%) miglioramento delle prestazioni. Inoltre, alcuni dischi possono avere la prima operazione di accesso a un blocco che potrebbe richiedere più tempo rispetto ad altri. Ciò significa che riempire lo spazio di zeri può portare a un miglioramento significativo (circa il 100%) delle prestazioni. In particolare, ciò può avvenire con i dischi (questi sono dati non ufficiali, non sono riuscito a confermarli). Lo stesso vale per gli storage (questo è invece un dato ufficiale, confermato dai test). Altri specialisti hanno fatto osservazioni simili Meno chiamate di sistema ci sono, maggiore è la prestazione (il guadagno può essere circa il 5%). Sembra che la chiamata - o la chiamata
open()con il flagO_DSYNCsia più veloce della chiamatapwritev2()con il flagRWF_SYNCrichiama più rapidamentefdatasync()Sospetto che il motivo risieda nel fatto che, con questo approccio, il numero di chiamate di sistema necessarie per risolvere lo stesso compito è ridotto (una chiamata invece di due). Tuttavia, la differenza nelle prestazioni è molto piccola, quindi potete ignorarla e utilizzare nell'applicazione ciò che non porterà a complicare la sua logica.
Se siete interessati al tema dell'archiviazione dati sicura, ecco alcuni materiali utili:
- — una panoramica dei principali meccanismi di input/output.
- — una spiegazione di cosa succede ai dati mentre viaggiano dall'applicazione al disco.
- — una risposta su quando applicare
fsync()per le directory. In breve, è consigliabile farlo quando si crea un nuovo file, e il motivo di questo consiglio è che in Linux possono esserci molte link a uno stesso file. - — qui viene descritto come l'archiviazione dati sicura venga implementata in SQL Server sulla piattaforma Linux. Ci sono anche alcune interessanti comparazioni tra le chiamate di sistema di Windows e Linux. Sono quasi certo di aver appreso dell'ottimizzazione FUA di XFS proprio grazie a questo materiale.
Avete mai perso dati che pensavate fossero conservati in modo sicuro sul disco?
Fonte: habr.com
