Introdução
Todo desenvolvedor .NET que trabalha com aplicações de médio ou grande porte já esbarrou em pelo menos um destes cenários: uma rotina noturna que precisa gravar 50 mil pedidos no banco, uma consulta de relatório que traz 300 mil registros de uma única vez e trava a aplicação, ou uma API que demora 8 segundos para responder porque o EF Core está executando 10 mil INSERT individuais em sequência.
O SQL Server e o Oracle são bancos robustos e maduros, mas nenhum deles foi projetado para receber milhares de gravações em rajada, nem para retornar centenas de milhares de linhas de uma só vez sem custo. O problema, na maioria dos casos, não é o banco — é a forma como a aplicação C# interage com ele.
Neste artigo vamos explorar as duas faces do gargalo: escrita em massa e leitura de grandes volumes. Para cada uma, você vai ver a causa raiz, as particularidades de SQL Server e Oracle com EF Core 8.0+, e a solução prática — mensageria para gravação e paginação eficiente para leitura. Todo o código é produção-ready e compatível com .NET 8/9.
Pré-requisitos: Conhecimento básico de C# e EF Core. Recomenda-se ter lido o artigo sobre programação assíncrona com C# antes de continuar.
📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Clone, explore e adapte ao seu contexto.
O Gargalo de Escrita: Por Que Gravar Milhares de Registros Dói
O Custo Real de Cada INSERT
Quando você salva uma lista de entidades com o EF Core da forma mais comum, algo assim acontece:
| |
Cada SaveChangesAsync() dentro do loop representa:
- Uma transação aberta → commit → fechada no banco
- Um roundtrip de rede (latência de 1–5ms por chamada)
- Log do banco de dados sendo escrito para cada operação
- Locks de linha sendo alocados e liberados 50 mil vezes
Para 50.000 pedidos com 2ms de latência por roundtrip, isso representa 100 segundos de processamento puro de I/O. E isso é no melhor cenário, sem contenção.
Particularidades: SQL Server vs Oracle
Ainda que a solução seja similar nos dois bancos, há diferenças importantes:
| Aspecto | SQL Server | Oracle |
|---|---|---|
| Bulk Insert nativo | BULK INSERT / SqlBulkCopy | ODP.NET BulkCopy / INSERT ALL |
| Tamanho máximo de lote padrão | 1.000 linhas por INSERT | Depende do ArrayBindCount |
| Sequências / Identity | IDENTITY ou SEQUENCE | Apenas SEQUENCE (obrigatório) |
| Rollback de bulk | Pode ser minimamente logado | Sempre logado (redo log) |
| EF Core provider | Microsoft.EntityFrameworkCore.SqlServer | Oracle.EntityFrameworkCore |
No Oracle, um detalhe crítico é que o provider oficial (Oracle.EntityFrameworkCore 8.x) exige que você configure a geração de chaves via SEQUENCE + TRIGGER (ou GENERATED ALWAYS AS IDENTITY no Oracle 12c+). Ignorar isso em gravações em massa vai gerar N chamadas extras ao banco só para obter os IDs.
| |
A Solução Imediata: SaveChanges em Lote com EF Core 8
Antes de introduzir filas, a primeira otimização é mover o SaveChangesAsync() para fora do loop e usar o chunk para não sobrecarregar o contexto:
| |
💡 Dica: O método Chunk() foi introduzido no .NET 6. Combinado com ChangeTracker.Clear(), evita que o contexto EF Core cresça indefinidamente ao rastrear dezenas de milhares de entidades.
ExecuteInsertAsync e BulkInsert no EF Core 8
O EF Core 7 trouxe ExecuteUpdateAsync e ExecuteDeleteAsync. O EF Core 8 melhorou o suporte a operações em massa. Para cenários de altíssima performance, use a biblioteca EFCore.BulkExtensions:
| |
Com BulkInsert, 50.000 registros que levavam 100 segundos com SaveChanges individual passam a ser gravados em 2–4 segundos no SQL Server, e em 3–6 segundos no Oracle.
Mensageria: A Solução Arquitetural para Gravação em Massa
Otimizar o próprio INSERT resolve o sintoma, mas não a causa raiz. O problema real é que a aplicação está tentando processar um volume enorme de forma síncrona, bloqueando a thread e a requisição enquanto grava. A solução arquitetural correta é desacoplar a recepção da gravação usando uma fila de mensagens.
Como a Mensageria Resolve o Problema
Em vez de gravar diretamente no banco ao receber os dados, a aplicação:
- Publica os registros em uma fila (RabbitMQ, Azure Service Bus, etc.) — operação rápida (~1ms por mensagem)
- Retorna imediatamente para o cliente com
202 Accepted - Um Consumer separado lê a fila em lotes e grava no banco com bulk insert
Essa separação traz benefícios além da performance:
- Resiliência: se o banco cair temporariamente, as mensagens ficam na fila. Naão há perda de dados.
- Controle de fluxo: o consumer pode processar na velocidade que o banco suporta
- Backpressure natural: a fila amortece picos de carga
- Observabilidade: você pode monitorar o tamanho da fila e saber exatamente o backlog pendente
Implementação com RabbitMQ.Client
A biblioteca oficial RabbitMQ.Client é 100% open-source (Apache 2.0) e fornece acesso direto ao protocolo AMQP. Combinada com o BackgroundService do .NET, implementamos um consumer que acumula mensagens em lote e grava tudo de uma vez com BulkInsert.
| |
Registrando no Program.cs
| |
⚠️ Atenção: No Oracle, o BulkInsert do EFCore.BulkExtensions requer o Oracle Data Provider for .NET (ODP.NET). Certifique-se de registrar o provider correto e de que as sequences foram configuradas corretamente no modelo, ou a inserção em massa vai falhar com erro de constraint de chave primária.
RabbitMQ vs Azure Service Bus: Qual Escolher?
As duas opções resolvem o mesmo problema — desacoplar produção e consumo de mensagens — mas com modelos operacionais e econômicos bem distintos. A escolha depende do seu contexto: infraestrutura self-hosted vs cloud managed, custo variável vs fixo, e grau de controle desejado.
| Critério | RabbitMQ | Azure Service Bus |
|---|---|---|
| Tipo | Open-source (MPL 2.0), self-hosted | PaaS gerenciado pela Microsoft |
| Protocolo | AMQP 0-9-1 (nativo), AMQP 1.0, MQTT, STOMP | AMQP 1.0 |
| Hospedagem | Container próprio, VM, Kubernetes | Azure (sem infra para gerenciar) |
| Custo | Infraestrutura + operação (seu time) | Pay-per-use (~$0,10/milhão de ops) |
| Filas | Queues, Exchanges, Bindings | Queues e Topics/Subscriptions |
| Tamanho máximo de mensagem | 128 MB (padrão) | 256 KB (Standard) / 100 MB (Premium) |
| Retenção de mensagens | Até o disco encher / TTL configurável | 14 dias (máximo) |
| Dead-letter queue | ✅ Configurável | ✅ Nativo |
| Sessions (ordenação garantida) | ✅ Via x-single-active-consumer | ✅ Service Bus Sessions |
| Retry automático | Manual (Dead-letter + requeue) | Nativo (MaxDeliveryCount) |
| Escalabilidade horizontal | Manual (cluster Erlang) | Automática |
| Biblioteca .NET | RabbitMQ.Client (Apache 2.0) | Azure.Messaging.ServiceBus (MIT) |
| Melhor para | On-premise, multi-cloud, custo controlado | Ecossistema Azure, equipe pequena, SLA garantido |
Implementação Equivalente com Azure Service Bus
Para migrar do RabbitMQ para o Azure Service Bus em ambiente Azure, o padrão de producer/consumer é similar, usando Azure.Messaging.ServiceBus:
| |
💡 Dica: Em produção no Azure, prefira autenticação via Managed Identity em vez de connection string, usando new ServiceBusClient("meu-namespace.servicebus.windows.net", new DefaultAzureCredential()). Isso elimina segredos na configuração.
O Gargalo de Leitura: Por Que Trazer Tudo de Uma Vez é Perigoso
O Problema da Query Sem Limite
| |
Essa query aparentemente inocente pode:
- Consumir gigabytes de RAM no servidor da aplicação
- Bloquear o banco com um table scan de longa duração
- Gerar timeouts em ambientes com alta concorrência
- Saturar a rede entre aplicação e banco com tráfego desnecessário
- Travar o GC do .NET com objetos grandes que precisam ir para o LOH (Large Object Heap)
No Oracle, um aspecto adicional é o uso do ROWNUM (Oracle 11g) vs FETCH FIRST … ROWS ONLY (Oracle 12c+), que afeta como a paginação é expressa em SQL. O provider Oracle.EntityFrameworkCore abstrai isso, mas é importante entender o que está sendo gerado.
Tipos de Paginação: Offset vs Keyset (Cursor)
📖 Artigo dedicado: Para um guia completo sobre todas as estratégias de paginação (Offset, Keyset, Cursor Server-Side, Time-based e Token Opaco) com SQL Server, Oracle e PostgreSQL — incluindo código EF Core 8+ para cada combinação —, veja: Paginação em APIs REST com C# e EF Core 8: Todos os Tipos, Todos os Bancos. Esta seção apresenta os conceitos essenciais; o artigo linked aprofunda quando e por que usar cada abordagem.
Existem duas estratégias principais de paginação, e cada uma tem casos de uso distintos:
| Característica | Offset Pagination | Keyset (Cursor) Pagination |
|---|---|---|
| SQL gerado | OFFSET N ROWS FETCH NEXT M | WHERE id > :lastId |
| Performance em páginas iniciais | ✅ Rápida | ✅ Rápida |
| Performance em páginas tardias | ❌ Lenta (scan cresce) | ✅ Constante |
| Suporte a salto de página | ✅ Direto | ❌ Apenas sequencial |
| Registros novos entre páginas | ❌ Pode duplicar/omitir | ✅ Consistente |
| Caso de uso ideal | UI com números de página | Scroll infinito / APIs |
Paginação por Offset: Simples e Adequada para UIs
A paginação por offset é a mais familiar, e o EF Core a gera corretamente para SQL Server e Oracle:
| |
💡 Dica: Sempre use .Select() com uma projection (DTO ou record), nunca retorne a entidade completa em queries de listagem. Isso reduz o volume de dados transferidos e evita o carregamento de navegações desnecessárias.
SQL Gerado: SQL Server vs Oracle
O EF Core 8 gera SQL correto e otimizado para ambos os bancos:
| |
⚠️ Atenção: Para Oracle 11g (sem FETCH FIRST), o provider gera uma query com ROWNUM aninhado, que pode ter custo adicional em tabelas muito grandes. Considere migrar para Oracle 12c+ ou usar views materializadas para relatórios pesados.
Keyset Pagination: Alta Performance para Scroll Infinito e APIs
Quando o usuário navega para a página 500 de um resultado com 10 mil itens, o banco precisa fazer um scan de 10.000 linhas só para pular as primeiras 9.980. Com keyset pagination, você usa o valor da última linha buscada como ponto de partida da próxima consulta:
| |
Este padrão mantém performance constante independente do número da página, pois o SQL gerado usa um simples WHERE com índice em vez de OFFSET.
| |
💡 Dica: Crie um índice composto nas colunas de keyset para garantir que a query use index seek em vez de table scan:
| |
Juntando Tudo: A Arquitetura Completa
Com as duas soluções combinadas, a arquitetura da aplicação fica assim:
- Escrita: API recebe → publica na fila → retorna
202 Accepted→ Consumer processa em lote com BulkInsert - Leitura: API recebe request paginado → executa query com Skip/Take (offset) ou WHERE (keyset) → retorna somente os dados necessários
| |
Dicas e Boas Práticas
- Índices adequados: Toda coluna usada em
OrderBy,Whereou keyset cursor deve ter índice. No Oracle, use índices funcionais para colunas com transformações (ex.:UPPER(nome)). - AsNoTracking sempre em leituras: Desativa o Change Tracker para queries de leitura, reduzindo uso de memória e CPU em até 30%.
- Evite
Count()em tabelas grandes: Para keyset pagination, você não precisa do total de registros. Para offset, considere cachear o total por alguns segundos. - Defina timeouts explícitos: Configure
context.Database.SetCommandTimeout(30)ou useWithTimeoutpor consulta para evitar queries longas bloqueando a aplicação indefinidamente. - Monitore o tamanho da fila: Configure alertas para quando a fila ultrapassar N mensagens — é o sinal de que o consumer não está dando conta do volume.
- Use
IAsyncEnumerablepara streaming: Para exportações de CSV ou processamento de grandes volumes que não precisam de paginação, useAsAsyncEnumerable()para processar linha por linha sem carregar tudo na memória:
| |
- Transações explícitas para consistência: Ao usar BulkInsert com múltiplas tabelas (ex.: gravar
PedidoeItensPedidoem conjunto), envolva a operação em uma transação explícita para garantir atomicidade.
Conclusão
Os gargalos de banco de dados com EF Core raramente são culpa do banco em si. Na esmagadora maioria dos casos, o problema está na forma como a aplicação interage com ele: inserindo um registro por vez em vez de em lote, e trazendo todo o resultado em vez de paginar.
A combinação de mensageria (RabbitMQ ou Azure Service Bus) para desacoplar gravação em massa e paginação eficiente (offset para UIs, keyset para APIs e scroll infinito) resolve os dois gargalos de forma elegante, escalável e resiliente. Com EF Core 8.0+ e as técnicas apresentadas aqui, você consegue suportar volumes muito maiores sem mudar a estrutura do banco, apenas ajustando a forma como a aplicação se comunica com ele.
O próximo passo natural é observabilidade: instrumentar as queries lentas com Application Performance Monitoring (APM), criar alertas para queries acima de N segundos, e mapear os índices faltantes via Query Store (SQL Server) ou AWR (Oracle).
Leia Também
- EF Core 8 com Fluent API: Mapeamento, ORM e Desacoplamento Total
- EF Core Migrations em Multi-Projeto: Secrets, Scaffolding e Gestão em Times
- Full-Text Search em APIs REST com C#: SQL Server, PostgreSQL e Oracle
- Arquitetura de Software e os Padrões GoF: do Código à Nuvem, do Monólito ao Microserviço
- Design de APIs REST: Sem Verbos na URL, Métodos HTTP e Binding de Parâmetros no ASP.NET Core
- Paginação em APIs REST com C# e EF Core 8: Todos os Tipos, Todos os Bancos
- Programação Assíncrona em C#: async/await do Fundamento à Produção
- Paralelismo em C#: Parallel, PLINQ e Tasks do Fundamento à Produção
- .NET Worker e Background Service para Alto Volume
Referências
- EF Core — Bulk Operations (Microsoft Docs) — Estratégias oficiais de atualização eficiente com EF Core
- EFCore.BulkExtensions — GitHub — Biblioteca open-source para bulk insert/update/delete no EF Core
- RabbitMQ.Client — NuGet / GitHub — Biblioteca oficial .NET para RabbitMQ (Apache 2.0)
- Azure.Messaging.ServiceBus — Documentação — Guia oficial do Azure Service Bus com .NET
- RabbitMQ vs Azure Service Bus — Comparativo — Visão geral do Azure Service Bus e quando usá-lo
- Oracle EF Core Provider — GitHub — Exemplos oficiais do provider Oracle para EF Core
- Use the query store (SQL Server) — Monitoramento de queries lentas no SQL Server via Query Store
- Repositório blog-zocateli-sample — Messaging — Código-fonte completo dos exemplos deste artigo
Ao comentar, você concorda com nossa Política de Privacidade, Termos de Uso e Política de Exclusão de Dados.