Yo, al investigar la persistencia del almacenamiento de datos en sistemas en la nube, decidí ponerme a prueba y asegurarme de que entiendo los conceptos básicos. Yo para entender cuáles son las garantías relacionadas con el almacenamiento persistente de datos (es decir, las garantías de que los datos estarán disponibles después de una falla del sistema) que nos ofrecen los discos NVMe. Mis principales conclusiones son las siguientes: se debe considerar que los datos están dañados desde el momento en que se da la orden de escritura y hasta que se complete su escritura en el medio de información. Sin embargo, en la mayoría de los programas de escritura de datos, se utilizan tranquilamente llamadas al sistema.
En este material, investigo los mecanismos de almacenamiento persistente de datos que proporcionan las API de archivos de Linux. Parece que todo debería ser simple aquí: el programa llama al comando write(), y después de que este comando finaliza su trabajo, los datos deberían ser almacenados de forma segura en el disco. Pero write() solo copia los datos de la aplicación en la caché del núcleo, que se encuentra en la memoria RAM. Para forzar al sistema a escribir los datos en el disco, se deben usar algunos mecanismos adicionales.
En general, este material es un conjunto de notas sobre lo que aprendí en el tema que me interesa. Si quiero resumir lo más importante, diría que para organizar un almacenamiento persistente de datos se debe utilizar el comando fdatasync() o abrir archivos con la bandera O_DSYNC. Si te interesa conocer en detalle qué sucede con los datos en el camino del código de programa al disco, echa un vistazo a el artículo.
Las características del uso de la función write()
Llamada del sistema write() están definidas en el estándar como un intento de escribir datos en un descriptor de archivo. Después de la finalización exitosa write() de la operación, la lectura de datos debe devolver exactamente esos bytes que fueron escritos previamente, incluso si los datos están siendo accedidos desde otros procesos o hilos ( la sección correspondiente del estándar POSIX). , en la sección dedicada a la interacción de los hilos con operaciones de archivo convencionales, hay una nota que dice que si cada uno de los dos hilos llama a estas funciones, cada llamada debe ver o todas las consecuencias designadas que resultan de la ejecución de la otra llamada, o no ver ninguna consecuencia en absoluto. Esto permite concluir que todas las operaciones de entrada/salida de archivos deben mantener un bloqueo del recurso con el que trabajan.
¿Significa esto que la operación write() es atómica? Desde un punto de vista técnico — sí. Las operaciones de lectura de datos deben devolver ya sea todo, o nada de lo que ha sido escrito mediante write(). Pero la operación write(), de acuerdo con el estándar, no necesariamente debe completar escribiendo todo lo que se le propuso escribir. Se le permite escribir solo una parte de los datos. Por ejemplo, podemos tener dos hilos, cada uno de los cuales adjunta 1024 bytes a un archivo descrito por el mismo descriptor de archivo. Desde el punto de vista del estándar, es aceptable que el resultado de cada operación de escritura solo pueda adjuntar un byte al archivo. Estas operaciones seguirán siendo atómicas, pero después de que se completen, los datos que escribieron en el archivo estarán mezclados. una discusión muy interesante sobre este tema en Stack Overflow.
Las funciones fsync() y fdatasync()
La forma más sencilla de volcar datos en el disco es a través de la llamada a la función . Esta función solicita al sistema operativo que transfiera todos los bloques modificados desde la caché al disco. Esto incluye todos los metadatos del archivo (tiempo de acceso, tiempo de modificación del archivo, etc.). Creo que la necesidad de estos metadatos surge raramente, por lo que, si sabes que no son importantes para ti, puedes utilizar la función fdatasync(). Hay en fdatasync() indica que durante la ejecución de esta función se guarda en disco un volumen de metadatos que es "necesario para el correcto funcionamiento de las siguientes operaciones de lectura de datos". Y esto es precisamente lo que preocupa a la mayoría de las aplicaciones.
Uno de los problemas que puede surgir aquí es que estos mecanismos no garantizan que se pueda encontrar el archivo después de un posible fallo. En particular, al crear un nuevo archivo, es necesario llamar a fsync() para el directorio que lo contiene. De lo contrario, después de un fallo, puede suceder que este archivo no exista. La razón de esto se debe a que en UNIX, debido al uso de enlaces duros, un archivo puede existir en varios directorios. Por lo tanto, al invocar fsync() no hay forma de que un archivo se entere de qué directorio específico también necesita ser volcado en el disco ( se puede leer más sobre esto). Parece que el sistema de archivos ext4 es capaz aplicar fsync() a los directorios que contienen los archivos correspondientes, pero en el caso de otros sistemas de archivos, esto puede no ser así.
Este mecanismo puede ser implementado de diferentes maneras en varios sistemas de archivos. Yo utilicé para averiguar cuáles operaciones de disco se utilizan en los sistemas de archivos ext4 y XFS. Ambos emiten comandos de escritura en disco estándar tanto para el contenido de los archivos como para el registro del sistema de archivos, vacían la caché y finalizan el trabajo ejecutando una escritura FUA (Acceso forzado a la unidad, escritura de datos directamente en el disco, omitiendo la caché) en el registro. Probablemente, hacen esto para confirmar el hecho de que se realizó la operación. En discos que no soportan FUA, esto provoca dos vaciados de caché. Mis experimentos mostraron 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 de blktrace logré averiguar que fdatasync() vuelca menos datos al disco (4 KiB en XFS).
Situaciones ambiguas que surgen al usar fsync()
Puedo recordar tres situaciones ambiguas relacionadas fsync(), con las que me encontré en la práctica.
El primer caso ocurrió en 2008. En ese momento, la interfaz de Firefox 3 "se colgaba" si se realizaba la escritura en disco de una gran cantidad de archivos. El problema residía en que en la implementación de la interfaz se utilizaba una base de datos SQLite para almacenar información sobre su estado. Después de cada cambio en la interfaz, se llamaba a la función fsync(), lo que daba buenas garantías de almacenamiento persistente de datos. En el sistema de archivos ext3 que se utilizaba entonces, la función fsync() reinició el disco con todas las páginas "sucias" en el sistema, y no solo aquellas relacionadas con el archivo correspondiente. Esto significaba que un clic en el botón de Firefox podía iniciar la escritura de megabytes de datos en el disco magnético, lo que podía tardar muchos segundos. La solución al problema, según entendí de el material, consistía en trasladar el trabajo con la base de datos a tareas asíncronas en segundo plano. Esto implica que antes en Firefox se implementaron requisitos más estrictos para la persistencia del almacenamiento de datos de lo que realmente era necesario, y las características del sistema de archivos ext3 solo agravaron este problema.
La segunda discrepancia ocurrió en 2009. Entonces, después de una falla en el sistema, los usuarios del nuevo sistema de archivos ext4 se encontraron con que muchos archivos recién creados tenían una longitud cero, mientras que no ocurrió lo mismo con el sistema de archivos más antiguo ext3. En el párrafo anterior, mencioné que ext3 volcaba demasiados datos al disco, lo que ralentizaba considerablemente el rendimiento fsync(). Para mejorar la situación, en ext4 solo se vuelcan al disco aquellas páginas "sucias" que están relacionadas con un archivo específico. Y los datos de otros archivos permanecen en memoria durante mucho más tiempo que con ext3. Esto se hizo para mejorar el rendimiento (por defecto, los datos permanecen en este estado 30 segundos, se puede ajustar esto con ; , se pueden encontrar materiales adicionales sobre esto). Esto implica que un gran volumen de datos puede perderse permanentemente tras un fallo. La solución a este problema radica en utilizar fsync() en aplicaciones que necesitan garantizar la persistencia del almacenamiento de datos y maximizar su protección contra las consecuencias de fallos. La función fsync() funciona con ext4 mucho más eficazmente que con ext3. La desventaja de este enfoque es que, al igual que antes, ralentiza la realización de algunas operaciones, como la instalación de programas. Para más detalles, consulte y .
El tercer problema relacionado con fsync(), surgió en 2018. Entonces, en el marco del proyecto PostgreSQL, se determinó que si la función fsync() se encuentra con un error, marca las páginas "sucias" como "limpias". Como resultado, las siguientes llamadas fsync() No se hacen nada con páginas como estas. Debido a esto, las páginas modificadas se mantienen en memoria y nunca se escriben en el disco. Esto es una verdadera catástrofe, ya que la aplicación considerará que algunos datos se han escrito en el disco, cuando en realidad no será así. Tales fallas fsync() son raras, y la aplicación en tales situaciones casi no puede hacer nada para combatir el problema. En nuestros días, cuando eso ocurre, PostgreSQL y otras aplicaciones se cierran inesperadamente. , en el material "¿Pueden las aplicaciones recuperarse de fallos de fsync?", este problema se investiga en todos los detalles. Actualmente, la mejor solución a este problema es usar Direct I/O con la bandera O_SYNC o con la bandera O_DSYNC. Con este enfoque, el sistema reportará los errores que puedan surgir al ejecutar operaciones específicas de escritura de datos, pero este enfoque requiere que la aplicación gestione los buffers por sí misma. Lea más sobre esto y .
Apertura de archivos utilizando las banderas O_SYNC y O_DSYNC
Volvemos a discutir los mecanismos de Linux que proporcionan almacenamiento de datos persistente. En particular, se refiere al uso de la bandera O_SYNC o de la bandera O_DSYNC al abrir archivos utilizando la llamada del sistema . Con este enfoque, cada operación de escritura de datos se realiza como si después de cada comando write() se dieran al sistema, respectivamente, comandos fsync() y fdatasync(). Hay esto se llama "Finalización de Integridad de Archivos de Entrada/Salida Sincronizados" y "Finalización de Integridad de Datos". La principal ventaja de este enfoque es que solo se necesita realizar una llamada al sistema para garantizar la integridad de los datos, en lugar de dos (por ejemplo, write() y fdatasync()). La principal desventaja de este enfoque es que todas las operaciones de escritura que utilicen el descriptor de archivo correspondiente estarán sincronizadas, lo que puede limitar las posibilidades de estructuración del código de la aplicación.
El uso de Direct I/O con la bandera O_DIRECT
Llamada del sistema open() soporta la bandera O_DIRECT, que está diseñada para realizar operaciones de entrada/salida, interactuando directamente con el disco, omitiendo la caché del sistema operativo. Esto, en muchos casos, significa que los comandos de escritura emitidos por el programa se traducirán directamente en comandos dirigidos 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 los comandos correspondientes para la escritura de datos. Y, lo que es peor, en algunos casos especiales, las operaciones de entrada y salida realizadas utilizando la bandera O_DIRECT, a operaciones de escritura en búfer tradicionales. La forma más fácil de resolver este problema es abrir archivos también con la bandera O_DSYNC, lo que significa que cada operación de escritura irá seguida de una llamada fdatasync().
Resulta que en el sistema de archivos XFS se ha añadido recientemente un "atajo" para O_DIRECT|O_DSYNC-la escritura de datos. Si se vuelve a escribir un bloque utilizando O_DIRECT|O_DSYNC, entonces XFS, en lugar de vaciar el caché, ejecutará el comando FUA de escritura si el dispositivo lo soporta. Me di cuenta de esto al usar la herramienta blktrace en Linux 5.4/Ubuntu 20.04. Este enfoque debería ser más eficiente, ya que al utilizarlo se escribe en el disco una cantidad mínima de datos y se aplica una sola operación, no dos (escritura y vaciado del caché). Encontré un enlace a un núcleo de 2018 en el que se implementó este mecanismo. Allí hay una discusión sobre la aplicación de esta optimización en otros sistemas de archivos, pero, por lo que sé, XFS es hasta ahora el único sistema de archivos que lo soporta.
La función sync_file_range()
En Linux hay una llamada al sistema , que permite vaciar en disco solo una parte del archivo, no todo el archivo. Esta llamada inicia un vaciado asíncrono de datos y no espera a que se complete. Pero en la documentación de sync_file_range() se menciona que este comando es "muy peligroso". No se recomienda su uso. Las características y peligros sync_file_range() están muy bien descritos en el material. En particular, parece que esta llamada utiliza RocksDB para gestionar cuándo el núcleo vacía los datos "sucios" en el disco. Pero al mismo tiempo, allí, para garantizar el almacenamiento persistente de datos, se emplea también fdatasync(). Hay RocksDB tiene comentarios interesantes sobre este tema. Por ejemplo, parece que la llamada sync_file_range() al usar ZFS no provoca el vaciado de datos en el disco. La experiencia me indica que el código que se utiliza raramente puede contener errores. Por lo tanto, recomendaría no utilizar esta llamada al sistema sin necesidad extrema.
Llamadas al sistema que ayudan a garantizar el almacenamiento persistente de datos.
He llegado a la conclusión de que, para realizar operaciones de entrada/salida que garanticen un almacenamiento de datos sostenible, se pueden utilizar tres enfoques. Todos ellos requieren invocar la función fsync() para el directorio donde se creó el archivo. Estos son los enfoques:
- Invocar la función
fdatasync()ofsync()después de la funciónwrite()(es mejor usarfdatasync()). - Trabajar con el descriptor de archivo abierto con la bandera
O_DSYNCoO_SYNC(es mejor— con la banderaO_DSYNC). - Usar el comando
pwritev2()con la banderaRWF_DSYNCoRWF_SYNC(es preferible— con la banderaRWF_DSYNC).
Notas sobre rendimiento
No he realizado una medición minuciosa del rendimiento de los diversos mecanismos que he investigado. Las diferencias en la velocidad que he notado son bastante pequeñas. Esto significa que podría estar equivocado y que, bajo otras condiciones, lo mismo podría mostrar resultados diferentes. Primero hablaré sobre lo que afecta más al rendimiento y luego sobre lo que influye menos en el rendimiento.
- La sobrescritura de datos del archivo es más rápida que la anexión de datos al archivo (la ganancia en rendimiento puede ser del 2 al 100%). Anexar datos a un archivo requiere hacer 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 asegurar el mejor rendimiento, invocarfallocate()para la asignación previa del espacio necesario. Luego, este espacio debe llenarse explícitamente con ceros y se debe invocarfsync(). Gracias a esto, los bloques correspondientes en el sistema de archivos serán marcados como "asignados", no como "no asignados". Esto proporciona una pequeña (alrededor del 2%) mejora del rendimiento. Además, en algunos discos, la primera operación de acceso a un bloque puede realizarse más lentamente que las demás. Esto significa que llenar el espacio con ceros puede conducir a una mejora significativa (alrededor del 100%) en el rendimiento. En particular, esto puede ocurrir con los discos (estos son datos no oficiales, no pude confirmarlos). Lo mismo se aplica a los almacenes (y esta es ya información oficial, corroborada por pruebas). Otros expertos han hecho observaciones similares Cuantos menos llamados al sistema, mayor es el rendimiento (la ganancia puede ser de alrededor del 5%). Parece que invocar - o invocar
open()con la banderaO_DSYNCes más rápido que la llamadapwritev2()con la banderaRWF_SYNCmás rápido de llamarfdatasync()Sospecho que esto se debe a que, con este enfoque, lo que importa es que para resolver una misma tarea se requieren menos llamadas del sistema (una llamada en lugar de dos). Pero la diferencia en el rendimiento es muy pequeña, por lo que puedes no prestarle atención y usar en tu aplicación lo que no compliqué su lógica.
Si te interesa el tema del almacenamiento sostenible de datos, aquí tienes algunos materiales útiles:
- — una revisión de los mecanismos básicos de entrada/salida.
- — un relato sobre lo que sucede con los datos en su camino desde la aplicación hasta el disco.
- — una respuesta a la pregunta de cuándo aplicar
fsync()para carpetas. Si lo resumimos en pocas palabras, se debe hacer al crear un nuevo archivo, y la razón de esta recomendación es que en Linux puede haber muchos enlaces a un mismo archivo. - — aquí se describe cómo se implementa el almacenamiento sostenible de datos en SQL Server en la plataforma Linux. Se presentan algunas comparaciones interesantes entre las llamadas del sistema de Windows y Linux. Estoy casi seguro de que gracias a este material supe sobre la optimización FUA en XFS.
¿Has perdido datos que pensabas que estaban guardados de manera segura en el disco?
Fuente: habr.com
