System Design: processamento idempotente de arquivos
Olá!
Esse texto faz parte de uma série sobre System Design. Você encontra os desafios anteriores nos links abaixo.
Desafio 0: separando banco de dados
Desafio 1: integração de arquivos
Desafio 2: Processamento Idempotente de Arquivos
Desenhe uma solução de processamento idempotente de arquivos grandes (~10GB). A idempotência será do arquivo como um todo ou registro a registro? Cabe tudo na memória ou será um processamento via fluxo (streaming)?
Para fins de simplificação do exercício, não vou considerar como o arquivo irá ser recebido para processamento. Caso você queira ler uma análise sobre esse ponto, o desafio 1 dessa série é justamente uma discussão sobre isso.
Assumindo que o arquivo é recepcionado por algum outro processo e simplesmente armazenado, temos o seguinte fluxo macro como ponto de partida:
Ao meu ver, temos algumas possibilidades de resolver esse problema e irei comentar as opções em seguida. Antes, segue o código que usei como base para gerar um arquivo de 10GB para efetuar alguns testes.
//Caso você queira testar os exemplos mencionados
//esse código node foi gerado pelo chatgpt e gera um arquivo de 10GB
const fs = require("fs");
const writeStream = fs.createWriteStream("file.txt");
writeStream.on("error", function (err) {
console.error("Error writing to file:", err);
});
const chunkSize = 1024 * 1024; // 1 MB
const totalChunks = Math.ceil(1073741824 / chunkSize);
writeStream.setMaxListeners(totalChunks + 1);
write();
function write() {
let i = totalChunks;
writeChunk();
function writeChunk() {
let canContinue = true;
while (i > 0 && canContinue) {
const chunk = "x".repeat(chunkSize);
canContinue = writeStream.write(chunk);
i--;
if (i === 0) {
writeStream.end();
} else if (!canContinue) {
writeStream.once("drain", writeChunk);
}
}
}
}
Garantindo idempotência
A forma mais simples de verificar se o arquivo deve ser processado (ou seja, mantendo a idempotência), é rodar um algoritmo de hashing antes de sequer ler uma linha do arquivo.
//Exemplo de código em Node que gera um checksum de um arquivo
//usando md5, sha256 e sha512
const { exec } = require("child_process");
let start = Date.now();
exec("sha256sum file.txt", (err, stdout, stderr) => {
const end = Date.now();
console.log(`Sha 256 Execution time: ${end - start} ms`);
console.log(stdout);
});
start = Date.now();
exec("md5sum file.txt", (err, stdout, stderr) => {
const end = Date.now();
console.log(`MD5 Execution time: ${end - start} ms`);
console.log(stdout);
});
start = Date.now();
exec("sha512sum file.txt", (err, stdout, stderr) => {
const end = Date.now();
console.log(`Sha512 Execution time: ${end - start} ms`);
console.log(stdout);
});
Para esse mecanismo funcionar, ao receber um arquivo novo para processamento, antes de qualquer coisa, bastaria gerar o checksum do arquivo e comparar com os hashes previamente armazenados em algum mecanismo de persistência. Caso o hash do novo arquivo seja igual a algum hash previamente armazenado, o arquivo pode ser rejeitado, pois os hashes iguais indicam que possuem o mesmo conteúdo.
Apesar de o arquivo ser grande, a operação de geração do checksum é bem mais barata de ser efetuada do que o processamento em si. Em um arquivo de 10GB, em testes locais na minha máquina usando o código de exemplo acima, obtive os seguintes resultados:
Muito provavelmente, processar o arquivo inteiro demoraria mais tempo do que o exemplo, independente do algoritmo usado. Um detalhe: não há grandes diferenças além do tamanho do hash e possibilidades de hash collision para este caso de uso, usando diferentes métodos. Mesmo com pequenas diferenças de performance, o recomendado é utilizar no mínimo SHA256, pelo fato do MD5 ser praticamente obsoleto e os problemas de segurança não valem o ganho de velocidade. O fato do SHA512 ter sido mais performático é devido ao conteúdo do arquivo, isso impacta a performance do algoritmo em alguns casos.
Após essa checagem, podemos começar o processamento em si. Agora, temos outro problema: como ler um arquivo desse tamanho?
Processamento
Temos três opções: ler o arquivo inteiro de uma vez, ler registro a registro ou efetuar a leitura em chunks.
A vantagem da primeira opção é ler o arquivo do começo ao fim e processá-lo com todos os dados na memória. A desvantagem é ler o arquivo do começo ao fim e processá-lo com todos os dados na memória. Explico.
Armazenar 10GB de uma só vez na memória acaba sendo uma má ideia, pelos seguintes problemas: interrupções no processamento (não será possível retomar de onde a leitura parou), aumento atípico de tamanho de arquivo (a memória usada irá aumentar junto e dependendo do ambiente, mais memória pode não estar disponível facilmente, inviabilizando o processamento). Geralmente, soluções que dependam de aumento de recursos computacionais acabam sendo problemáticas para escalar com facilidade.
A segunda opção (leitura por registro) permite um controle mais granular do processamento. Afinal, o arquivo será processado linha a linha, até o fim. O processamento nesse formato e no anterior pode ser interrompido assim que for identificado um registro inválido. O grande problema é rejeitar o arquivo no penúltimo registro e ter que processá-lo inteiro novamente do zero.
Portanto, para que seja ainda mais eficiente, o ideal seria controlar o estado do processamento. Uma estrutura de chave-valor que armazena o nome do arquivo e a última linha em questão é o mínimo necessário para efetuar esse controle, mas pode ser usado algo como uma tabela em algum banco de dados também. Só lembrando que um grande ofensor de performance nessa solução é efetuar conexões ao banco de dados a cada linha do arquivo (imagine abrir 1 milhão de conexões no banco de dados no caso de um arquivo com essa quantidade de linhas).
A terceira opção permite uma junção entre performance com um controle mais granular. Ao invés de ler linha a linha, uma solução possível seria ler uma quantidade qualquer de linhas em chunks. Com isso, seria possível evitar múltiplas leituras ao disco (que impacta a performance), ao mesmo tempo que se mantém uma quantidade específica de linhas na memória (ganhando performance de acessos a esses dados, ao mesmo tempo que mantém a quantidade usada de RAM sob controle).
Além disso, é possível criar uma estrutura em código (chamarei de Dispatcher) que processa cada chunk usando uma thread diferente, adicionando paralelismo ao processo. A estrutura de dados que funcionaria bem para esse caso seria uma Fila (Queue).
Neste caso, o Dispatcher seria responsável por fazer o Enqueue, enquanto as Threads fazem o Dequeue ao mesmo tempo. Lembrando que o paralelismo seguro só ocorre se cada registro seja passível de processar de forma atômica (ou seja, cada registro consegue ser processado sem demais dependências com outros registros do mesmo arquivo). Caso essa questão não seja respeitada, geralmente ocorre compartilhamento de estado entre as threads e inúmeros bugs difíceis de corrigir podem surgir (como deadlocks, race conditions e similares). O resumo dessa solução seria a imagem abaixo.
Essa solução teria uma boa performance, mantendo os recursos computacionais sob controle (bastaria limitar a quantidade de registros por queue, por exemplo). Caso você mais sofisticação seja necessária, é possível substituir as queues na memória por um Kafka. Dessa forma, usando das features do Kafka, como persistência dos registros e controle de concorrência, tornaria a solução um pouco mais enxuta. Seria possível trocar a responsabilidade de alguns componentes e deixá-los mais simples também. O resultado seria o abaixo:
O motivo do dispatcher ser síncrono é para evitar problemas relacionados a concorrência no acesso ao arquivo. Gerenciar a leitura de cada linha em paralelo seria muito complexo. Devido a isso, acredito que seja mais adequado ler de forma sequencial e distribuir o processamento das linhas posteriormente.
Além do Kafka, poderia ser utilizado qualquer mecanismo de gerenciamento de queues disponíveis no mercado, como SQS, Pub/Sub ou RabbitMQ, que funcionaria igual. Inclusive, recomendo o uso de soluções assim antes de partir para ferramentas mais robustas. Como linha geral, se o seu problema não atinge o limite de uma determinada ferramenta, não há motivos para usar algo mais complexo (e caro).
Se você encontrar algum erro neste texto, me mande uma mensagem que eu ajustarei.
Obrigado pela leitura!
Até!
Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.