ERP lento é fácil de culpar. Difícil é encontrar as três camadas internas que ninguém abre.

TL;DR

  • ERP lento raramente é banco, quase nunca é servidor, e em três de cada cinco casos é camada interna: serialização, regras de negócio ou arquitetura.
  • Cada camada exige diagnóstico próprio: profiler para serialização, instrumentação de domínio para regras, distributed tracing para arquitetura distribuída.
  • Subir máquina mascara o sintoma por algumas semanas e empurra o problema para a próxima release, com custo maior.
  • O método de três passes — medir, hipotetizar, isolar — é o que separa quem refatora com direção de quem troca seis por meia dúzia.
  • A dívida técnica mora em código que ninguém abre — não em CPU ociosa. É onde mora a próxima sprint cara.

~ 12 min de leitura · 2116 palavras

O fastio do “aumenta o servidor”

O post anterior desta série mostrou que banco de dados não é o vilão da maioria dos casos de ERP lento. EXPLAIN ANALYZE, `pg_stat_statements`, `slow query log`, APM de aplicação: tudo isso continua valendo como primeira linha de investigação. O que ficou de fora daquela lista são as três camadas internas que, em sistemas com mais de cinco anos de operação, respondem pela maior parte do tempo de resposta que o usuário final sente — e que quase nunca aparecem em dashboard de observabilidade padrão.

São elas: serialização, regras de negócio mal modeladas e arquitetura em novelo de dependências. As três coexistem em qualquer ERP com volume de produção real. Cada uma exige diagnóstico próprio, ferramenta própria e decisão de refatoração própria. Aplicar a ferramenta da camada A no problema da camada B produz, no melhor cenário, mais código que ninguém entende. No pior, um sistema rápido que entrega dados errados.

A frase “vamos subir mais memória” é o equivalente técnico de colocar fita adesiva no painel do carro quando o motor falha. O ruído some por uma semana. O problema continua lá.

Camada 1: serialização — o gargalo invisível entre banco e regra

Serialização raramente aparece nos gráficos de performance. O profiler de aplicação mostra tempo em `psycopg2.execute`, em chamadas HTTP, em funções de cálculo. A serialização — aquele loop que converte tupla do Postgres em dicionário Python, ou DataTable em JSON para o front — fica entre o que o profiler consegue medir e o que o usuário sente. É o gargalo invisível porque está dentro do próprio código de aplicação, não na fronteira com banco ou serviço externo.

O caso típico de ERP brasileiro envolve JSON em payloads de NF-e, CT-e, MDF-e e eventos SEFAZ. Um documento fiscal completo tem entre 60 e 80 campos, com estruturas aninhadas de até quatro níveis (det, imposto, ICMS, ICMS00). Quando o ERP serializa uma lista de cem notas para a tela de consulta, o `json.dumps` sozinho consome 35% a 60% do tempo de resposta medido no servidor — e isso antes de qualquer cálculo de regra de negócio.

A primeira reação costuma ser adotar Protobuf ou MessagePack. O ganho de parse é real: benchmarks públicos colocam MessagePack entre 2x e 4x mais rápido que JSON puro, e Protobuf com schema binário alcança 4x a 6x em payload do mesmo tamanho. Para sistemas com alta vazão e payload grande, a troca compensa. Para ERP de PME que serializa NF-e a cada requisição HTTP, a conta é diferente.

O ponto que quase nenhum post genérico menciona: serialização rápida sem contrato versionado quebra consumers em produção quando o schema evolui. Protobuf com `field_number` reservado exige disciplina de versionamento que equipes pequenas raramente mantêm. JSON com schema em Swagger ou OpenAPI tolera adição de campo opcional sem quebrar clientes. MessagePack fica no meio-termo: rápido, mas com semântica de evolução implícita que vira bomba-relógio em três releases.

A segunda reação, mais barata e frequentemente mais correta, é medir onde o tempo de serialização de fato mora:

import json
import time
from dataclasses import asdict, dataclass

@dataclass
class NFeItem:
    codigo: str
    descricao: str
    ncm: str
    cfop: str
    valor_unitario: float
    quantidade: float
    icms: dict

# 100 NF-e com 50 itens cada
lote = [[NFeItem(f"P{i}", f"Produto {i}", "84713012", "6102", 99.90, 1.0, {}) for i in range(50)] for _ in range(100)]

t0 = time.perf_counter()
payload = json.dumps([asdict(item) for nota in lote for item in nota], default=str)
t1 = time.perf_counter()
print(f"Serialização: {(t1-t0)*1000:.1f}ms, payload: {len(payload)/1024:.1f}KB")

A primeira linha que volta desse script raramente está no log de aplicação. Está, isso sim, no p99 medido no navegador. Otimizar serialização começa por tornar o gargalo visível, não por trocar de biblioteca.

Camada 2: regras de negócio mal modeladas — onde a complexidade vira latência

Regras de negócio em ERP vivem em três lugares: banco de dados (triggers, stored procedures, constraints), código de aplicação (services, validadores, calculadoras de imposto) e, cada vez mais, motor de regras externo (Drools, Easy Rules, NRules). Onde elas moram muda completamente o perfil de performance — e onde deveriam morar quase nunca coincide com onde moram.

O anti-padrão mais observado em sistemas com mais de oito anos de operação é a regra que atravessou camadas por inércia. Trigger que valida CFOP no banco, calculadora de ICMS no código de aplicação, totalizador em procedure chamada por job noturno. Cada vez que o usuário salva uma nota, a mesma regra roda em três lugares. Pior: cada lugar evoluiu com bugs diferentes. O CFOP 5.556 que a SEFAZ atualizou em 2024 está correto na procedure, errado no validador, e ausente na trigger.

O segundo anti-padrão é o agregado gordo. Domain-Driven Design define agregado como conjunto de entidades tratadas como unidade de consistência. Em ERP, o agregado “Pedido” tende a crescer até incluir cliente, itens, impostos, frete, pagamento, comissão, logística e histórico de alterações. Quando o agregado passa de 30 campos e 8 entidades filhas, qualquer operação de leitura precisa hidratar tudo para devolver qualquer coisa. O ORM resolve isso com lazy loading — e o lazy loading em agregado gordo é a causa estrutural do problema N+1 que o post 146 desta série já diagnosticou.

A leitura do post anterior sobre ORM N+1 é complementar a este aqui: lá o foco é o sintoma (N queries quando uma resolveria), aqui o foco é a causa raiz — o agregado foi modelado para escrita transacional, não para leitura, e o ORM está sendo usado para o caso de uso errado.

O terceiro anti-padrão é a regra de cálculo que virou loop. Validação tributária de NF-e referencia tabelas de CFOP, NCM, CST, alíquotas estaduais, exceções por UF, convênios interestaduais. Em sistemas legados, essa validação é implementada como sequência de `if` aninhados com `for` interno, percorrendo listas carregadas inteiras do banco. A complexidade é O(n × m × k) onde cada eixo é uma tabela tributária. Para 100 itens × 50 CFOPs × 27 UFs, são 135 mil comparações por nota. Em uma venda de balcão, isso é invisível. Em uma importação mensal de mil notas, é o que trava o sistema às sextas.

A solução não é “usar microsserviço de tributação”. A solução é extrair a regra para tabela de decisão (cabeçalho + condição → resultado) carregada em memória no startup, atualizada por evento de mudança tributária. O cálculo de ICMS que levava 800ms por nota passa para 12ms. A manutenção deixa de ser refatoração de `if` aninhado e vira inserção de linha em tabela versionada.

Camada 3: arquitetura em novelo de dependências — quando o sistema inteiro vira um join

Sistemas ERP monolíticos bem escritos escalam. Sistemas ERP monolíticos com acoplamento N:N viram novelo de dependências — e aí nem monolito bem escrito sobrevive. O sintoma é conhecido: uma alteração em módulo de cadastro trava o job de fechamento fiscal. Uma mudança em cálculo de comissão quebra a geração de pedido. A justificativa é sempre a mesma: “o sistema é integrado”.

O que “integrado” esconde em ERP brasileiro é, na maioria dos casos, acoplamento por tabela compartilhada. Schema de banco com 400 tabelas, todas referenciadas por todas. Tabela `cliente` lida por 80 módulos diferentes, cada um com sua query. Tabela `produto` idem. Tabela `movimento_estoque` com 30 colunas de uso misto — sete módulos escrevem nela, todos os outros leem parte dela. O “join implícito” do ORM atravessa módulos que não se conhecem, mas que dependem do mesmo schema para funcionar.

A primeira refatoração que resolve isso é bounded context — termo cunhado por Eric Evans em Domain-Driven Design. Significa delimitar, no mesmo banco ou em bancos separados, quais tabelas pertencem a qual contexto de negócio. Contexto fiscal lê `cliente` por uma view, contexto comercial lê por outra, contexto de logística lê por um modelo próprio. A duplicação de leitura é intencional; o acoplamento é o que se elimina.

A segunda refatoração — e aqui mora a armadilha — é microsserviço. Post 146 desta série já documentou o caso de microsserviço que não deveria ter existido. Em ERP, o cenário mais comum de microsserviço mal extraído é regra de negócio que rodava em 5ms dentro do mesmo processo passando a consumir 180ms por chamada HTTP, somado a 3 retries por timeout eventual, mais 2 chamadas síncronas para resolver transação distribuída que o monolito resolvia com `BEGIN COMMIT` local. O sistema ficou distribuído; a transação não ficou mais correta — ficou mais lenta e mais difícil de rastrear.

Distributed tracing resolve parte do problema. OpenTelemetry com propagação de contexto entre serviços mostra onde a latência mora. Mas a pergunta que o tracing responde — “qual chamada está lenta?” — não é a mesma que “essa chamada deveria existir?”. Um trace com 12 spans em 800ms, em que 10 deles são chamadas síncronas a serviços que rodam em loop de validação, não é problema de tracing. É problema de modelagem. Tracing mostra o sintoma; a refatoração é outra.

O método de diagnóstico em três passes

Camadas diferentes exigem ferramentas diferentes. O método é o mesmo.

Primeiro passe: medição por camada. Antes de qualquer hipótese, medir latência separadamente em gateway, camada de aplicação, serialização, domínio e persistência. A métrica certa é p99, não média — porque média esconde os casos que travam a operação. Grafana com dashboards por camada, ou distributed tracing com spans nomeados por camada, resolve. Sem isso, qualquer decisão de otimização é chute.

Segundo passe: hipótese com custo de testar. Listar três hipóteses ordenadas por probabilidade. Para cada uma, descrever o teste que confirmaria ou refutaria, e o custo de rodar esse teste. Se a primeira hipótese exige refatoração de duas semanas para ser validada, e a segunda exige adicionar uma métrica e rodar um teste de carga, ir pela segunda. Diagnóstico bom é incremental.

Terceiro passe: isolamento. Reproduzir o sintoma em ambiente de teste com carga sintética. Instrumentar a camada sob suspeita. Medir antes e depois de cada mudança candidata. Se a mudança não move o p99 da camada isolada, o problema não era aquele. Próxima hipótese.

Em sistemas com mais de 50 mil linhas de código e mais de oito anos de operação, esse ciclo raramente termina em menos de duas iterações. A tentação de pular para a refatoração diretamente — porque “já dá para saber o que é” — é a fonte número um de otimização que troca 6 por 6.

Por que servidor maior não resolve nenhuma das três

A frase “vamos subir a máquina” é o canto da sereia do orçamento de TI. Tem atratividade psicológica: resolve a ansiedade do chamado aberto, gera um número de aprovação visível (mais RAM, mais vCPU, mais IOPS), e transfere a responsabilidade do time de desenvolvimento para o time de infraestrutura. O problema é que nenhuma das três camadas internas de lentidão responde a mais CPU — e ERP lento que é sintoma de modelagem não cede a clock mais alto.

Serialização é CPU-bound em CPU single-core, mas o ganho de multi-core vem de paralelização de payload, não de clock mais alto. Subir a máquina não muda o algoritmo. Regras de negócio mal modeladas são CPU-bound em loop de validação que não paraleliza — o mesmo processador 2x mais rápido roda o loop em metade do tempo, mas o problema estrutural de modelagem continua. Arquitetura em novelo é network-bound: subir a máquina local não muda a latência de chamada entre serviços, só muda o ponto em que a contenção aparece.

O efeito colateral é pior. Servidor maior mascara o sintoma por semanas, durante as quais a dívida técnica continua acumulando juros. Quando o limite do novo hardware é atingido — inevitável, porque o código continua crescendo — a próxima release trava com mais força ainda, porque o sistema está maior e o gargalo estrutural está mais fundo.

A alternativa, menos palatável politicamente, é admitir que o sistema precisa de refatoração e orçar isso como projeto, não como manutenção. O custo de um projeto de 6 a 12 semanas para extrair bounded context, instrumentar tracing e revisar serialização é, em PME, da ordem de R$ 80 mil a R$ 250 mil dependendo do tamanho do time. O custo de subir máquina a cada 18 meses durante cinco anos é da mesma ordem, sem resolver o problema. A diferença é que um fica no código; o outro some no orçamento de infraestrutura.

Leitura relacionada

Posts Similares

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *