Stockage durable des données et API de fichiers Linux

En explorant la durabilité du stockage de données dans les systèmes cloud, j'ai décidé de me tester, pour m'assurer que je comprends les éléments de base. Je ai commencé par lire la spécification NVMe pour comprendre quelles garanties relatives à la durabilité du stockage de données (c'est-à-dire, la garantie que les données seront disponibles après une panne système) nous fournissent les disques NVMe. J'ai tiré les principales conclusions suivantes : il faut considérer les données comme endommagées à partir du moment où la commande d'écriture des données est donnée, et jusqu'à ce que leur écriture sur le support d'information soit terminée. Cependant, dans la plupart des programmes d'écriture de données, les appels systèmes sont utilisés sans aucune précaution.

Dans ce document, j'explore les mécanismes de durabilité du stockage de données fournis par les API de fichiers Linux. Il semble que tout cela devrait être simple : le programme appelle la commande write(), et après que cette commande soit terminée, les données doivent être enregistrées de manière fiable sur le disque. Mais write() elle ne fait que copier les données de l'application dans le cache du noyau, situé en mémoire vive. Pour forcer le système à écrire les données sur le disque, il faut utiliser certains mécanismes supplémentaires.

Stockage durable des données et API de fichiers Linux

Dans l'ensemble, ce document est un ensemble de notes sur ce que j'ai appris sur le sujet qui m'intéresse. Pour résumer très brièvement l'essentiel, il en ressort que pour organiser un stockage de données durable, il faut utiliser la commande fdatasync() ou ouvrir les fichiers avec le drapeau O_DSYNC. Si vous êtes intéressé à en savoir plus sur ce qui se passe avec les données lors de leur passage du code à disque, jetez un coup d'œil à cet article.

Les caractéristiques de l'utilisation de la fonction write()

Appel système write() sont définies dans la norme IEEE POSIX comme une tentative d'écriture de données dans un descripteur de fichier. Après la réussite de l'opération, write() les opérations de lecture de données doivent renvoyer exactement les octets qui ont été précédemment écrits, même si les données sont accédées depuis d'autres processus ou threads (voici section correspondante de la norme POSIX). Ici, dans la section consacrée à l'interaction des flux avec les opérations de fichiers courantes, il est noté que si chacun des deux flux appelle ces fonctions, chaque appel doit soit voir toutes les conséquences désignées résultant de l'exécution de l'autre appel, soit ne voir aucune conséquence. Cela permet de conclure que toutes les opérations d'entrée/sortie sur les fichiers doivent maintenir un verrou sur la ressource avec laquelle elles travaillent.

Cela signifie-t-il que l'opération write() est atomique? D'un point de vue technique - oui. Les opérations de lecture de données doivent retourner soit tout, soit rien de ce qui a été écrit via write(). Mais l'opération write(), conformément à la norme, n'est pas nécessairement tenue de se terminer en écrivant tout ce qui lui a été proposé d'écrire. Elle est autorisée à écrire seulement une partie des données. Par exemple, nous pourrions avoir deux flux, chacun ajoutant 1024 octets à un fichier décrit par le même descripteur de fichier. D'un point de vue normatif, un résultat où chacune des opérations d'écriture parvient à ajouter au fichier seulement un octet serait acceptable. Ces opérations resteront atomiques, mais une fois terminées, les données qu'elles auront écrites dans le fichier seront mélangées. Voici il y a une discussion très intéressante à ce sujet sur Stack Overflow.

Les fonctions fsync() et fdatasync()

Le moyen le plus simple de forcer les données sur le disque est d'appeler la fonction fsync(). Cette fonction demande au système d'exploitation de transférer tous les blocs modifiés du cache vers le disque. Cela inclut également tous les métadonnées du fichier (heure d'accès, heure de modification du fichier, etc.). Je pense que le besoin de ces métadonnées se produit rarement, donc si vous savez qu'elles ne sont pas essentielles pour vous, vous pouvez utiliser la fonction fdatasync(). Il y a aide pour fdatasync() indique que lors de l'exécution de cette fonction, un volume de métadonnées est sauvegardé sur le disque, qui est "nécessaire pour l'exécution correcte des opérations suivantes de lecture de données". Et c'est précisément ce qui préoccupe la plupart des applications.

Un des problèmes qui peuvent survenir ici est que ces mécanismes ne garantissent pas que le fichier sera détectable après un éventuel échec. En particulier, lorsque vous créez un nouveau fichier, il est nécessaire d'appeler fsync() pour le répertoire qui le contient. Sinon, après un crash, il se peut que ce fichier n'existe plus. La raison en est qu'en UNIX, en raison de l'utilisation de liens durs, un fichier peut exister dans plusieurs répertoires. Par conséquent, lors de l'appel fsync() il n'y a aucun moyen de savoir pour quel répertoire les données doivent également être écrites sur disque (ici on peut en lire plus en détail). Il semble que le système de fichiers ext4 soit capable d'appliquer automatiquement à des répertoires contenant les fichiers correspondants, mais dans le cas d'autres systèmes de fichiers, cela peut ne pas être le cas. fsync() Ce mécanisme peut être implémenté différemment dans divers systèmes de fichiers. J'ai utilisé

pour comprendre quelles opérations de disque sont utilisées dans les systèmes de fichiers ext4 et XFS. Les deux produisent des commandes d'écriture sur disque habituelles, tant pour le contenu des fichiers que pour le journal du système de fichiers, qui vidangent le cache et terminent le travail en exécutant une écriture FUA (Force Unit Access, écriture des données directement sur le disque, contournant le cache) dans le journal. Il est probable qu'ils agissent ainsi pour confirmer le fait de l'opération. Sur les disques qui ne prennent pas en charge FUA, cela entraîne deux vidanges de cache. Mes expériences ont montré que blktrace un peu plus rapide fdatasync() . L'utilitaire fsync()indique que blktrace écrit généralement moins de données sur le disque (dans ext4 fdatasync() écrit 20 Ko, tandis que fsync() — écrit 16 Ko). De plus, j'ai découvert que XFS est légèrement plus rapide qu'ext4. Et ici, avec l'aide de fdatasync() j'ai pu déterminer que blktrace vidange moins de données sur le disque (4 Ko dans XFS). fdatasync() Situations ambiguës qui surviennent lors de l'utilisation de fsync()

Je peux me souvenir de trois situations ambiguës concernant

, auxquelles j'ai été confronté dans la pratique. fsync()Le premier de ces cas s'est produit en 2008. À l'époque, l'interface de Firefox 3 « gelait » lorsque de nombreux fichiers étaient écrits sur disque. Le problème était que, dans la mise en œuvre de l'interface, une base de données SQLite était utilisée pour stocker les informations sur son état. Après chaque modification survenue dans l'interface, la fonction

, qui garantissait un bon stockage des données, était appelée. Dans le système de fichiers ext3 utilisé alors, la fonction fsync(), ce qui offrait de bonnes garanties de stockage durable des données. Dans le système de fichiers ext3 utilisé à l'époque, la fonction fsync() elle enregistrait toutes les « pages sales » dans le système sur le disque, et pas seulement celles qui étaient liées au fichier concerné. Cela signifiait qu'un clic sur un bouton dans Firefox pouvait déclencher l'écriture de plusieurs mégaoctets de données sur le disque magnétique, ce qui pouvait prendre plusieurs secondes. La solution à ce problème, comme je l'ai compris de ce fichier) : le matériel, consistait à déplacer le travail de la base de données vers des tâches de fond asynchrones. Cela signifie qu'auparavant, Firefox appliquait des exigences beaucoup plus strictes en matière de durabilité du stockage des données, que cela n'était réellement nécessaire, et que les particularités du système de fichiers ext3 aggravaient ce problème.

La deuxième incohérence est survenue en 2009. À l'époque, après une défaillance du système, les utilisateurs du nouveau système de fichiers ext4 ont constaté que de nombreux fichiers récemment créés avaient une longueur nulle, tandis qu'avec l'ancien système de fichiers ext3, cela ne se produisait pas. Dans le paragraphe précédent, je parlais du fait qu'ext3 enregistrait trop de données sur le disque, ce qui ralentissait considérablement le système fsync(). Afin d'améliorer la situation, ext4 n'enregistre sur le disque que les « pages sales » qui sont liées à un fichier spécifique. Les données des autres fichiers restent en mémoire beaucoup plus longtemps qu'avec ext3. Cela a été fait pour améliorer les performances (par défaut, les données restent dans cet état pendant 30 secondes, ce qui peut être ajusté à l'aide de dirty_expire_centisecs; ici , des ressources supplémentaires peuvent être trouvées à ce sujet). Cela signifie qu'un grand volume de données peut être irrémédiablement perdu après une défaillance. La solution à ce problème consiste à utiliser fsync() dans les applications nécessitant une durabilité des données et maximisant leur protection contre les conséquences des défaillances. La fonction fsync() fonctionne sous ext4 de manière beaucoup plus efficace que sous ext3. L'inconvénient de cette approche est qu'elle ralentit, comme auparavant, l'exécution de certaines opérations, comme l'installation de programmes. Pour les détails, veuillez consulter ici et ici.

Le troisième problème relatif fsync(), est survenu en 2018. À cette époque, dans le cadre du projet PostgreSQL, il a été constaté que si la fonction fsync() rencontraient une erreur, elles marquaient les « pages sales » comme « propres ». En conséquence, les appels suivants fsync() Rien n'est fait avec de telles pages. En raison de cela, les pages modifiées sont stockées en mémoire et ne sont jamais écrites sur le disque. C'est une véritable catastrophe, car l'application croira que certaines données sont enregistrées sur le disque, alors qu'en réalité ce n'est pas le cas. De tels échecs fsync() sont rares, l'application dans de telles situations peut à peine faire quelque chose pour lutter contre le problème. De nos jours, lorsque cela se produit, PostgreSQL et d'autres applications se terminent de manière inattendue. Ici, dans le matériel « Les applications peuvent-elles récupérer après des échecs d'fsync ? », ce problème est exploré dans tous les détails. Actuellement, la meilleure solution à ce problème est d'utiliser Direct I/O avec le drapeau O_SYNC ou avec le drapeau O_DSYNC. Avec cette approche, le système signalera les erreurs qui peuvent survenir lors de l'exécution de certaines opérations d'écriture de données, mais cela nécessite que l'application gère les tampons par elle-même. Pour plus de détails, lisez ici et ici.

L'ouverture de fichiers avec les drapeaux O_SYNC et O_DSYNC

Revenons à la discussion sur les mécanismes Linux garantissant la conservation des données. En effet, il s'agit de l'utilisation du drapeau O_SYNC ou du drapeau O_DSYNC lors de l'ouverture de fichiers à l'aide de l'appel système open(). Avec cette approche, chaque opération d'écriture de données est effectuée comme si après chaque commande write() le système recevait, respectivement, les commandes fsync() et fdatasync(). Il y a de la spécification POSIX. Ceci est appelé « Complétion des intégrités de fichier d'E/S synchronisées » et « Complétion de l'intégrité des données ». L'avantage principal de cette approche est que pour garantir l'intégrité des données, il ne faut effectuer qu'un seul appel système, et non deux (par exemple — write() et fdatasync()). Le principal inconvénient de cette approche est que toutes les opérations d'écriture utilisant le descripteur de fichier correspondant seront synchronisées, ce qui peut limiter les possibilités de structuration du code de l'application.

L'utilisation de Direct I/O avec le drapeau O_DIRECT

Appel système open() soutient le drapeau O_DIRECT, qui est conçu pour effectuer des opérations d'entrée/sortie en contournant le cache du système d'exploitation, interagissant directement avec le disque. Cela signifie, dans de nombreux cas, que les commandes d'écriture émises par le programme seront directement traduites en commandes destinées à travailler avec le disque. Mais en général, ce mécanisme n'est pas un remplacement des fonctionnalités fsync() ou fdatasync(). En effet, le disque lui-même peut retarder ou mettre en cache les commandes d'enregistrement de données appropriées. Et, ce qui est pire, dans certains cas particuliers, les opérations d'entrée-sortie effectuées en utilisant le drapeau O_DIRECT, sont transmises en opérations tampons traditionnelles. Le moyen le plus simple de résoudre ce problème est d'ouvrir les fichiers avec le drapeau O_DSYNC, ce qui signifie qu'après chaque opération d'écriture, un appel sera effectué fdatasync().

Il s'avère qu'un « chemin rapide » a récemment été ajouté dans le système de fichiers XFS pour O_DIRECT|O_DSYNC-l'écriture des données. Si un bloc est réécrit en utilisant O_DIRECT|O_DSYNC, alors XFS, au lieu de vider le cache, exécutera la commande d'écriture FUA si le périphérique le prend en charge. Je m'en suis assuré en utilisant l'outil blktrace dans le système Linux 5.4/Ubuntu 20.04. Cette approche devrait être plus efficace, car elle écrit un minimum de données sur le disque et utilise une seule opération au lieu de deux (écriture et vidage du cache). J'ai trouvé un lien vers un patch un noyau de 2018 dans lequel ce mécanisme a été implémenté. Il y a une discussion concernant l'utilisation de cette optimisation dans d'autres systèmes de fichiers, mais, autant que je sache, XFS est le seul système de fichiers qui le prend en charge pour le moment.

La fonction sync_file_range()

Dans Linux, il existe un appel système sync_file_range(), qui permet de vider vers le disque seulement une partie du fichier, au lieu de tout le fichier. Cet appel initie une vidange asynchrone des données et n'attend pas sa fin. Mais dans la documentation de sync_file_range() il est dit que cette commande est « très dangereuse ». Son utilisation n'est pas recommandée. Les particularités et dangers sync_file_range() sont très bien décrits dans ce matériau. En particulier, apparemment, cet appel utilise RocksDB pour gérer quand le noyau vide les données « sales » sur le disque. Mais là, pour garantir un stockage persistant des données, il utilise aussi fdatasync(). Il y a le code RocksDB a des commentaires intéressants à ce sujet. Par exemple, il semble que l'appel sync_file_range() lors de l'utilisation de ZFS ne mène pas à une vidange des données sur le disque. L'expérience me dit que le code utilisé rarement contient peut-être des erreurs. Je conseillerais donc de ne pas utiliser cet appel système sans nécessité absolue.

Appels système qui aident à garantir un stockage persistant des données

Je suis arrivé à la conclusion qu'il existe trois approches pour effectuer des opérations d'entrée/sortie qui garantissent un stockage durable des données. Chacune d'elles nécessite d'appeler une fonction fsync() dans le répertoire où le fichier a été créé. Voici ces approches :

  1. L'appel de la fonction fdatasync() ou fsync() après la fonction write() (il est préférable d'utiliser fdatasync()).
  2. Le travail avec un descripteur de fichier ouvert avec le drapeau O_DSYNC ou O_SYNC (idéalement — avec le drapeau O_DSYNC).
  3. L'utilisation de la commande pwritev2() avec le drapeau RWF_DSYNC ou RWF_SYNC (préférable — avec le drapeau RWF_DSYNC).

Notes sur les performances

Je n'ai pas effectué de mesurages précis des performances des différents mécanismes que j'ai examinés. Les différences de vitesse que j'ai remarquées sont assez peu significatives. Cela signifie que je peux me tromper, et que d'autres conditions pourraient donner des résultats différents. D'abord, je parlerai de ce qui a le plus d'impact sur les performances, puis de ce qui en a moins.

  1. La réécriture des données d'un fichier est plus rapide que l'ajout de données à un fichier (le gain de performance peut aller de 2 à 100 %). L'ajout de données à un fichier nécessite des modifications supplémentaires des métadonnées du fichier, même après l'appel système fallocate(), mais l'ampleur de cet effet peut varier. Je recommande, pour garantir la meilleure performance, d'appeler fallocate() pour réserver l'espace nécessaire. Ensuite, cet espace doit être explicitement rempli de zéros et appeler fsync(). Cela permet aux blocs appropriés dans le système de fichiers d'être marqués comme "réservés", plutôt que comme "non réservés". Cela donne une légère amélioration de la performance (environ 2 %). De plus, sur certains disques, la première opération d'accès à un bloc peut prendre plus de temps que les autres. Cela signifie que le remplissage de l'espace avec des zéros peut entraîner une amélioration significative des performances (environ 100 %). En particulier, cela peut se produire avec les disques AWS EBS (ce sont des données non officielles, je n'ai pas pu les confirmer). Il en va de même pour les stockages GCP Persistent Disk (et cela, c'est déjà une information officielle, confirmée par des tests). D'autres spécialistes ont fait des observations similaires, concernant différents disques.
  2. Moins il y a d'appels système, plus la performance est élevée (le gain peut être d'environ 5 %). Il semble que l'appel open() avec le drapeau O_DSYNC ou l'appel pwritev2() avec le drapeau RWF_SYNC appel plus rapide fdatasync(). Je soupçonne que cela est dû au fait qu'avec cette approche, le nombre d'appels système nécessaires pour résoudre la même tâche est réduit (un appel au lieu de deux). Mais la différence de performance est très faible, donc vous pouvez tout à fait l'ignorer et utiliser dans l'application ce qui ne compliquera pas sa logique.

Si le sujet du stockage durable des données vous intéresse, voici quelques ressources utiles :

  • Méthodes d'accès I/O — un aperçu des principes de base des mécanismes d'entrée/sortie.
  • Assurer que les données atteignent le disque — une discussion sur ce qui arrive aux données entre l'application et le disque.
  • Quand devez-vous fsync le répertoire contenant — une réponse à la question de quand il est nécessaire d'appliquer fsync() pour les répertoires. En résumé, il est conseillé de le faire lors de la création d'un nouveau fichier, la raison étant qu'il peut y avoir plusieurs liens vers le même fichier sous Linux.
  • SQL Server sur Linux : internes de FUA — ici, vous trouverez une description de la manière dont le stockage durable des données est implémenté dans SQL Server sur la plateforme Linux. Il y a quelques comparaisons intéressantes entre les appels système de Windows et de Linux. Je suis presque sûr que c'est grâce à ce document que j'ai appris l'optimisation FUA d'XFS.

Avez-vous déjà perdu des données que vous pensiez être en sécurité sur le disque ?

Stockage durable des données et API de fichiers Linux

Stockage durable des données et API de fichiers Linux

Source : habr.com

Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS 🔥 Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster