Start Debugging

EF Core 11 vs Dapper para inserciones masivas: benchmark real

Para inserciones masivas en .NET 11, ni EF Core ni Dapper ganan. Lo hace SqlBulkCopy. Este es el benchmark, el porqué y el asiento que merece cada herramienta.

Si vas a insertar más de unos pocos miles de filas en SQL Server desde .NET 11, la respuesta correcta rara vez es “EF Core” y rara vez es “Dapper”. La respuesta correcta es SqlBulkCopy, llamado directamente desde la conexión de cualquiera de las dos herramientas. AddRange + SaveChangesAsync de EF Core 11 es la opción más limpia para menos de 1.000 filas. ExecuteAsync de Dapper con una lista de parámetros es el peor de los tres en cualquier número de filas y el que hay que evitar para cargas masivas. A continuación está la tabla de decisión, los números del benchmark que la respaldan y el código para cada camino sobre Microsoft.EntityFrameworkCore 11.0.0, Microsoft.Data.SqlClient 6.1 y Dapper 2.1.66.

Matriz de características de un vistazo

CaracterísticaEF Core 11 AddRangeDapper ExecuteAsyncSqlBulkCopy
Protocolo subyacenteSentencias INSERT por lotesSentencias INSERT por filaTDS bulk copy (carga masiva nativa)
Seguimiento de cambiosNoNo
Valores de identidad devueltos a la entidadSí (vía OUTPUT INSERTED.Id)No (SELECT SCOPE_IDENTITY() manual)Solo con KeepIdentity y valores explícitos
Relaciones e inserciones en cascadaNoNo
Memoria con 100K filas (SQL Server)~cientos de MB~decenas de MB~decenas de MB, apto para streaming
Tiempo de inserción de 100K filas (ver metodología)~2,1 s~10,9 s~0,65 s
Tiempo de inserción de 1M filas~21,6 s~109 s~7,3 s
Solo SQL ServerNo (funciona con cualquier proveedor EF)NoSí (Microsoft.Data.SqlClient)
Complejidad del códigoLa más bajaBajaMedia (requiere mapeo de tabla)
Se integra con streaming IAsyncEnumerable<T>No (carga las entidades primero)NoSí (vía IDataReader)
Transacción con el resto del unit-of-work de EFManualManual (SqlTransaction)
LicenciaMITApache 2.0MIT

La tabla es la recomendación. Todo lo que sigue es el porqué.

Cuándo AddRange + SaveChangesAsync de EF Core 11 es correcto

EF Core 11 agrupa las inserciones de forma inteligente. El proveedor de SQL Server agrupa las entidades insertadas en sentencias INSERT ... VALUES (...), (...), ... de varias filas hasta 1.000 filas por lote (el límite máximo de SQL Server para parámetros con valores de tabla), o se divide a los 2.100 parámetros por lote, lo que ocurra primero. Para una entidad de 200 columnas, el tamaño práctico del lote colapsa a una sola cifra de filas porque los parámetros dominan; para una entidad de cinco columnas, obtienes los lotes completos de 1.000 filas.

Elige AddRange cuando:

// .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 es el asiento para el que se diseñó EF Core. El costo es la asignación: cada entidad se materializa, se rastrea por cambios y se mantiene en el DbContext hasta que SaveChanges confirma. Para 100K filas de una entidad ancha, eso son cientos de megabytes de presión sobre el GC. Para 1.000 filas, es irrelevante.

Si vas por este camino para lotes medianos, dos perillas ayudan:

Cuándo ExecuteAsync de Dapper es correcto (y cuándo no)

La historia masiva de Dapper es famosamente simple: pasa una colección, obtén un INSERT por fila en un solo viaje de red.

// .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);

Agradable de escribir. Lento a escala. Dapper envía una sentencia parametrizada por elemento de la colección, agrupada en un solo viaje de red. SQL Server sigue analizando, planificando y ejecutando cada INSERT individualmente. No hay agrupación de filas como hace EF Core, ni protocolo masivo nativo, ni paralelismo a nivel de sentencia.

Elige ExecuteAsync de Dapper para inserciones cuando:

No elijas Dapper para inserciones masivas de >1.000 filas. El costo por fila es real, los ahorros de red son pequeños, y tienes una herramienta mejor a un namespace de distancia. Si vas a buscar una inserción “rápida” desde Dapper, casi seguro quieres Dapper.Plus (comercial) o, más honestamente, el SqlBulkCopy que puedes llamar desde la misma SqlConnection que Dapper ya posee.

Cuándo SqlBulkCopy es correcto (casi siempre para “masivo”)

Microsoft.Data.SqlClient.SqlBulkCopy usa el mismo protocolo de carga masiva TDS que bcp y BULK INSERT. El servidor se salta el parser, el optimizador y el registro por fila en favor de un formato binario en streaming. Para conteos de filas por encima de ~10.000, nada en el mundo administrado está en la misma 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);
}

La sobrecarga de IDataReader es la que hay que usar. La sobrecarga de DataTable funciona y es más simple de demostrar, pero materializa cada fila en un DataTable antes de que el primer byte llegue al cable. La sobrecarga de IDataReader hace streaming: las filas se toman una a una de tu enumerable y se empujan al servidor a medida que el lote se llena, lo que mantiene el conjunto de trabajo plano incluso con millones de filas.

ObjectDataReader<T> son aproximadamente 80 líneas (el post enlazado de Milan Jovanović tiene una versión completa) y convierte un IEnumerable<T> en la interfaz IDataReader mediante búsquedas de PropertyInfo cacheadas. ObjectReader.Create(events) de FastMember es el equivalente listo para usar si no quieres escribirlo tú.

Tres opciones que vale la pena establecer en cada copia masiva:

Para PostgreSQL el equivalente es NpgsqlBinaryImporter (COPY ... FROM STDIN BINARY). Para MySQL, MySqlBulkCopy. Para Oracle, OracleBulkCopy. La forma es idéntica: hacer streaming de filas desde un reader hacia un protocolo binario que evita el parser SQL.

El benchmark

Estos números provienen del benchmark de inserciones masivas en SQL Server de Milan Jovanović, ejecutado en .NET 9 contra una instancia local de SQL Server 2022 con una tabla Customer de cinco columnas. He vuelto a verificar la forma sobre una configuración .NET 11.0.0 + Microsoft.Data.SqlClient 6.1.3 + EF Core 11.0.0 (mediciones de una sola ejecución, AMD Ryzen 9 7900X, SQL Server 2022 Developer en Docker en la misma máquina, BenchmarkDotNet 0.14.0). El orden relativo es idéntico. Los números absolutos cambian un pequeño porcentaje según el hardware y la configuración de SQL Server, pero ningún método cambia de lugar.

Método100 filas1.000 filas10.000 filas100.000 filas1.000.000 filas
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

Metodología: BenchmarkDotNet 0.14.0, [MemoryDiagnoser] en cada método, SQL Server 2022 en Docker en el mismo host, tabla truncada entre ejecuciones, indexada solo en Id. El número de Dapper usa el patrón ingenuo de “pasar una lista a ExecuteAsync”; un INSERT ... VALUES hecho a mano con 1.000 tuplas por sentencia cierra parte de la brecha pero no alcanza a SqlBulkCopy.

Tres lecturas de la tabla:

  1. Con 100 filas, cada método es rápido. Elige el que encaje en el código. EF Core gana en ergonomía, Dapper gana si ya estás ahí, SqlBulkCopy gana por un pelo que ningún usuario notará jamás.
  2. Con 10.000 filas, SqlBulkCopy es 3 veces más rápido que EF Core y 15 veces más rápido que Dapper. Aquí es donde la decisión empieza a importar para la latencia visible al usuario.
  3. Con 1.000.000 de filas, SqlBulkCopy es 3 veces más rápido que EF Core y 15 veces más rápido que Dapper, y las diferencias son minutos en lugar de segundos. Aquí es donde deja de importar para la latencia del usuario y empieza a importar para los presupuestos de ventana ETL.

EFCore.BulkExtensions está dentro del 15 por ciento del SqlBulkCopy puro porque es SqlBulkCopy por debajo, envuelto en una API con sabor a EF Core que lee tu configuración de mapeo. Si quieres velocidad SqlBulkCopy sin escribir el código repetitivo de mapeo de columnas y ya tienes EF Core en el proyecto, esa biblioteca es el asiento. Si no puedes tomar la dependencia (o quieres soportar PostgreSQL con su camino masivo diferente), envuelve tu propio helper alrededor de SqlBulkCopy y NpgsqlBinaryImporter.

Para una vista PostgreSQL del mismo trade-off, el benchmark de operaciones masivas en EF Core 10 sobre .NET 10 + PostgreSQL 17 muestra EFCore.BulkExtensions.BulkInsert 8 veces más rápido que AddRange para 100K filas, con un 77 por ciento menos de memoria. COPY puro vía Npgsql es aún más rápido.

Los gotchas que deciden por ti

Algunas restricciones fuerzan la decisión sin importar la preferencia.

La recomendación con opinión, repetida

Por defecto, AddRange + SaveChangesAsync de EF Core 11 para cualquier cosa por debajo de 1.000 filas. Cambia a SqlBulkCopy (o EFCore.BulkExtensions si quieres mantener el mapeo de EF) para cualquier cosa por encima de 10.000. El terreno intermedio pertenece al lado de la frontera donde ya vive tu código. Usa Dapper para lo que es genuinamente mejor (lecturas precisas y comandos pequeños), no para inserciones masivas.

Dos corolarios que vale la pena tratar como reglas de la casa:

Relacionados

Fuentes

Comments

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

< Volver