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.