Mentre esploravo la resilienza dello storage nei sistemi cloud, ho deciso di mettermi alla prova per assicurarmi di comprendere i concetti di base. Ho per capire quali garanzie riguardanti la resilienza dello storage (cioè le garanzie che i dati saranno accessibili dopo un guasto del sistema) ci forniscono i dischi NVMe. Ho tratto le seguenti conclusioni principali: si deve considerare che i dati sono danneggiati dal momento in cui viene emesso il comando di scrittura fino a quando la scrittura non è completata sul supporto. Tuttavia, nella maggior parte dei programmi di scrittura, si utilizzano tranquillamente le chiamate di sistema.
In questo articolo esaminerò i meccanismi di resilienza dello storage forniti dalle API file di Linux. Sembra che qui tutto dovrebbe essere semplice: il programma chiama il comando write(), e dopo che questo comando è stato completato, i dati dovrebbero essere salvati in modo sicuro sul disco. Ma write() copierà solo i dati dell'applicazione nella cache del kernel, situata nella memoria. Per forzare il sistema a scrivere i dati sul disco, è necessario utilizzare alcuni meccanismi aggiuntivi.
In generale, questo materiale è un insieme di appunti che riguardano ciò che ho appreso sull'argomento che mi interessa. Se dobbiamo sintetizzare le informazioni più importanti, si può dire che per organizzare uno storage dati sostenibile è necessario utilizzare il comando fdatasync() o aprire i file con il flag O_DSYNC. Se siete interessati a conoscere i dettagli su cosa succede ai dati durante il passaggio dal codice sorgente al disco, date un'occhiata a articolo.
Le caratteristiche dell'uso della funzione write()
Chiamata di sistema write() sono definite nello standard come un tentativo di scrivere dati in un descrittore di file. Dopo il completamento riuscito write() le operazioni di lettura dei dati devono restituire esattamente i byte che sono stati precedentemente scritti, anche se i dati vengono acceduti 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 ciascuno dei due thread chiama queste funzioni, ogni chiamata deve vedere o tutte le conseguenze designate che derivano dall'esecuzione dell'altra chiamata, oppure non vedere affatto conseguenze. Questo porta a concludere che tutte le operazioni di input/output su file devono trattenere il blocco della risorsa con cui stanno lavorando.
Significa che l'operazione write() è atomica? Dal punto di vista tecnico — sì. Le operazioni di lettura dei dati devono restituire o tutto, o niente di quanto è stato scritto con write(). Ma l'operazione write(), in conformità con lo standard, non è necessario che si completi, registrando tutto ciò che le è stato proposto di registrare. Le è consentito registrare solo una parte dei dati. Ad esempio, potremmo avere due flussi, ciascuno dei quali aggiunge 1024 byte a un file descritto dallo stesso descrittore di file. Dal punto di vista dello standard, si considererebbe accettabile il risultato in cui ciascuna delle operazioni di scrittura può aggiungere solo un byte al file. Queste operazioni rimarranno atomiche, ma dopo il loro completamento, 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 forzare la scrittura dei dati su disco è chiamare la funzione . Questa funzione richiede al sistema operativo di trasferire tutti i blocchi modificati dalla cache al disco. Ciò include anche tutti i metadati del file (tempo di accesso, tempo di modifica del file e così via). Credo che la necessità di questi metadati sorga raramente, quindi, se sai che non sono importanti per te, puoi utilizzare la funzione fdatasync(). In per fdatasync() Si dice che durante l'esecuzione di questa funzione venga salvato su disco un volume di metadati «necessario per l'esecuzione corretta delle operazioni di lettura dei dati successive». E questo è proprio ciò che preoccupa la maggior parte delle applicazioni.
Uno dei problemi che potrebbe sorgere qui è che questi meccanismi non garantiscono che il file sarà recuperabile dopo un possibile guasto. In particolare, quando si crea un nuovo file, è necessario chiamare fsync() per la directory che lo contiene. Altrimenti, dopo un guasto, potrebbe risultare che quel file non esiste. La ragione è che in UNIX, a causa dell'uso dei collegamenti rigidi, un file può esistere in più directory. Pertanto, al momento della chiamata fsync() per il file non c'è modo di sapere quali dati di quale directory devono essere scritti su disco ( puoi leggere di più a riguardo). Sembra che il file system ext4 sia in grado di applicare fsync() alle directory che contengono i file appropriati, ma nel caso di altri file system questo potrebbe non essere così.
Questo meccanismo può essere implementato in vari modi nei diversi file system. Ho utilizzato per scoprire quali operazioni di disco vengono utilizzate nei file system ext4 e XFS. Entrambi emettono comuni comandi di scrittura su disco sia per il contenuto dei file che per il registro del file system, svuotano la cache e terminano l'operazione eseguendo la scrittura FUA (Force Unit Access, scrittura dei dati direttamente sul disco, saltando la cache) nel registro. Probabilmente, adottano questo approccio per confermare che l'operazione è stata effettuata. Su dischi che non supportano FUA, ciò provoca due svuotamenti della cache. I miei esperimenti hanno dimostrato che fdatasync() è leggermente più veloce fsync(). Strumento blktrace indica che fdatasync() di solito scrive meno dati sul disco (in ext4 fsync() scrive 20 KiB, mentre fdatasync() scrive 16 KiB). Inoltre, ho scoperto che XFS è leggermente più veloce di ext4. E qui con l'aiuto di blktrace sono riuscito a scoprire che fdatasync() svuota sul disco meno dati (4 KiB in XFS).
Situazioni ambigue che si presentano quando si utilizza fsync()
Posso ricordare tre situazioni ambigue riguardanti fsync(), con cui mi sono confrontato nella pratica.
Il primo caso di questo tipo si è verificato nel 2008. Allora, l'interfaccia di Firefox 3 si bloccava quando si tentava di scrivere su disco un grande numero di file. Il problema era che nella realizzazione dell'interfaccia per memorizzare le informazioni sul suo stato veniva utilizzato un database SQLite. Dopo ogni modifica apportata all'interfaccia, veniva chiamata la funzione fsync(), che garantiva buone garanzie di memorizzazione dei dati. Nella filesystem utilizzata allora, ext3, la funzione fsync() scriveva su disco tutte le pagine "sporche" nel sistema, non solo quelle relative al file specifico. Questo significava che un clic su un pulsante in Firefox poteva avviare la registrazione di megabyte di dati su disco rigido, richiedendo molti secondi. La soluzione al problema, da quanto ho compreso da materiale, consisteva nel trasferire il lavoro con il database in attività di sfondo asincrone. Questo significa che prima in Firefox erano implementati requisiti più severi per la robustezza della memorizzazione dei dati di quanto fosse realmente necessario, e le peculiarità del filesystem ext3 aggravavano ulteriormente questo problema.
Il secondo malfunzionamento si è verificato nel 2009. Allora, dopo un guasto del sistema, gli utenti del nuovo file system ext4 si sono trovati di fronte a file recentemente creati che avevano una lunghezza pari a zero, mentre con il più vecchio file system ext3 questo non è accaduto. Nel paragrafo precedente, ho accennato al fatto che ext3 scriveva su disco troppi dati, il che rallentava notevolmente l'operazione. fsync(). Per migliorare la situazione, in ext4 vengono scritte su disco solo le pagine "sporche" relative a un file specifico. I dati degli altri file rimangono in memoria per un tempo molto più lungo rispetto a quanto avviene con ext3. Questo è stato realizzato per migliorare le prestazioni (di default i dati rimangono in questo stato per 30 secondi, questo può essere configurato tramite ; è possibile trovare ulteriori materiali a riguardo). Questo significa che un grande volume di dati può andare irrimediabilmente perso dopo un guasto. La soluzione a questo problema consiste nell'utilizzo di fsync() in applicazioni che necessitano di garantire una memorizzazione dati sicura e massimizzare la protezione da eventuali malfunzionamenti. La funzione fsync() funziona con ext4 in modo molto più efficiente rispetto a ext3. Lo svantaggio di questo approccio è che, come prima, rallenta l'esecuzione di alcune operazioni, come l'installazione di programmi. Maggiori dettagli su questo si trovano e .
Il terzo problema riguarda fsync(), emerso nel 2018. Allora, nell'ambito del progetto PostgreSQL, si è scoperto che se la funzione fsync() incorre in un errore, contrassegna le pagine 'sporche' come 'pulite'. Di conseguenza, le chiamate successive fsync() non fanno nulla con queste pagine. Questo comporta che le pagine modificate rimangano in memoria e non vengano mai scritte su disco. È una vera e propria catastrofe, poiché l'applicazione riterrà che alcuni dati siano stati scritti su disco, mentre in realtà non è così. Tali guasti fsync() sono rari, e l'applicazione in tali situazioni può fare poco per combattere il problema. Oggigiorno, quando ciò avviene, PostgreSQL e altre applicazioni terminano in modo anomalo. , nel materiale 'Can Applications Recover from fsync Failures?', questo problema viene approfondito in tutti i dettagli. Attualmente, la migliore soluzione a questo problema è utilizzare Direct I/O con il flag O_SYNC o con il flag O_DSYNC. Con questo approccio, il sistema segnalerà eventuali errori che possono verificarsi durante specifiche operazioni di scrittura dei dati, ma richiede che l'applicazione gestisca autonomamente i buffer. Maggiori dettagli su questo possono essere trovati e .
Apertura di file usando i flag O_SYNC e O_DSYNC
Torniamo a discutere dei meccanismi Linux che garantiscono la persistenza dei dati. In particolare, stiamo parlando dell'uso del flag O_SYNC o del flag O_DSYNC quando si apre file utilizzando la chiamata di sistema . Con questo approccio, ogni operazione di scrittura dei dati viene eseguita come se dopo ogni comando write() al sistema vengano date, rispettivamente, comandi fsync() e fdatasync(). In questo è noto come «Synchronized I/O File Integrity Completion» e «Data Integrity Completion». Il principale vantaggio di questo approccio è che per garantire l'integrità dei dati è necessario effettuare solo una chiamata di sistema, invece di due (ad esempio — write() e fdatasync()). Il principale svantaggio di questo approccio è che tutte le operazioni di scrittura che utilizzano il corrispondente descrittore di file saranno sincronizzate, il che può limitare le capacità di strutturazione del codice dell'applicazione.
Utilizzo 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 e interagendo direttamente con il disco. Questo, in molti casi, significa che i comandi di scrittura emessi dal programma verranno tradotti direttamente in comandi diretti per lavorare con il disco. Tuttavia, in generale, questo meccanismo non sostituisce le funzioni fsync() o fdatasync(). Il fatto è che il disco stesso può i corrispondenti comandi di scrittura dei dati. E, ciò che è peggio, in alcuni casi particolari, le operazioni di input/output eseguite utilizzando il flag O_DIRECT, in operazioni tradizionali con buffer. Il modo più semplice per risolvere questo problema è utilizzare anche il flag O_DSYNC, il che significa che per ogni operazione di scrittura ci sarà una chiamata fdatasync().
Si è scoperto che nel file system XFS è stata recentemente aggiunta una "via rapida" per O_DIRECT|O_DSYNC-la scrittura dei dati. Se si sovrascrive un blocco utilizzando O_DIRECT|O_DSYNC, quindi XFS, invece di svuotare la cache, eseguirà il comando FUA per la scrittura nel caso in cui il dispositivo lo supporti. Ne ho avuto la conferma utilizzando l'utility blktrace sul sistema Linux 5.4/Ubuntu 20.04. Questo approccio dovrebbe essere più efficiente, poiché comporta la scrittura di un numero minimo di dati sul disco e utilizza un'unica operazione, invece di due (scrittura e svuotamento della cache). Ho trovato un link a un kernel del 2018, in cui è implementato questo meccanismo. Lì c'è una discussione riguardante l'applicazione di questa ottimizzazione anche in altri file system, ma, per quanto ne so, XFS è attualmente l'unico file system che la supporta.
La funzione sync_file_range()
In Linux, esiste una chiamata di sistema , che consente di scrivere su disco solo una parte di un file, e non l'intero file. Questa chiamata avvia un flush asincrono dei dati e non aspetta il suo completamento. Ma nella documentazione di sync_file_range() si dice che questo comando è 'molto pericoloso'. Non si consiglia di usarlo. Le caratteristiche e i pericoli sync_file_range() sono ben descritti in materiale. In particolare, sembra che questa chiamata utilizzi RocksDB per gestire quando il kernel scrive i dati "sporchi" su disco. Tuttavia, per garantire la persistenza dei dati, viene utilizzato anche fdatasync(). In RocksDB ha commenti interessanti su questo argomento. Ad esempio, sembra che la chiamata sync_file_range() quando si utilizza ZFS non porti al salvataggio dei dati su disco. L'esperienza mi suggerisce che il codice utilizzato raramente potrebbe contenere errori. Pertanto, consiglierei di non utilizzare questa chiamata di sistema senza una ragione impellente.
Chiamate di sistema che aiutano a garantire la persistenza dei dati
Sono giunto alla conclusione che per eseguire operazioni di input/output che garantiscono la persistenza dei dati, si possono utilizzare 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). - Utilizzare il comando
pwritev2()con il flagRWF_DSYNCoRWF_SYNC(preferibilmente — con il flagRWF_DSYNC).
Note sulle prestazioni
Non ho effettuato misurazioni dettagliate delle prestazioni dei vari meccanismi che ho esaminato. Le differenze di velocità che ho osservato sono piuttosto piccole. Questo significa che potrei sbagliarmi e che, in condizioni diverse, quello stesso meccanismo potrebbe mostrare risultati diversi. Inizierò parlando di ciò che influisce maggiormente sulle prestazioni e poi di ciò che ha un impatto minore.
- La riscrittura dei dati di un file è più veloce rispetto all'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 la preallocazione dello spazio necessario. Successivamente, è necessario riempire esplicitamente questo spazio con zeri e chiamarefsync(). Questo rende i blocchi corrispondenti nel file system contrassegnati come «dedicati» invece di «non dedicati». Ciò fornisce un piccolo miglioramento delle prestazioni (circa il 2%). Inoltre, per alcuni dischi, la prima operazione di accesso a un blocco può richiedere più tempo di altre. Ciò significa che riempire lo spazio con zeri può portare a un miglioramento delle prestazioni significativo (circa il 100%). In particolare, questo può accadere con i dischi (questi dati non sono ufficiali, non sono riuscito a confermarli). Lo stesso vale per gli archivi (questo è invece un'informazione ufficiale, confermata da test). Altri esperti hanno fatto osservazioni simili , relative a vari dischi. - Meno chiamate di sistema ci sono, maggiore è la prestazione (il guadagno può essere di circa il 5%). Sembra che la chiamata
open()con il flagO_DSYNCo la chiamatapwritev2()con il flagRWF_SYNCsia più veloce della chiamatafdatasync()Sospetto che il problema sia che, con questo approccio, il numero di chiamate di sistema per risolvere lo stesso compito è ridotto (una chiamata anziché due). Ma la differenza nelle prestazioni è molto piccola, quindi puoi tranquillamente non tenerne conto e usare nel tuo applicativo ciò che non complica la sua logica.
Se ti interessa il tema della conservazione sostenibile dei dati — ecco alcuni materiali utili:
- — panoramica dei principali meccanismi di input/output.
- — racconto su cosa succede ai dati lungo il percorso dall'applicazione al disco.
- — risposta alla domanda su quando è necessario utilizzare
fsync()per le directory. In breve, bisogna farlo quando si crea un nuovo file, e il motivo di questo consiglio è che in Linux possono esserci molteplici collegamenti a un file stesso. - — qui descrive come è implementato l'archiviazione persistente dei dati in SQL Server sulla piattaforma Linux. Qui ci sono alcune interessanti comparazioni tra le chiamate di sistema di Windows e Linux. Sono quasi certo che è grazie a questo materiale che ho appreso dell'ottimizzazione FUA di XFS.
Hai mai perso dati che pensavi fossero salvati in modo sicuro su disco?
Fonte: habr.com
