Start Debugging

EF Core 11 vs Dapper para inserções em massa: benchmark real

Para inserções em massa no .NET 11, nem EF Core nem Dapper vencem. SqlBulkCopy vence. Este é o benchmark, o porquê e o lugar que cada ferramenta merece.

Se você está inserindo mais do que algumas milhares de linhas no SQL Server a partir do .NET 11, a resposta certa raramente é “EF Core” e raramente é “Dapper”. A resposta certa é SqlBulkCopy, chamado diretamente a partir da conexão de qualquer uma das duas ferramentas. AddRange + SaveChangesAsync do EF Core 11 é a escolha mais limpa para menos de 1.000 linhas. ExecuteAsync do Dapper com uma lista de parâmetros é o pior dos três em qualquer contagem de linhas e o que se deve evitar para cargas em massa. Abaixo está a tabela de decisão, os números do benchmark por trás dela, e o código para cada caminho sobre Microsoft.EntityFrameworkCore 11.0.0, Microsoft.Data.SqlClient 6.1 e Dapper 2.1.66.

Matriz de recursos em um relance

RecursoEF Core 11 AddRangeDapper ExecuteAsyncSqlBulkCopy
Protocolo subjacenteSentenças INSERT em loteSentenças INSERT por linhaTDS bulk copy (carga em massa nativa)
Rastreamento de alteraçõesSimNãoNão
Valores de identidade preenchidos de voltaSim (via OUTPUT INSERTED.Id)Não (SELECT SCOPE_IDENTITY() manual)Apenas com KeepIdentity e valores explícitos
Relacionamentos e inserções em cascataSimNãoNão
Memória com 100K linhas (SQL Server)~centenas de MB~dezenas de MB~dezenas de MB, amigável a streaming
Tempo de inserção de 100K linhas (ver metodologia)~2,1 s~10,9 s~0,65 s
Tempo de inserção de 1M linhas~21,6 s~109 s~7,3 s
Apenas SQL ServerNão (funciona em qualquer provider do EF)NãoSim (Microsoft.Data.SqlClient)
Complexidade de códigoMenorBaixaMédia (mapeamento de tabela necessário)
Funciona com streaming IAsyncEnumerable<T>Não (carrega entidades primeiro)NãoSim (via IDataReader)
Transação com o resto do unit-of-work do EFSimManualManual (SqlTransaction)
LicençaMITApache 2.0MIT

A tabela é a recomendação. Tudo abaixo é o porquê.

Quando AddRange + SaveChangesAsync do EF Core 11 está correto

EF Core 11 agrupa inserções de forma inteligente. O provider do SQL Server agrupa entidades inseridas em sentenças INSERT ... VALUES (...), (...), ... de várias linhas até 1.000 linhas por lote (o limite rígido do SQL Server para parâmetros com valores de tabela), ou divide aos 2.100 parâmetros por lote, o que ocorrer primeiro. Para uma entidade de 200 colunas, o tamanho prático do lote colapsa para uma cifra de uma só linha porque os parâmetros dominam; para uma entidade de cinco colunas, você obtém os lotes completos de 1.000 linhas.

Escolha AddRange quando:

// .NET 11, EF Core 11.0.0
public async Task InsertEventsAsync(IEnumerable<TelemetryEvent> events, CancellationToken ct)
{
    await using var db = new AppDbContext(_options);
    db.TelemetryEvents.AddRange(events);
    await db.SaveChangesAsync(ct);
}

Este é o lugar para o qual o EF Core foi projetado. O custo é alocação: cada entidade é materializada, rastreada por alterações, e mantida no DbContext até que SaveChanges faça commit. Para 100K linhas de uma entidade larga, isso é centenas de megabytes de pressão sobre o GC. Para 1.000 linhas, é irrelevante.

Se você for por esse caminho para lotes médios, dois botões ajudam:

Quando ExecuteAsync do Dapper está correto (e quando não)

A história em massa do Dapper é famosamente simples: passe uma coleção, obtenha um INSERT por linha em uma única ida e volta de rede.

// .NET 11, Dapper 2.1.66, Microsoft.Data.SqlClient 6.1.3
using var conn = new SqlConnection(_connectionString);
await conn.ExecuteAsync(
    "INSERT INTO TelemetryEvents (Id, DeviceId, At, Payload) VALUES (@Id, @DeviceId, @At, @Payload);",
    events);

Agradável de escrever. Lento em escala. Dapper envia uma sentença parametrizada por elemento na coleção, agrupada em uma única ida e volta de rede. SQL Server ainda analisa, planeja, e executa cada INSERT individualmente. Não há agrupamento de linhas como o EF Core faz, nem protocolo em massa nativo, nem paralelismo no nível de sentença.

Escolha ExecuteAsync do Dapper para inserções quando:

Não escolha Dapper para inserções em massa de >1.000 linhas. O custo por linha é real, a economia de rede é pequena, e você tem uma ferramenta melhor a um namespace de distância. Se você está buscando uma inserção “rápida” no Dapper, você quase certamente quer Dapper.Plus (comercial) ou, mais honestamente, o SqlBulkCopy que você pode chamar a partir da mesma SqlConnection que o Dapper já possui.

Quando SqlBulkCopy está correto (quase sempre para “em massa”)

Microsoft.Data.SqlClient.SqlBulkCopy usa o mesmo protocolo de carga em massa TDS que bcp e BULK INSERT. O servidor pula parser, otimizador, e log por linha em favor de um formato binário em streaming. Para contagens de linhas acima de ~10.000, nada no mundo gerenciado está na mesma liga sobre SQL Server.

// .NET 11, Microsoft.Data.SqlClient 6.1.3
public async Task BulkInsertAsync(IEnumerable<TelemetryEvent> events, CancellationToken ct)
{
    await using var conn = new SqlConnection(_connectionString);
    await conn.OpenAsync(ct);

    using var bulk = new SqlBulkCopy(conn, SqlBulkCopyOptions.TableLock, externalTransaction: null)
    {
        DestinationTableName = "dbo.TelemetryEvents",
        BatchSize = 5_000,
        BulkCopyTimeout = 120,
        EnableStreaming = true,
    };

    bulk.ColumnMappings.Add(nameof(TelemetryEvent.Id), "Id");
    bulk.ColumnMappings.Add(nameof(TelemetryEvent.DeviceId), "DeviceId");
    bulk.ColumnMappings.Add(nameof(TelemetryEvent.At), "At");
    bulk.ColumnMappings.Add(nameof(TelemetryEvent.Payload), "Payload");

    using var reader = new ObjectDataReader<TelemetryEvent>(events);
    await bulk.WriteToServerAsync(reader, ct);
}

A sobrecarga IDataReader é a que se deve usar. A sobrecarga DataTable funciona e é mais simples de demonstrar, mas materializa cada linha em um DataTable antes do primeiro byte chegar ao fio. A sobrecarga IDataReader faz streaming: as linhas são puxadas uma por vez do seu enumerable e empurradas para o servidor à medida que o lote enche, o que mantém o working set plano mesmo com milhões de linhas.

ObjectDataReader<T> tem cerca de 80 linhas (o post linkado de Milan Jovanović tem uma versão completa) e converte um IEnumerable<T> na interface IDataReader via buscas de PropertyInfo cacheadas. ObjectReader.Create(events) do FastMember é o equivalente pronto se você não quiser escrevê-lo.

Três opções que vale a pena definir em cada cópia em massa:

Para PostgreSQL o equivalente é NpgsqlBinaryImporter (COPY ... FROM STDIN BINARY). Para MySQL, MySqlBulkCopy. Para Oracle, OracleBulkCopy. A forma é idêntica: fazer streaming de linhas de um reader para um protocolo binário que evita o parser SQL.

O benchmark

Estes números vêm do benchmark de inserções em massa no SQL Server de Milan Jovanović, executado no .NET 9 contra uma instância local de SQL Server 2022 com uma tabela Customer de cinco colunas. Eu reverifiquei a forma em uma configuração .NET 11.0.0 + Microsoft.Data.SqlClient 6.1.3 + EF Core 11.0.0 (medições de uma única execução, AMD Ryzen 9 7900X, SQL Server 2022 Developer em Docker na mesma máquina, BenchmarkDotNet 0.14.0). A ordem relativa é idêntica. Os números absolutos mudam alguns por cento dependendo do hardware e da configuração do SQL Server, mas nenhum método troca de lugar.

Método100 linhas1.000 linhas10.000 linhas100.000 linhas1.000.000 linhas
EF Core 11 AddRange2,04 ms17,86 ms204,03 ms2.111,11 ms21.605,67 ms
Dapper ExecuteAsync10,65 ms113,14 ms1.027,98 ms10.916,63 ms109.064,82 ms
EFCore.BulkExtensions 8.01,92 ms7,94 ms76,41 ms742,33 ms8.333,95 ms
SqlBulkCopy1,72 ms7,38 ms68,36 ms646,22 ms7.339,30 ms

Metodologia: BenchmarkDotNet 0.14.0, [MemoryDiagnoser] em cada método, SQL Server 2022 em Docker no mesmo host, tabela truncada entre execuções, indexada apenas em Id. O número do Dapper usa o padrão ingênuo de “passar uma lista para ExecuteAsync”; um INSERT ... VALUES escrito à mão com 1.000 tuplas por sentença fecha parte do gap mas não alcança o SqlBulkCopy.

Três leituras da tabela:

  1. Com 100 linhas, cada método é rápido. Escolha o que se encaixa no código. EF Core vence na ergonomia, Dapper vence se você já está lá, SqlBulkCopy vence por um fio que nenhum usuário jamais notará.
  2. Com 10.000 linhas, SqlBulkCopy é 3x mais rápido que EF Core e 15x mais rápido que Dapper. É aqui que a decisão começa a importar para a latência visível ao usuário.
  3. Com 1.000.000 de linhas, SqlBulkCopy é 3x mais rápido que EF Core e 15x mais rápido que Dapper, e as diferenças são minutos em vez de segundos. É aqui que deixa de importar para a latência do usuário e começa a importar para os orçamentos de janela ETL.

EFCore.BulkExtensions está dentro de 15 por cento do SqlBulkCopy puro porque é SqlBulkCopy por baixo, envolvido em uma API com sabor de EF Core que lê sua configuração de mapeamento. Se você quer velocidade SqlBulkCopy sem escrever o boilerplate de mapeamento de colunas e já tem EF Core no projeto, essa biblioteca é o lugar. Se você não pode aceitar a dependência (ou quer suportar PostgreSQL com seu caminho em massa diferente), envolva seu próprio helper em torno de SqlBulkCopy e NpgsqlBinaryImporter.

Para uma visão PostgreSQL do mesmo trade-off, o benchmark de operações em massa no EF Core 10 no .NET 10 + PostgreSQL 17 mostra EFCore.BulkExtensions.BulkInsert 8x mais rápido que AddRange para 100K linhas, com 77 por cento menos memória. COPY puro via Npgsql é ainda mais rápido.

Os gotchas que decidem por você

Algumas restrições forçam a decisão independentemente da preferência.

A recomendação opinativa, repetida

Por padrão, AddRange + SaveChangesAsync do EF Core 11 para qualquer coisa abaixo de 1.000 linhas. Mude para SqlBulkCopy (ou EFCore.BulkExtensions se quiser manter o mapeamento do EF) para qualquer coisa acima de 10.000. O meio-termo pertence a qualquer lado da fronteira onde seu código já vive. Use Dapper para o que ele é genuinamente melhor (leituras precisas e comandos pequenos), não para inserções em massa.

Dois corolários que vale a pena tratar como regras da casa:

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar