Eu, ao investigar a sustentabilidade do armazenamento de dados em sistemas de nuvem, decidi me testar e me certificar de que compreendo os conceitos básicos. Eu para entender quais garantias sobre o armazenamento de dados sustentável (ou seja, garantias de que os dados estarão acessíveis após uma falha no sistema) são oferecidas pelos discos NVMe. Eu cheguei às seguintes conclusões principais: os dados devem ser considerados corrompidos a partir do momento em que um comando para gravação de dados é emitido, até o momento em que a gravação é concluída no meio de armazenamento. No entanto, na maioria dos programas de gravação de dados, chamadas de sistema são utilizadas sem grandes preocupações.
Neste material, eu investigarei os mecanismos de armazenamento de dados sustentável fornecidos pelas APIs de arquivos Linux. Parece que tudo deve ser simples: o programa chama o comando write(), e após a conclusão deste comando, os dados devem estar seguros no disco. Mas write() apenas copia os dados do aplicativo para o cache do núcleo, que está localizado na memória RAM. Para forçar o sistema a gravar os dados no disco, é necessário usar alguns mecanismos adicionais.
De modo geral, este material é um conjunto de notas sobre o que aprendi sobre o assunto que me interessa. Se eu resumir brevemente o mais importante, seria que para organizar o armazenamento de dados sustentável, deve-se usar o comando fdatasync() ou abrir arquivos com a flag O_DSYNC. Se você estiver interessado em detalhes sobre o que acontece com os dados ao serem transferidos do código do programa para o disco, dê uma olhada em artigo.
Características do uso da função write()
A chamada de sistema write() é definida no padrão como uma tentativa de gravar dados em um descritor de arquivo. Após a conclusão bem-sucedida da execução, write() as operações de leitura de dados devem retornar exatamente aqueles bytes que foram previamente gravados, fazendo isso mesmo quando os dados são acessados por outros processos ou threads ( seção correspondente do padrão POSIX). , na seção dedicada à interação de fluxos com operações de arquivos comuns, há uma observação que diz que se cada um dos dois fluxos invocar essas funções, cada chamada deve perceber ou todas as consequências designadas resultantes da execução da outra chamada, ou não perceber nenhuma consequência. Isso leva à conclusão de que todas as operações de entrada/saída de arquivos devem manter o bloqueio do recurso com o qual estão trabalhando.
Isso significa que a operação write() é atômica? Do ponto de vista técnico — sim. As operações de leitura de dados devem retornar tudo ou nada do que foi gravado com write(). Mas a operação write(), de acordo com o padrão, não precisa necessariamente completar gravando tudo o que foi solicitado a gravar. É permitido que ela escreva apenas uma 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, é aceitável um resultado em que cada uma das operações de gravação consiga anexar apenas um byte ao arquivo. Essas operações permanecerão atômicas, mas, após sua conclusão, os dados gravados no arquivo estarão embaralhados. uma discussão muito interessante sobre esse assunto no Stack Overflow.
As funções fsync() e fdatasync()
A maneira mais simples de gravar dados no disco é chamando a função . Essa função solicita ao sistema operacional a transferência de 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 a necessidade desses metadados surge raramente, portanto, se você souber que eles não são importantes para você, pode usar a função fdatasync(). No para fdatasync() diz que, ao executar essa função, é realizado o salvamento no disco de uma quantidade de metadados que é 'necessária para a execução correta das próximas operações de leitura de dados'. E isso é exatamente o que preocupa a maioria dos aplicativos.
Um dos problemas que pode surgir aqui é que esses mecanismos não garantem que o arquivo possa ser recuperado após uma possível falha. Em particular, ao criar um novo arquivo, é necessário chamar fsync() para o diretório que o contém. Caso contrário, após uma falha, pode acontecer que esse arquivo não exista. A razão para isso é que, no UNIX, devido ao uso de links rígidos, o arquivo pode existir em vários diretórios. Portanto, ao chamar fsync() não há como o arquivo saber quais diretórios também devem ser gravados no disco ( sobre isso pode-se ler em mais detalhes). Parece que o sistema de arquivos ext4 é capaz de aplicar fsync() a diretórios que contêm arquivos correspondentes, mas no caso de outros sistemas de arquivos, isso pode não ser verdade.
Esse mecanismo pode ser implementado de diferentes maneiras em diferentes sistemas de arquivos. Eu utilizei para descobrir quais operações de disco são utilizadas nos sistemas de arquivos ext4 e XFS. Ambos emitem comandos normais de gravação no disco tanto para o conteúdo dos arquivos quanto para o log do sistema de arquivos, limpam o cache e finalizam as operações executando gravação FUA (Force Unit Access, gravação de dados diretamente no disco, ignorando o cache) no log. Provavelmente, agem dessa forma para confirmar o fato da realização da operação. Em discos que não suportam FUA, isso causa duas limpezas de cache. Meus experimentos mostraram que fdatasync() um pouco mais rápido fsync(). A ferramenta blktrace indica que fdatasync() geralmente grava menos dados no disco (no ext4 fsync() grava 20 KiB, enquanto fdatasync() — 16 KiB). Além disso, descobri que o XFS é um pouco mais rápido que o ext4. E aqui, com o auxílio de blktrace foi possível descobrir que fdatasync() grava menos dados no disco (4 KiB no XFS).
Situações ambíguas que surgem ao usar fsync()
Posso lembrar de três situações ambíguas relacionadas fsync(), com as quais me deparei na prática.
O primeiro desses casos ocorreu em 2008. Naquele ano, a interface do Firefox 3 "congelava" quando uma gravação de muitos arquivos no disco era realizada. O problema era que na implementação da interface, para armazenar informações sobre seu estado, um banco de dados SQLite era utilizado. Após cada alteração na interface, a função fsync(), o que proporcionava boas garantias de armazenamento persistente de dados. No sistema de arquivos ext3 usado na época, a função fsync() gravava no disco todas as páginas "sujas" do sistema, e não apenas aquelas que estavam relacionadas ao arquivo correspondente. Isso significava que um clique no botão no Firefox poderia iniciar a gravação de megabytes de dados no disco magnético, o que poderia levar muitos segundos. A solução para o problema, pelo que entendi de material, estava em mover o trabalho com o banco de dados para tarefas de fundo assíncronas. Isso significa que antes, no Firefox, eram implementados requisitos mais rígidos para a durabilidade do armazenamento de dados do que realmente eram necessários, e as particularidades do sistema de arquivos ext3 apenas agravaram esse problema.
O segundo desvio ocorreu em 2009. Naquele ano, após uma falha do sistema, os usuários do novo sistema de arquivos ext4 enfrentaram o problema de que muitos arquivos recém-criados tinham comprimento zero, enquanto isso não aconteceu com o sistema de arquivos mais antigo ext3. No parágrafo anterior, eu mencionei que o ext3 gravava muitos dados no disco, o que diminuía muito a performance fsync(). Para melhorar a situação, no ext4, apenas as páginas "sujas" que estão relacionadas a um arquivo específico são gravadas no disco. Os dados de outros arquivos permanecem na memória por muito mais tempo do que com a aplicação do ext3. Isso foi feito para melhorar o desempenho (por padrão, os dados permanecem nessa condição por 30 segundos, e isso pode ser ajustado com ; , onde é possível encontrar mais materiais sobre isso). Isso significa que uma grande quantidade de dados pode ser irremediavelmente perdida após uma falha. A solução para esse problema consiste em usar fsync() em aplicações que precisam garantir a durabilidade dos dados e maximizar sua proteção contra as consequências de falhas. A função fsync() funciona de forma muito mais eficaz com ext4 do que com ext3. A desvantagem dessa abordagem é que sua aplicação, como antes, desacelera a execução de algumas operações, como a instalação de programas. Para mais detalhes sobre isso, veja e .
O terceiro problema, envolvendo fsync(), surgiu em 2018. Então, no âmbito do projeto PostgreSQL, foi descoberto que se a função fsync() encontrar um erro, ela marca as páginas "sujas" como "limpas". Como resultado, as chamadas seguintes fsync() não se faz nada com essas páginas. Por causa disso, as páginas modificadas permanecem na memória e nunca são gravadas no disco. Isso é uma verdadeira catástrofe, pois o aplicativo acreditará que alguns dados foram gravados no disco, quando na verdade não foram. Essas falhas fsync() são raras, o aplicativo em tais situações quase nada pode fazer para combater o problema. Hoje em dia, quando isso acontece, o PostgreSQL e outros aplicativos encerram de forma abrupta. , no material "Os Aplicativos Podem se Recuperar de Falhas de fsync?", esse problema é investigado em detalhes. Atualmente, a melhor solução para esse problema é o uso de Direct I/O com a flag O_SYNC ou com a flag O_DSYNC. Com essa abordagem, o sistema reportará erros que podem ocorrer durante a execução de operações de gravação de dados, mas essa abordagem requer que o aplicativo gerencie os buffers por conta própria. Mais detalhes sobre isso podem ser lidos e .
Abertura de arquivos utilizando as flags O_SYNC e O_DSYNC
Vamos voltar à discussão sobre os mecanismos do Linux que garantem a persistência dos dados. Especificamente, isso se refere ao uso da flag O_SYNC ou da flag O_DSYNC ao abrir arquivos utilizando a chamada de sistema . Com essa abordagem, cada operação de gravação de dados é executada como se, após cada comando, write() o sistema recebesse, respectivamente, os comandos fsync() e fdatasync(). No isso é chamado de “Conclusão de Integridade de Arquivo de Entrada/Saída Sincronizada” e “Conclusão de Integridade de Dados”. A principal vantagem dessa abordagem é que, para garantir a integridade dos dados, é necessário executar apenas uma chamada de sistema, em vez de duas (por exemplo — write() e fdatasync()). O principal inconveniente dessa abordagem é que todas as operações de gravação que usam o descritor de arquivo correspondente serão sincronizadas, o que pode limitar a capacidade de estruturar o código do aplicativo.
O uso de Direct I/O com a flag O_DIRECT
A chamada de sistema open() suporta a flag O_DIRECT, que é projetada para realizar operações de entrada/saída, interagindo diretamente com o disco, contornando o cache do sistema operacional. Isso, em muitos casos, significa que os comandos de gravação emitidos pelo programa serão diretamente traduzidos em comandos destinados a operações no disco. Mas, em geral, esse mecanismo não substitui as funções fsync() ou fdatasync(). O fato é que o próprio disco pode as correspondentes instruções de gravação de dados. E, o que é ainda pior, em algumas situações especiais, as operações de entrada-saída realizadas com a flag O_DIRECT, para operações de buffer tradicionais. A solução mais simples para esse problema é usar também a flag ao abrir arquivos O_DSYNC, o que significa que após cada operação de gravação será feita uma chamada fdatasync().
Descobriu-se que no sistema de arquivos XFS foi adicionada recentemente uma "via rápida" para O_DIRECT|O_DSYNC-gravação de dados. Se um bloco é regravado usando O_DIRECT|O_DSYNC, então o XFS, ao invés de limpar o cache, executará o comando FUA de gravação caso o dispositivo o suporte. Verifiquei isso usando a ferramenta blktrace no sistema Linux 5.4/Ubuntu 20.04. Essa abordagem deve ser mais eficiente, pois, ao usá-la, a quantidade mínima de dados é gravada no disco e é aplicada uma operação, e não duas (gravação e limpeza do cache). Encontrei um link para um kernel de 2018, no qual esse mecanismo foi implementado. Há discussões sobre a aplicação dessa otimização em outros sistemas de arquivos, mas, até onde sei, o XFS é o único sistema de arquivos que a suporta.
A função sync_file_range()
No Linux, existe uma chamada de sistema , que permite gravar apenas uma parte do arquivo no disco, e não o arquivo inteiro. Essa chamada inicia uma gravação assíncrona de dados e não espera sua conclusão. Mas, na documentação, sync_file_range() essa instrução é descrita como "muito perigosa". Não é recomendada. As características e perigos sync_file_range() são muito bem descritos no material. Em particular, aparentemente, essa chamada utiliza o RocksDB para gerenciar quando o kernel grava dados "sujos" no disco. Mas, para garantir o armazenamento resiliente de dados, fdatasync(). No Há comentários interessantes sobre isso no RocksDB. Por exemplo, parece que a chamada sync_file_range() ao usar ZFS não leva à gravação de dados no disco. A experiência me diz que o código raramente utilizado pode conter erros. Portanto, eu recomendaria não usar essa chamada de sistema a menos que seja absolutamente necessário.
Chamadas de sistema que ajudam a garantir o armazenamento resiliente de dados
Cheguei à conclusão de que para realizar operações de entrada/saída que garantam o armazenamento confiável de dados, pode-se usar três abordagens. Todas elas requerem a chamada da função fsync() para o diretório onde o arquivo foi criado. Aqui estão essas abordagens:
- Chamada da função
fdatasync()oufsync()após a funçãowrite()(é melhor usarfdatasync()). - Trabalhar com o descritor de arquivo aberto com a flag
O_DSYNCouO_SYNC(melhor — com a flagO_DSYNC). - Uso do comando
pwritev2()com a flagRWF_DSYNCouRWF_SYNC(preferencialmente — com a flagRWF_DSYNC).
Notas sobre desempenho
Não realizei medições detalhadas de desempenho dos vários mecanismos que pesquisei. As diferenças que observei na velocidade de suas operações são bastante pequenas. Isso significa que posso estar errado, e que em outras condições o mesmo pode apresentar resultados diferentes. Primeiro, falarei sobre o que tem maior impacto no desempenho, e depois sobre o que tem um impacto menor.
- A reescrita de dados em um arquivo é mais rápida do que a anexação de dados ao arquivo (o ganho de desempenho pode variar 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 garantir o melhor desempenho, chamarfallocate()para pré-alocar o espaço necessário. Depois, esse espaço deve ser explicitamente preenchido com zeros e chamarfsync(). Isso faz com que os blocos correspondentes no sistema de arquivos sejam marcados como 'alocados', e não como 'não alocados'. Isso oferece uma pequena melhoria no desempenho (cerca de 2%). Além disso, em alguns discos, a primeira operação de acesso a um bloco pode ser mais lenta do que as outras. Isso significa que preencher o espaço com zeros pode levar a uma melhoria significativa (cerca de 100%) no desempenho. Em particular, isso pode ocorrer com discos (esses são dados não oficiais, não consegui confirmar). O mesmo se aplica ao armazenamento (e isso já é informação oficial, confirmada por testes). Outros especialistas observaram as mesmas , relacionadas a diferentes discos. - Quanto menos chamadas de sistema — maior o desempenho (o ganho pode ser de cerca de 5%). Parece que a chamada
open()com a flagO_DSYNCou a chamadapwritev2()com a flagRWF_SYNCé mais rápida do que a chamadafdatasync()Suspeito que a questão esteja no fato de que, com essa abordagem, o que conta é que para resolver a mesma tarefa são feitas menos chamadas de sistema (uma chamada em vez de duas). Mas a diferença de desempenho é muito pequena, então você pode muito bem ignorá-la e usar no aplicativo o que não complicará sua lógica.
Se você está interessado no tema de armazenamento de dados sustentável — aqui estão alguns materiais úteis:
- — uma visão geral dos mecanismos de entrada/saída.
- — um relato sobre o que acontece com os dados ao ir do aplicativo para o disco.
- — a resposta para quando deve ser aplicado
fsync()para diretórios. Se eu resumir em duas palavras, é que isso deve ser feito ao criar um novo arquivo, e a razão para essa recomendação é que no Linux pode haver muitas referências ao mesmo arquivo. - — aqui é descrito como o armazenamento persistente de dados é implementado no SQL Server na plataforma Linux. Existem algumas comparações interessantes entre chamadas de sistema do Windows e Linux. Estou quase certo de que foi graças a este material que soube sobre a otimização FUA do XFS.
Você já perdeu dados que acreditava estarem seguros no disco?
Fonte: habr.com
