De 15.000 a 400.000 por Hora
Otimizando a Carga de XMLs com C++Builder e SQL Server em Hardware Limitado.
Meu Desafio: Um Dilúvio de Dados em Hardware Modesto
Tudo começou com um ex-colega que me procurou enfrentando um problema crítico de performance: "Cleverton, não aguento mais meu sistema, preciso ler mais de um milhão de arquivos XML por dia, e demoro muito tempo". Os arquivos em questão eram Notas Fiscais Eletrônicas (NF-e), e a necessidade de processar esse volume diariamente era real e urgente para o escritório de advocacia onde ela trabalha.
A performance do sistema legado era desanimadora. Na melhor das hipóteses, alcançava 65.000 arquivos por hora, mas a média diária era de apenas 15.000 arquivos por hora. O desafio, no entanto, vinha com uma restrição severa: a solução deveria rodar no mesmo ambiente do cliente, sem qualquer investimento em novos servidores. A empresa, como muitas cujo negócio principal não é tecnologia, hesitava em investir em infraestrutura de TI.
Aceitei o desafio, motivado por um sucesso anterior em processamento massivo de dados, onde transformei um trabalho de uma semana em uma tarefa de 30 minutos (leia também o artigo "De Uma Semana a 15 Minutos").
A Bancada de Testes: Definindo o Campo de Batalha
O ambiente de trabalho era modesto, replicando exatamente a máquina do cliente para garantir que os resultados fossem transferíveis:
Hardware:
- Notebook com processador Intel Core i7 (2 núcleos com Hyper-Threading), 8GB de RAM e um HD SSD.
Software:
- Aplicação: Desenvolvida em C++Builder XE6, utilizando seus componentes nativos DBGo (TADO) para acesso a banco de dados.
- Banco de Dados: Iniciando com MicroSoft SQL Express 2017 mas devido a limitações acabei migrando para o Microsoft SQL Server 2012.
- Biblioteca de Parsing: Pugixml, uma biblioteca C++ leve e de alta performance para processamento de XML.
O objetivo foi definido com base em uma carga de trabalho real do cliente: processar 163.152 arquivos XML em menos de 2 horas e 40 minutos. Isso estabeleceu uma meta de performance mínima de ~61.200 arquivos por hora.
3. Jornada de Otimização: A Caça ao Gargalo
A busca pela performance foi um processo iterativo, uma verdadeira "arqueologia de código" onde cada tentativa revelava uma nova camada do problema.
Tentativas Iniciais: O Erro Clássico
A primeira abordagem foi a mais intuitiva: eu ia ler cada XML, "achatar" suas informações em uma única tabela de dados brutos e realizar um INSERT simples para cada arquivo. O resultado foi um banho de água fria: 5.500 arquivos por hora.
A introdução de paralelismo, com 4 threads (uma escolha baseada nos 2 núcleos com Hyper-Threading da CPU), dobrou a performance para 11.000 arquivos por hora. Um ganho real, mas ainda muito longe do nosso objetivo. Rapidamente percebi que o gargalo não estava no processamento em si, mas na interação com o banco de dados.
Refinando o Banco de Dados
Então, mudei a minha estratégia. Abandonei a ideia da tabela achatada e passei a distribuir os dados extraídos do XML em uma estrutura relacional normalizada, similar à do cliente. As otimizações seguintes focaram exclusivamente no banco de dados:
- Remoção de SELECTs: Verificações de hash duplicado foram removidas das procedures de inserção.
- Ajuste de Logs: O log de transações do SQL Server foi passado para o modo SIMPLE e configurado para crescer em blocos maiores, reduzindo o overhead de gerenciamento de disco.
- Eliminação de Constraints: Durante o processo de carga, todas as chaves primárias, estrangeiras e índices foram desabilitados, deixando apenas os campos de ID com IDENTITY.
Cada passo trouxe um ganho. Ao final desta fase, eu havia alcançado a marca de 111.000 arquivos por hora. Havíamos superado a meta, mas a intuição dizia que o gargalo principal ainda estava escondido. Tentativas de mover a lógica de parsing para dentro do SQL Server ou usar BULK INSERT diretamente de arquivos de texto falharam catastroficamente, travando a máquina e provando que a aplicação C++ era o lugar certo para o trabalho pesado de parsing.
4. A Arquitetura Vencedora: O Poder dos Lotes
Minha verdadeira virada de chave veio com uma mudança no paradigma que eu adorei: se uma viagem ao banco de dados tem um custo fixo, a solução é fazer o menor número de viagens possível. Decidi abandonar completamente as procedures de inserção e os INSERTs individuais. A nova missão da aplicação C++ era montar e executar comandos de INSERT em lote.
Implementação Multithread de Alta Performance
A arquitetura final que eu desenhei foi pensada para maximizar o paralelismo e minimizar a contenção:
- Conexões Dedicadas: Cada uma das 4 threads de trabalho recebia sua própria instância de TADOConnection. Isso eliminou a contenção na conexão e permitiu que as threads operassem de forma verdadeiramente independente no acesso ao banco.
- IDs Atômicos: Para gerar IDs únicos para a tabela principal (XMLBruto) sem a sobrecarga de um IDENTITY do banco ou a lentidão de um CriticalSection, utilizei a função InterlockedIncrement da API do Windows.Como já mencionei no artigo "A Velocidade da Luz - Programação Lock-Free com std::atomic", esta é a aplicação prática de uma operação atômica, a forma mais rápida e segura de se obter um contador thread-safe.
Snippet de Código 1: Geração de ID Thread-Safe C++
// Variável global acessível por todas as threads
volatile LONG g_counter = 0;
// Dentro da thread, antes de processar um arquivo...
InterlockedIncrement(&g_counter);
arq[i].indice = (int)g_counter; // Garante um ID único e thread-safe
Montagem dos Lotes: A função grava_arquivo foi redesenhada. Em vez de executar uma query por arquivo, ela agora lia um "bloco" de arquivos (por exemplo, 3 arquivos), extraía os dados e construía uma única e massiva string de INSERT para cada tabela, utilizando a sintaxe de múltiplos VALUES.
Snippet de Código 2: Lógica Conceitual da Montagem do Lote C++
// Dentro da função grava_arquivo, recebendo um vetor de 3 arquivos
String s_cabecalho = "INSERT INTO tbNFe_Cabecalho (...) VALUES ";
String s_itens = "INSERT INTO tbNFe_Itens (...) VALUES ";
for (int i = 0; i < 3; i++) {
// Extrai dados do arquivo arq[i]...
// Adiciona os valores do arquivo atual à string do lote
s_cabecalho += "(...valores do arq[i]...),";
// Adiciona múltiplos itens, se houver
for (auto item : itens_do_arq_i) {
s_itens += "(...valores do item...),";
}
}
// Remove a vírgula final e executa a query uma única vez por tabela
FQuery->SQL->Text = s_cabecalho.TrimRight(",");
FQuery->ExecSQL();
FQuery->SQL->Text = s_itens.TrimRight(",");
FQuery->ExecSQL();
O Ponto Ótimo: Velocidade com 100% de Integridade
Os testes com lotes (ou blocos) mostraram um salto de performance "brutal". Com blocos de 3 arquivos, a performance explodiu para 403.200 arquivos por hora. Blocos maiores eram ainda mais rápidos, chegando ao pico de 540.000 arquivos/hora.
Contudo, a partir de blocos de 10 arquivos, um problema começou a surgir: perda de dados. Com o aumento da velocidade, a contenção e os timeouts geravam falhas na execução dos lotes. O critério para o sucesso mudou: não era mais a velocidade máxima, mas sim a velocidade máxima com 0% de perda.
O cenário bloco de 3 em 3 foi o campeão. Ele ofereceu um equilíbrio perfeito, atingindo a incrível marca de 403.200 arquivos/hora com total uniformidade entre os arquivos processados, o log de tela e os registros no banco de dados.
5. Detalhes Técnicos e Descobertas
No calor da batalha, algumas descobertas técnicas valiosas foram feitas:
- Otimização do Parsing: Uma análise mais profunda no código revelou uma otimização sutil, mas inteligente: a remoção do nó
do XML antes do parsing. A assinatura digital, embora vital para a validação, é um bloco de dados grande e irrelevante para a extração de informações, e sua remoção prévia acelera o trabalho da biblioteca pugixml. - O Limite do Provider: Ao testar blocos cada vez maiores (500, 999), a aplicação começou a falhar com erros genéricos. A investigação revelou que o provider SQLNCLI11.1 do ADO apresentava problemas com strings de comando SQL que excediam 22.869 caracteres. Uma limitação prática que definiu o teto para o tamanho dos lotes.
6. Conclusão: A Lição do Lote
O resultado final foi um sucesso retumbante. Partindo de uma média de 65.000 arquivos por hora (melhor cenário), a solução otimizada alcançou 403.200 arquivos por hora no mesmo hardware, um aumento de performance de mais de 6,20 vezes. O tempo para processar a carga de 163.152 arquivos despencou de quase 2 horas e 40 minutos (no melhor cenário) para impressionantes 24 minutos. Se considerarmos a média, o resultado é mais impressionante ainda, pois saiu de 15.000 arquivos por hora para 403.200, o seja, 26 x mais rápido.
A jornada para alcançar esse resultado reforça uma lição fundamental no processamento de dados em massa: o I/O (Entrada/Saída) é, quase sempre, o verdadeiro inimigo. Cada chamada de rede e cada comando executado no banco de dados carrega uma latência fixa. A chave para a performance extrema não foi um algoritmo de parsing mais inteligente, mas sim a estratégia de minimizar a sobrecarga de I/O, um princípio que também foi fundamental no case "De Uma Semana a 15 Minutos", agrupando centenas de operações em uma única transação em lote.