Almacenamiento de datos duradero y API de archivos de Linux

Yo, investigando la estabilidad del almacenamiento de datos en los sistemas en la nube, decidí ponerme a prueba para asegurarme de que entiendo las cosas básicas. I comenzó leyendo la especificación NVMe para comprender qué garantías con respecto a la persistencia de datos (es decir, garantías de que los datos estarán disponibles después de una falla del sistema) nos dan los discos NMVe. Llegué a las siguientes conclusiones principales: debe considerar los datos dañados desde el momento en que se da el comando de escritura de datos y hasta el momento en que se escriben en el medio de almacenamiento. Sin embargo, en la mayoría de los programas, las llamadas al sistema se usan con bastante seguridad para escribir datos.

En este artículo, exploro los mecanismos de persistencia proporcionados por las API de archivos de Linux. Parece que todo debería ser simple aquí: el programa llama al comando write(), y una vez completada la operación de este comando, los datos se almacenarán de forma segura en el disco. Pero write() solo copia los datos de la aplicación en el caché del kernel ubicado en la RAM. Para obligar al sistema a escribir datos en el disco, se deben utilizar algunos mecanismos adicionales.

Almacenamiento de datos duradero y API de archivos de Linux

En general, este material es un conjunto de notas relacionadas con lo que he aprendido sobre un tema de mi interés. Si hablamos muy brevemente sobre lo más importante, resulta que para organizar un almacenamiento de datos sostenible, debe usar el comando fdatasync() o abrir archivos con bandera O_DSYNC. Si está interesado en obtener más información sobre lo que sucede con los datos en el camino desde el código hasta el disco, eche un vistazo a este artículo

Características del uso de la función write()

Llamada al sistema write() definido en la norma IEEEPOSIX como un intento de escribir datos en un descriptor de archivo. Después de completar con éxito el trabajo write() Las operaciones de lectura de datos deben devolver exactamente los bytes que se escribieron previamente, incluso si se accede a los datos desde otros procesos o subprocesos (aquí sección correspondiente del estándar POSIX). es, en la sección sobre la interacción de subprocesos con operaciones normales de archivos, hay una nota que dice que si dos subprocesos llaman a estas funciones, entonces cada llamada debe ver todas las consecuencias indicadas a las que conduce la ejecución de la otra llamada, o No veo en absoluto ninguna consecuencia. Esto lleva a la conclusión de que todas las operaciones de E/S de archivos deben mantener un bloqueo en el recurso en el que se está trabajando.

¿Significa esto que la operación write() es atómico? Desde un punto de vista técnico, sí. Las operaciones de lectura de datos deben devolver todo o nada de lo que se escribió con write(). Pero la operación write(), de acuerdo con la norma, no debe terminar, habiendo escrito todo lo que se le pidió que escribiera. Se permite escribir sólo una parte de los datos. Por ejemplo, podríamos tener dos flujos, cada uno agregando 1024 bytes a un archivo descrito por el mismo descriptor de archivo. Desde el punto de vista del estándar, el resultado será aceptable cuando cada una de las operaciones de escritura pueda agregar solo un byte al archivo. Estas operaciones seguirán siendo atómicas, pero una vez completadas, los datos que escriben en el archivo se mezclarán. aquí está discusión muy interesante sobre este tema en Stack Overflow.

funciones fsync() y fdatasync()

La forma más sencilla de vaciar datos en el disco es llamar a la función fsync (). Esta función le pide al sistema operativo que mueva todos los bloques modificados del caché al disco. Esto incluye todos los metadatos del archivo (hora de acceso, hora de modificación del archivo, etc.). Creo que estos metadatos rara vez se necesitan, por lo que si sabe que no es importante para usted, puede usar la función fdatasync(). En ayuda en fdatasync() dice que durante el funcionamiento de esta función, se guarda en el disco tal cantidad de metadatos, que es "necesaria para la correcta ejecución de las siguientes operaciones de lectura de datos". Y esto es exactamente lo que le importa a la mayoría de las aplicaciones.

Un problema que puede surgir aquí es que estos mecanismos no garantizan que el archivo se pueda encontrar después de una posible falla. En particular, cuando se crea un nuevo archivo, se debe llamar fsync() para el directorio que lo contiene. De lo contrario, después de un bloqueo, puede resultar que este archivo no exista. La razón de esto es que bajo UNIX, debido al uso de enlaces duros, un archivo puede existir en varios directorios. Por lo tanto, al llamar fsync() no hay forma de que un archivo sepa qué datos de directorio también deben vaciarse en el disco (aquí puedes leer más sobre esto). Parece que el sistema de archivos ext4 es capaz de automáticamente применять fsync() a directorios que contienen los archivos correspondientes, pero esto puede no ser el caso con otros sistemas de archivos.

Este mecanismo se puede implementar de manera diferente en diferentes sistemas de archivos. solía trazo negro para saber qué operaciones de disco se utilizan en los sistemas de archivos ext4 y XFS. Ambos emiten los comandos de escritura habituales en el disco tanto para el contenido de los archivos como para el diario del sistema de archivos, vacían el caché y salen realizando una escritura FUA (Forzar acceso a la unidad, escribiendo datos directamente en el disco, sin pasar por el caché) en el diario. Probablemente hagan precisamente eso para confirmar el hecho de la transacción. En las unidades que no admiten FUA, esto provoca dos vaciados de caché. Mis experimentos han demostrado que fdatasync() un poco más rápido fsync(). Utilidad blktrace indica que fdatasync() generalmente escribe menos datos en el disco (en ext4 fsync() escribe 20 KiB, y fdatasync() - 16 KiB). Además, descubrí que XFS es un poco más rápido que ext4. Y aquí con la ayuda blktrace fue capaz de averiguar que fdatasync() descarga menos datos en el disco (4 KiB en XFS).

Situaciones ambiguas al usar fsync()

Puedo pensar en tres situaciones ambiguas relacionadas con fsync()que me he encontrado en la práctica.

El primer incidente de este tipo ocurrió en 2008. En ese momento, la interfaz de Firefox 3 se "congelaba" si se escribía una gran cantidad de archivos en el disco. El problema fue que la implementación de la interfaz utilizó una base de datos SQLite para almacenar información sobre su estado. Después de cada cambio que ocurría en la interfaz, se llamaba a la función fsync(), que dio buenas garantías de almacenamiento de datos estable. En el sistema de archivos ext3 utilizado entonces, la función fsync() vaciaba en el disco todas las páginas "sucias" del sistema, y ​​no solo las que estaban relacionadas con el archivo correspondiente. Esto significaba que hacer clic en un botón en Firefox podía hacer que se escribieran megabytes de datos en un disco magnético, lo que podía llevar varios segundos. La solución al problema, por lo que entendí de lo material, fue mover el trabajo con la base de datos a tareas asíncronas en segundo plano. Esto significa que Firefox solía implementar requisitos de persistencia de almacenamiento más estrictos de lo que realmente era necesario, y las funciones del sistema de archivos ext3 solo exacerbaron este problema.

El segundo problema ocurrió en 2009. Luego, después de un bloqueo del sistema, los usuarios del nuevo sistema de archivos ext4 descubrieron que muchos archivos recién creados tenían una longitud cero, pero esto no sucedió con el antiguo sistema de archivos ext3. En el párrafo anterior, hablé sobre cómo ext3 descargó demasiados datos en el disco, lo que ralentizó mucho las cosas. fsync(). Para mejorar la situación, ext4 vacía solo aquellas páginas "sucias" que son relevantes para un archivo en particular. Y los datos de otros archivos permanecen en la memoria mucho más tiempo que con ext3. Esto se hizo para mejorar el rendimiento (de forma predeterminada, los datos permanecen en este estado durante 30 segundos, puede configurar esto usando sucio_expire_centisecs; aquí puede encontrar más información al respecto). Esto significa que una gran cantidad de datos pueden perderse irremediablemente después de un bloqueo. La solución a este problema es utilizar fsync() en aplicaciones que necesitan proporcionar un almacenamiento de datos estable y protegerlos al máximo de las consecuencias de los fallos. Función fsync() funciona mucho más eficientemente con ext4 que con ext3. La desventaja de este enfoque es que su uso, como antes, ralentiza algunas operaciones, como la instalación de programas. Ver detalles sobre esto aquí и aquí.

El tercer problema en cuanto a fsync(), se originó en 2018. Luego, en el marco del proyecto PostgreSQL, se descubrió que si la función fsync() encuentra un error, marca las páginas "sucias" como "limpias". Como resultado, las siguientes llamadas fsync() no hacer nada con tales páginas. Debido a esto, las páginas modificadas se almacenan en la memoria y nunca se escriben en el disco. Esto es un verdadero desastre, porque la aplicación pensará que algunos datos están escritos en el disco, pero en realidad no será así. Tales fallas fsync() son raros, la aplicación en tales situaciones no puede hacer casi nada para combatir el problema. En estos días, cuando esto sucede, PostgreSQL y otras aplicaciones fallan. es, en el artículo "¿Pueden las aplicaciones recuperarse de fallas de fsync?", este problema se explora en detalle. Actualmente, la mejor solución a este problema es utilizar E/S directa con el indicador O_SYNC o con una bandera O_DSYNC. Con este enfoque, el sistema informará los errores que pueden ocurrir al realizar operaciones específicas de escritura de datos, pero este enfoque requiere que la aplicación administre los búferes por sí misma. Leer más al respecto aquí и aquí.

Abrir archivos usando las banderas O_SYNC y O_DSYNC

Volvamos a la discusión de los mecanismos de Linux que proporcionan almacenamiento de datos persistente. Es decir, estamos hablando del uso de la bandera O_SYNC o bandera O_DSYNC al abrir archivos usando una llamada al sistema open(). Con este enfoque, cada operación de escritura de datos se realiza como si después de cada comando write() el sistema recibe, respectivamente, comandos fsync() и fdatasync(). En Especificaciones POSIX esto se denomina "Finalización de integridad de archivo de E/S sincronizada" y "Finalización de integridad de datos". La principal ventaja de este enfoque es que solo se necesita ejecutar una llamada al sistema para garantizar la integridad de los datos, y no dos (por ejemplo: write() и fdatasync()). La principal desventaja de este enfoque es que todas las operaciones de escritura que utilizan el descriptor de archivo correspondiente se sincronizarán, lo que puede limitar la capacidad de estructurar el código de la aplicación.

Uso de E/S directa con el indicador O_DIRECT

Llamada al sistema open() apoya la bandera O_DIRECT, que está diseñado para omitir el caché del sistema operativo, realizar operaciones de E/S, interactuando directamente con el disco. Esto, en muchos casos, significa que los comandos de escritura emitidos por el programa se traducirán directamente en comandos destinados a trabajar con el disco. Pero, en general, este mecanismo no reemplaza las funciones fsync() o fdatasync(). El hecho es que el propio disco puede retraso o caché comandos apropiados para escribir datos. Y, lo que es peor, en algunos casos especiales, las operaciones de E/S realizadas al usar la bandera O_DIRECT, transmisión en operaciones tradicionales almacenadas en búfer. La forma más fácil de resolver este problema es usar la bandera para abrir archivos O_DSYNC, lo que significará que cada operación de escritura será seguida por una llamada fdatasync().

Resultó que el sistema de archivos XFS había agregado recientemente una "ruta rápida" para O_DIRECT|O_DSYNC-registros de datos. Si el bloque se sobrescribe usando O_DIRECT|O_DSYNC, XFS, en lugar de vaciar la memoria caché, ejecutará el comando de escritura FUA si el dispositivo lo admite. Verifiqué esto usando la utilidad blktrace en un sistema Linux 5.4/Ubuntu 20.04. Este enfoque debería ser más eficiente, ya que escribe la cantidad mínima de datos en el disco y utiliza una operación, no dos (escribir y vaciar la memoria caché). Encontré un enlace a parche Kernel 2018 que implementa este mecanismo. Existe cierta discusión sobre la aplicación de esta optimización a otros sistemas de archivos, pero hasta donde yo sé, XFS es el único sistema de archivos que lo admite hasta ahora.

función sync_file_range()

Linux tiene una llamada al sistema sincronizar_archivo_rango(), que le permite vaciar solo una parte del archivo en el disco, no el archivo completo. Esta llamada inicia un vaciado asíncrono y no espera a que se complete. Pero en la referencia a sync_file_range() se dice que este comando es "muy peligroso". No se recomienda su uso. caracteristicas y peligros sync_file_range() muy bien descrito en Este material. En particular, esta llamada parece usar RocksDB para controlar cuándo el kernel descarga datos "sucios" en el disco. Pero al mismo tiempo allí, para garantizar un almacenamiento de datos estable, también se utiliza fdatasync(). En código RocksDB tiene algunos comentarios interesantes sobre este tema. Por ejemplo, parece que la llamada sync_file_range() cuando se usa ZFS, no se vacían los datos en el disco. La experiencia me dice que el código que rara vez se usa puede contener errores. Por lo tanto, desaconsejaría el uso de esta llamada al sistema a menos que sea absolutamente necesario.

Llamadas al sistema para ayudar a garantizar la persistencia de los datos

Llegué a la conclusión de que hay tres enfoques que se pueden usar para realizar operaciones de E/S persistentes. Todos requieren una llamada de función. fsync() para el directorio donde se creó el archivo. Estos son los enfoques:

  1. Llamada de función fdatasync() o fsync() después de la función write() (mejor usar fdatasync()).
  2. Trabajar con un descriptor de archivo abierto con una bandera O_DSYNC o O_SYNC (mejor - con una bandera O_DSYNC).
  3. uso de comandos pwritev2() con bandera RWF_DSYNC o RWF_SYNC (preferiblemente con una bandera RWF_DSYNC).

Notas de rendimiento

No medí cuidadosamente el desempeño de los diversos mecanismos que investigué. Las diferencias que noté en la velocidad de su trabajo son muy pequeñas. Esto quiere decir que puedo estar equivocado, y que en otras condiciones lo mismo puede arrojar resultados diferentes. Primero hablaré de lo que afecta más al rendimiento y luego de lo que afecta menos al rendimiento.

  1. Sobrescribir los datos del archivo es más rápido que agregar datos a un archivo (la ganancia de rendimiento puede ser del 2 al 100 %). Adjuntar datos a un archivo requiere cambios adicionales en los metadatos del archivo, incluso después de la llamada al sistema fallocate(), pero la magnitud de este efecto puede variar. Recomiendo, para un mejor rendimiento, llamar fallocate() para preasignar el espacio requerido. Entonces este espacio debe llenarse explícitamente con ceros y llamarse fsync(). Esto hará que los bloques correspondientes en el sistema de archivos se marquen como "asignados" en lugar de "no asignados". Esto proporciona una pequeña mejora en el rendimiento (alrededor del 2%). Además, algunos discos pueden tener una operación de acceso al primer bloque más lenta que otros. Esto significa que llenar el espacio con ceros puede conducir a una mejora significativa del rendimiento (alrededor del 100%). En particular, esto puede suceder con los discos. EBS de AWS (Estos son datos no oficiales, no pude confirmarlos). Lo mismo ocurre con el almacenamiento. Disco persistente de GCP (y esto ya es información oficial, confirmada por pruebas). Otros expertos han hecho lo mismo. observacionesrelacionados con diferentes discos.
  2. Cuantas menos llamadas al sistema, mayor será el rendimiento (la ganancia puede ser de alrededor del 5%). parece una llamada open() con bandera O_DSYNC o llamar pwritev2() con bandera RWF_SYNC llamada más rápida fdatasync(). Sospecho que el punto aquí es que con este enfoque, el hecho de que se deban realizar menos llamadas al sistema para resolver la misma tarea (una llamada en lugar de dos) juega un papel importante. Pero la diferencia de rendimiento es muy pequeña, por lo que puede ignorarla fácilmente y usar algo en la aplicación que no conduzca a la complicación de su lógica.

Si está interesado en el tema del almacenamiento sostenible de datos, aquí hay algunos materiales útiles:

  • Métodos de acceso de E/S — una descripción general de los conceptos básicos de los mecanismos de entrada / salida.
  • Garantizar que los datos lleguen al disco - una historia sobre lo que sucede con los datos en el camino desde la aplicación hasta el disco.
  • ¿Cuándo debería sincronizar el directorio contenedor? - la respuesta a la pregunta de cuándo aplicar fsync() para directorios. En pocas palabras, resulta que debe hacer esto al crear un nuevo archivo, y el motivo de esta recomendación es que en Linux puede haber muchas referencias al mismo archivo.
  • SQL Server en Linux: componentes internos de FUA - aquí hay una descripción de cómo se implementa el almacenamiento persistente de datos en SQL Server en la plataforma Linux. Hay algunas comparaciones interesantes entre las llamadas al sistema Windows y Linux aquí. Estoy casi seguro de que fue gracias a este material que aprendí sobre la optimización FUA de XFS.

¿Alguna vez ha perdido datos que creía que estaban almacenados de forma segura en el disco?

Almacenamiento de datos duradero y API de archivos de Linux

Almacenamiento de datos duradero y API de archivos de Linux

Fuente: habr.com