De quase zero a 1000 NF-e/min: otimizando API Spring com transações e JMeter
TL;DR - Otimizei a emissão de NF-e (queries e transações); vários @Transactional seguravam conexão durante I/O. Corrigi e a meta de 1000 notas/min no JMeter foi alcançada.
Num projeto em Java eu precisei otimizar a emissão de nota fiscal eletrônica de pedidos. Tudo assíncrono, com schemas no banco e integração com outras equipes. Antes das mudanças, o sistema não conseguia emitir nem uma nota por minuto.
Para achar os pontos lentos usei um profiler (Java VisualVM). O tempo sumia em consultas ao banco e em trechos que seguravam transação aberta sem necessidade. Duas frentes: otimizar queries e corrigir o uso de @Transactional.
O problema dos @Transactional
Vários métodos de serviço estavam anotados com @Transactional em nível de classe ou em métodos que faziam muito mais do que uma unidade de escrita. A transação abria no início e só dava commit no fim. No meio, o código lia dados, chamava outros serviços, montava XML e falava com a SEFAZ. A conexão do pool ficava presa o tempo todo; qualquer lock ou espera dentro desse método aumentava o tempo de uso da transação. Em carga, o pool esgotava e o resto ficava na fila.
Exemplo do que encontrei (genérico, mas o padrão era esse):
@Service
public class NotaFiscalService {
@Transactional // transação aberta durante o método inteiro
public void processarEmissao(Pedido pedido) {
Pedido comItens = pedidoRepository.findByIdComItens(pedido.getId()); // read
validar(comItens); // read + regras
String xml = montarXmlNfe(comItens); // CPU
String respostaSefaz = sefazClient.enviar(xml); // I/O externo
Nota nota = parseResposta(respostaSefaz);
notaRepository.save(nota); // write
pedidoRepository.atualizarStatus(pedido.getId(), EMITIDO); // write
}
}
Só o par save(nota) + atualizarStatus precisava de transação ativa. Buscar pedido, validar, montar XML e chamar a SEFAZ não precisam segurar conexão nem transação de escrita. Com @Transactional no método inteiro, cada chamada ocupava uma conexão durante todo esse tempo.
A correção foi deixar transação só onde há escrita e manter o método orquestrador sem transação, delegando o trecho que altera o banco para um método transacional:
@Service
public class NotaFiscalService {
public void processarEmissao(Pedido pedido) {
Pedido comItens = pedidoRepository.findByIdComItens(pedido.getId());
validar(comItens);
String xml = montarXmlNfe(comItens);
String respostaSefaz = sefazClient.enviar(xml);
Nota nota = parseResposta(respostaSefaz);
persistirNotaEAtualizarPedido(nota, pedido.getId());
}
@Transactional
void persistirNotaEAtualizarPedido(Nota nota, Long pedidoId) {
notaRepository.save(nota);
pedidoRepository.atualizarStatus(pedidoId, EMITIDO);
}
}
Em outros pontos havia @Transactional em métodos só de leitura (por exemplo, buscar pedido para montar relatório). Para leitura que não exige consistência estrita, usei @Transactional(readOnly = true) ou removi a anotação e deixei cada find usar sua própria conexão de forma curta. A transação (e a conexão) param de ficar abertas à toa.
Queries e índices
Nas consultas que apareciam no profiler, usei EXPLAIN ANALYZE no Postgres para ver plano de execução e uso de índices. Na maior parte dos casos os índices já estavam corretos; criei um índice que faltava para um filtro usado em todo o fluxo. O ganho maior veio da redução de N+1 (joins ou batch) e do tempo que a aplicação passou a segurar menos conexão por causa das transações.
Teste de carga com JMeter
Depois da refatoração, montei um teste de carga com JMeter. O objetivo era 1000 notas por minuto no fluxo completo: criar pedido, processar até emitir a nota oficialmente.
flowchart LR
Pedido[Pedido] --> Processos[Processos]
Processos --> NF-e[Emissão NF-e]
NF-e --> Ok[1000/min]
Estoque[Estoque] -.-> Gargalo[Gargalo]
Contabilidade[Contabilidade] -.-> Gargalo
A meta foi fácil de alcançar. O trecho de emissão de NF-e deixou de ser o limitante; o gargalo passou a ser os serviços de outras equipes (estoque e contabilidade). O JMeter serviu para ter número concreto e mostrar onde o limite estava de fato.
Isso não substitui teste de stress prolongado nem análise de gargalo (CPU, conexões, pool), mas para ter ordem de grandeza e provar que o fluxo aguenta a meta, JMeter resolve.