Armazenamento de dados durável e APIs de arquivos do Linux

Eu, pesquisando a estabilidade do armazenamento de dados em sistemas em nuvem, resolvi me testar, para ter certeza de que entendi o básico. EU começou lendo a especificação NVMe para entender quais garantias em relação à persistência de dados (ou seja, garantias de que os dados estarão disponíveis após uma falha do sistema) nos fornecem os discos NMVe. Tirei as seguintes conclusões principais: você precisa considerar os dados danificados desde o momento em que o comando de gravação de dados é dado e até o momento em que são gravados no meio de armazenamento. No entanto, na maioria dos programas, as chamadas do sistema são usadas com bastante segurança para gravar dados.

Neste artigo, exploro os mecanismos de persistência fornecidos pelas APIs de arquivo do Linux. Parece que tudo deve ser simples aqui: o programa chama o comando write(), e após a conclusão da operação deste comando, os dados serão armazenados com segurança no disco. Mas write() copia apenas os dados do aplicativo para o cache do kernel localizado na RAM. Para forçar o sistema a gravar dados no disco, alguns mecanismos adicionais devem ser usados.

Armazenamento de dados durável e APIs de arquivos do Linux

Em geral, este material é um conjunto de notas relativas ao que aprendi sobre um tema de meu interesse. Se falarmos brevemente sobre o mais importante, descobrimos que, para organizar o armazenamento sustentável de dados, você precisa usar o comando fdatasync() ou abrir arquivos com bandeira O_DSYNC. Se você estiver interessado em aprender mais sobre o que acontece com os dados no caminho do código para o disco, dê uma olhada em este artigo.

Recursos do uso da função write()

Chamada do sistema write() definido na norma IEEE POSIX como uma tentativa de gravar dados em um descritor de arquivo. Após a conclusão bem-sucedida do trabalho write() operações de leitura de dados devem retornar exatamente os bytes que foram escritos anteriormente, mesmo que os dados estejam sendo acessados ​​de outros processos ou threads (aqui seção correspondente do padrão POSIX). é, na seção sobre a interação de encadeamentos com operações normais de arquivo, há uma nota que diz que se dois encadeamentos chamarem essas funções, cada chamada deverá ver todas as consequências indicadas às quais a execução da outra chamada leva ou não vejo nenhuma consequência. Isso leva à conclusão de que todas as operações de E/S de arquivo devem manter um bloqueio no recurso que está sendo trabalhado.

Isso significa que a operação write() é atômico? Do ponto de vista técnico, sim. As operações de leitura de dados devem retornar tudo ou nada do que foi escrito com write(). Mas a operação write(), de acordo com a norma, não tem que acabar, tendo anotado tudo o que lhe foi pedido para anotar. É permitido escrever apenas parte dos dados. Por exemplo, podemos ter dois fluxos, cada um anexando 1024 bytes a um arquivo descrito pelo mesmo descritor de arquivo. Do ponto de vista do padrão, o resultado será aceitável quando cada uma das operações de gravação puder anexar apenas um byte ao arquivo. Essas operações permanecerão atômicas, mas depois de concluídas, os dados gravados no arquivo serão misturados. aqui é discussão muito interessante sobre este tópico no Stack Overflow.

Funções fsync() e fdatasync()

A maneira mais fácil de liberar dados para o disco é chamar a função fsync (). Esta função solicita ao sistema operacional que mova todos os blocos modificados do cache para o disco. Isso inclui todos os metadados do arquivo (hora de acesso, hora de modificação do arquivo e assim por diante). Acredito que esses metadados raramente sejam necessários, portanto, se você sabe que não é importante para você, pode usar a função fdatasync(). Em Socorro em fdatasync() diz que durante a operação desta função, tal quantidade de metadados é salva no disco, o que é "necessário para a execução correta das seguintes operações de leitura de dados". E é exatamente com isso que a maioria dos aplicativos se preocupa.

Um problema que pode surgir aqui é que esses mecanismos não garantem que o arquivo possa ser encontrado após uma possível falha. Em particular, quando um novo arquivo é criado, deve-se chamar fsync() para o diretório que o contém. Caso contrário, após uma falha, pode acontecer que este arquivo não exista. A razão para isso é que no UNIX, devido ao uso de hard links, um arquivo pode existir em vários diretórios. Portanto, ao chamar fsync() não há como um arquivo saber quais dados do diretório também devem ser liberados para o disco (aqui você pode ler mais sobre isso). Parece que o sistema de arquivos ext4 é capaz de automaticamente aplicar fsync() para diretórios contendo os arquivos correspondentes, mas isso pode não ser o caso com outros sistemas de arquivos.

Esse mecanismo pode ser implementado de maneira diferente em diferentes sistemas de arquivos. eu usei traço preto para saber quais operações de disco são usadas nos sistemas de arquivos ext4 e XFS. Ambos emitem os comandos usuais de gravação no disco para o conteúdo dos arquivos e do diário do sistema de arquivos, liberam o cache e saem executando uma gravação FUA (Force Unit Access, gravando dados diretamente no disco, ignorando o cache) no diário. Eles provavelmente fazem exatamente isso para confirmar o fato da transação. Em unidades que não oferecem suporte a FUA, isso causa duas liberações de cache. Meus experimentos mostraram que fdatasync() um pouco mais rápido fsync(). Utilitário blktrace indica que fdatasync() geralmente grava menos dados no disco (em ext4 fsync() escreve 20 KiB, e fdatasync() - 16 KiB). Além disso, descobri que o XFS é um pouco mais rápido que o ext4. E aqui com a ajuda blktrace foi capaz de descobrir que fdatasync() libera menos dados para o disco (4 KiB em XFS).

Situações ambíguas ao usar fsync()

Posso pensar em três situações ambíguas sobre fsync()que encontrei na prática.

O primeiro incidente desse tipo ocorreu em 2008. Naquela época, a interface do Firefox 3 “congelava” se um grande número de arquivos estivesse sendo gravado no disco. O problema era que a implementação da interface utilizava um banco de dados SQLite para armazenar informações sobre seu estado. Após cada alteração ocorrida na interface, a função era chamada fsync(), que deu boas garantias de armazenamento de dados estável. No então usado sistema de arquivos ext3, a função fsync() liberou para o disco todas as páginas "sujas" do sistema, e não apenas aquelas relacionadas ao arquivo correspondente. Isso significava que clicar em um botão no Firefox poderia fazer com que megabytes de dados fossem gravados em um disco magnético, o que poderia levar muitos segundos. A solução para o problema, tanto quanto eu entendi de ele material, era mover o trabalho com o banco de dados para tarefas assíncronas em segundo plano. Isso significa que o Firefox costumava implementar requisitos de persistência de armazenamento mais rigorosos do que o realmente necessário, e os recursos do sistema de arquivos ext3 apenas exacerbavam esse problema.

O segundo problema aconteceu em 2009. Então, após uma falha no sistema, os usuários do novo sistema de arquivos ext4 descobriram que muitos arquivos recém-criados tinham comprimento zero, mas isso não acontecia com o antigo sistema de arquivos ext3. No parágrafo anterior, falei sobre como o ext3 despejava muitos dados no disco, o que tornava as coisas muito lentas. fsync(). Para melhorar a situação, o ext4 libera apenas as páginas "sujas" que são relevantes para um arquivo específico. E os dados de outros arquivos permanecem na memória por muito mais tempo do que com o ext3. Isso foi feito para melhorar o desempenho (por padrão, os dados ficam nesse estado por 30 segundos, você pode configurar isso usando sujo_expire_centisecs; aqui você pode encontrar mais informações sobre isso). Isso significa que uma grande quantidade de dados pode ser irremediavelmente perdida após uma falha. A solução para este problema é usar fsync() em aplicativos que precisam fornecer armazenamento de dados estável e protegê-los o máximo possível das consequências de falhas. Função fsync() funciona muito mais eficientemente com ext4 do que com ext3. A desvantagem dessa abordagem é que seu uso, como antes, retarda algumas operações, como a instalação de programas. Veja detalhes sobre isso aqui и aqui.

O terceiro problema relativo fsync(), originado em 2018. Então, dentro da estrutura do projeto PostgreSQL, descobriu-se que se a função fsync() encontra um erro, ele marca as páginas "sujas" como "limpas". Como resultado, as seguintes chamadas fsync() não faça nada com essas páginas. Por causa disso, as páginas modificadas são armazenadas na memória e nunca gravadas no disco. Isso é um verdadeiro desastre, porque o aplicativo pensará que alguns dados foram gravados no disco, mas na verdade não será. Tais falhas fsync() são raros, a aplicação em tais situações não pode fazer quase nada para combater o problema. Hoje em dia, quando isso acontece, o PostgreSQL e outros aplicativos falham. é, no artigo "Os aplicativos podem se recuperar de falhas de fsync?", esse problema é explorado em detalhes. Atualmente, a melhor solução para esse problema é usar o Direct I/O com o sinalizador O_SYNC ou com uma bandeira O_DSYNC. Com essa abordagem, o sistema relatará erros que podem ocorrer ao executar operações específicas de gravação de dados, mas essa abordagem exige que o próprio aplicativo gerencie os buffers. Leia mais sobre isso aqui и aqui.

Abrindo arquivos usando os sinalizadores O_SYNC e O_DSYNC

Voltemos à discussão dos mecanismos do Linux que fornecem armazenamento persistente de dados. Ou seja, estamos falando sobre o uso da bandeira O_SYNC ou bandeira O_DSYNC ao abrir arquivos usando a chamada do sistema abrir(). Com essa abordagem, cada operação de gravação de dados é executada como se após cada comando write() o sistema recebe, respectivamente, comandos fsync() и fdatasync(). Em Especificações POSIX isso é chamado de "Conclusão da integridade do arquivo de E/S sincronizada" e "Conclusão da integridade dos dados". A principal vantagem dessa abordagem é que apenas uma chamada de sistema precisa ser executada para garantir a integridade dos dados, e não duas (por exemplo - write() и fdatasync()). A principal desvantagem dessa abordagem é que todas as operações de gravação usando o descritor de arquivo correspondente serão sincronizadas, o que pode limitar a capacidade de estruturar o código do aplicativo.

Usando Direct I/O com o sinalizador O_DIRECT

Chamada do sistema open() apoia a bandeira O_DIRECT, que foi projetado para ignorar o cache do sistema operacional, realizar operações de E/S, interagindo diretamente com o disco. Isso, em muitos casos, significa que os comandos de gravação emitidos pelo programa serão traduzidos diretamente em comandos destinados a trabalhar com o disco. Mas, em geral, esse mecanismo não substitui as funções fsync() ou fdatasync(). O fato é que o próprio disco pode atraso ou cache comandos apropriados para escrever dados. E, pior ainda, em alguns casos especiais, as operações de I/O realizadas ao usar o flag O_DIRECT, transmissão em operações tamponadas tradicionais. A maneira mais fácil de resolver esse problema é usar o sinalizador para abrir arquivos O_DSYNC, o que significa que cada operação de gravação será seguida por uma chamada fdatasync().

Descobriu-se que o sistema de arquivos XFS adicionou recentemente um "caminho rápido" para O_DIRECT|O_DSYNC-registros de dados. Se o bloco for sobrescrito usando O_DIRECT|O_DSYNC, o XFS, em vez de liberar o cache, executará o comando de gravação FUA se o dispositivo for compatível. Eu verifiquei isso usando o utilitário blktrace em um sistema Linux 5.4/Ubuntu 20.04. Essa abordagem deve ser mais eficiente, pois grava a quantidade mínima de dados no disco e usa uma operação, não duas (gravar e liberar o cache). Eu encontrei um link para remendo 2018 que implementa esse mecanismo. Há alguma discussão sobre a aplicação dessa otimização a outros sistemas de arquivos, mas até onde eu sei, o XFS é o único sistema de arquivos que suporta isso até agora.

função sync_file_range()

Linux tem uma chamada de sistema intervalo_de_arquivo_sincronizado(), que permite liberar apenas parte do arquivo para o disco, não o arquivo inteiro. Essa chamada inicia uma liberação assíncrona e não espera que ela seja concluída. Mas na referência a sync_file_range() este comando é considerado "muito perigoso". Não é recomendado usá-lo. Características e perigos sync_file_range() muito bem descrito em esta material. Em particular, esta chamada parece usar o RocksDB para controlar quando o kernel libera dados "sujos" no disco. Mas, ao mesmo tempo, para garantir um armazenamento de dados estável, também é usado fdatasync(). Em code RocksDB tem alguns comentários interessantes sobre este tópico. Por exemplo, parece que a chamada sync_file_range() ao usar o ZFS, os dados não são liberados no disco. A experiência me diz que o código raramente usado pode conter bugs. Portanto, eu desaconselharia o uso dessa chamada de sistema, a menos que seja absolutamente necessário.

Chamadas do sistema para ajudar a garantir a persistência dos dados

Cheguei à conclusão de que existem três abordagens que podem ser usadas para executar operações de E/S persistentes. Todos eles requerem uma chamada de função fsync() para o diretório onde o arquivo foi criado. Estas são as abordagens:

  1. Chamada de função fdatasync() ou fsync() depois da função write() (melhor usar fdatasync()).
  2. Trabalhando com um descritor de arquivo aberto com um sinalizador O_DSYNC ou O_SYNC (melhor - com uma bandeira O_DSYNC).
  3. Uso do comando pwritev2() com bandeira RWF_DSYNC ou RWF_SYNC (de preferência com uma bandeira RWF_DSYNC).

Notas de Desempenho

Não medi cuidadosamente o desempenho dos vários mecanismos que investiguei. As diferenças que notei na velocidade de seu trabalho são muito pequenas. Isso significa que posso estar errado e que em outras condições a mesma coisa pode apresentar resultados diferentes. Primeiro, falarei sobre o que afeta mais o desempenho e, em seguida, sobre o que afeta menos o desempenho.

  1. Substituir dados de arquivo é mais rápido do que anexar dados a um arquivo (o ganho de desempenho pode ser de 2 a 100%). Anexar dados a um arquivo requer alterações adicionais nos metadados do arquivo, mesmo após a chamada do sistema fallocate(), mas a magnitude desse efeito pode variar. Recomendo, para melhor desempenho, ligar fallocate() para pré-alocar o espaço necessário. Então este espaço deve ser explicitamente preenchido com zeros e chamado fsync(). Isso fará com que os blocos correspondentes no sistema de arquivos sejam marcados como "alocados" em vez de "não alocados". Isso proporciona uma pequena melhoria de desempenho (cerca de 2%). Além disso, alguns discos podem ter uma operação de acesso ao primeiro bloco mais lenta do que outros. Isso significa que preencher o espaço com zeros pode levar a uma melhoria de desempenho significativa (cerca de 100%). Em particular, isso pode acontecer com discos. AWS EBS (estes são dados não oficiais, não pude confirmá-los). O mesmo vale para o armazenamento. Disco permanente do GCP (e isso já é uma informação oficial, confirmada por testes). Outros especialistas fizeram o mesmo observaçõesrelacionados a diferentes discos.
  2. Quanto menos chamadas de sistema, maior o desempenho (o ganho pode ser de cerca de 5%). Parece uma chamada open() com bandeira O_DSYNC ou chamar pwritev2() com bandeira RWF_SYNC chamada mais rápida fdatasync(). Eu suspeito que o ponto aqui é que, com essa abordagem, o fato de que menos chamadas de sistema precisam ser executadas para resolver a mesma tarefa (uma chamada em vez de duas) desempenha um papel. Mas a diferença de desempenho é muito pequena, então você pode ignorá-la facilmente e usar algo no aplicativo que não complique sua lógica.

Se você estiver interessado no tópico de armazenamento sustentável de dados, aqui estão alguns materiais úteis:

  • Métodos de acesso de E/S — uma visão geral dos fundamentos dos mecanismos de entrada/saída.
  • Garantir que os dados cheguem ao disco - uma história sobre o que acontece com os dados no caminho do aplicativo para o disco.
  • Quando você deve sincronizar o diretório contido - a resposta para a pergunta de quando aplicar fsync() para diretórios. Resumindo, acontece que você precisa fazer isso ao criar um novo arquivo, e o motivo dessa recomendação é que no Linux pode haver muitas referências ao mesmo arquivo.
  • SQL Server no Linux: FUA Internals - aqui está uma descrição de como o armazenamento de dados persistente é implementado no SQL Server na plataforma Linux. Existem algumas comparações interessantes entre chamadas de sistema Windows e Linux aqui. Tenho quase certeza de que foi graças a esse material que aprendi sobre a otimização FUA do XFS.

Você já perdeu dados que achava que estavam armazenados com segurança em disco?

Armazenamento de dados durável e APIs de arquivos do Linux

Armazenamento de dados durável e APIs de arquivos do Linux

Fonte: habr.com