Start Debugging

EF Core 11 vs Dapper для массовых вставок: реальный бенчмарк

Для массовых вставок в .NET 11 не побеждают ни EF Core, ни Dapper. Побеждает SqlBulkCopy. Это бенчмарк, причина и то место, которое заслуживает каждый инструмент.

Если вы вставляете больше нескольких тысяч строк в SQL Server из .NET 11, правильный ответ редко “EF Core” и редко “Dapper”. Правильный ответ — это SqlBulkCopy, вызванный напрямую из соединения любого из этих инструментов. AddRange + SaveChangesAsync из EF Core 11 — самый чистый выбор для менее чем 1000 строк. ExecuteAsync из Dapper со списком параметров — худший из трёх при любом количестве строк и тот, которого следует избегать для массовых загрузок. Ниже — таблица решений, цифры бенчмарка за ней и код для каждого пути на Microsoft.EntityFrameworkCore 11.0.0, Microsoft.Data.SqlClient 6.1 и Dapper 2.1.66.

Матрица возможностей с первого взгляда

ВозможностьEF Core 11 AddRangeDapper ExecuteAsyncSqlBulkCopy
Базовый протоколПакетные операторы INSERTОператоры INSERT построчноTDS bulk copy (нативная массовая загрузка)
Отслеживание измененийДаНетНет
Возврат значений identity в сущностьДа (через OUTPUT INSERTED.Id)Нет (ручной SELECT SCOPE_IDENTITY())Только с KeepIdentity и явными значениями
Связи и каскадные вставкиДаНетНет
Память при 100K строк (SQL Server)~сотни МБ~десятки МБ~десятки МБ, дружественно к streaming
Время вставки 100K строк (см. методологию)~2,1 с~10,9 с~0,65 с
Время вставки 1M строк~21,6 с~109 с~7,3 с
Только SQL ServerНет (работает с любым провайдером EF)НетДа (Microsoft.Data.SqlClient)
Сложность кодаСамая низкаяНизкаяСредняя (требует маппинга таблицы)
Работает с потоковым IAsyncEnumerable<T>Нет (сначала загружает сущности)НетДа (через IDataReader)
Транзакция с остальной EF unit-of-workДаВручнуюВручную (SqlTransaction)
ЛицензияMITApache 2.0MIT

Таблица — это рекомендация. Всё ниже — почему.

Когда AddRange + SaveChangesAsync из EF Core 11 правильны

EF Core 11 пакетирует вставки разумно. Провайдер SQL Server группирует вставляемые сущности в многострочные операторы INSERT ... VALUES (...), (...), ... до 1000 строк на пакет (жёсткий лимит SQL Server для табличных параметров) или разбивает на 2100 параметрах на пакет, что наступит раньше. Для сущности с 200 столбцами практический размер пакета схлопывается до однозначного числа строк, потому что доминируют параметры; для сущности с пятью столбцами вы получаете полные пакеты по 1000 строк.

Выбирайте AddRange, когда:

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

Это то место, для которого EF Core был спроектирован. Цена — аллокации: каждая сущность материализуется, отслеживается на изменения и удерживается в DbContext до коммита SaveChanges. Для 100K строк широкой сущности это сотни мегабайт давления на GC. Для 1000 строк это несущественно.

Если вы идёте этим путём для средних пакетов, помогают два рычага:

Когда ExecuteAsync из Dapper правилен (и когда нет)

Массовая история Dapper известна простотой: передайте коллекцию, получите один INSERT на строку за один сетевой round-trip.

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

Приятно писать. Медленно при масштабе. Dapper отправляет один параметризованный оператор на элемент в коллекции, упакованные в один сетевой round-trip. SQL Server всё ещё парсит, планирует и выполняет каждый INSERT индивидуально. Нет группировки строк, как делает EF Core, нет нативного массового протокола и нет параллелизма на уровне оператора.

Выбирайте ExecuteAsync из Dapper для вставок, когда:

Не выбирайте Dapper для массовых вставок >1000 строк. Цена на строку реальна, экономия на сети мала, и у вас есть лучший инструмент в одном namespace. Если вы тянетесь за “быстрой” вставкой из Dapper, вы почти наверняка хотите Dapper.Plus (коммерческий) или, более честно, SqlBulkCopy, который можно вызвать из того же SqlConnection, которым Dapper уже владеет.

Когда SqlBulkCopy правилен (почти всегда для “массовых”)

Microsoft.Data.SqlClient.SqlBulkCopy использует тот же протокол массовой загрузки TDS, что и bcp и BULK INSERT. Сервер пропускает парсер, оптимизатор и журналирование построчно в пользу потокового бинарного формата. Для количеств строк выше ~10 000 ничто в управляемом мире не находится в той же лиге на 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);
}

Перегрузка IDataReader — та, которую следует использовать. Перегрузка DataTable работает и проще для демонстрации, но материализует каждую строку в DataTable до того, как первый байт попадёт в провод. Перегрузка IDataReader делает streaming: строки вытягиваются по одной из вашего enumerable и проталкиваются на сервер по мере заполнения пакета, что удерживает рабочий набор плоским даже при миллионах строк.

ObjectDataReader<T> — это примерно 80 строк (в связанном посте Milan Jovanović есть полная версия) и конвертирует IEnumerable<T> в интерфейс IDataReader через кэшированные lookup PropertyInfo. ObjectReader.Create(events) из FastMember — готовый эквивалент, если вы не хотите писать его сами.

Три опции, которые стоит установить на каждую массовую копию:

Для PostgreSQL эквивалент — NpgsqlBinaryImporter (COPY ... FROM STDIN BINARY). Для MySQL — MySqlBulkCopy. Для Oracle — OracleBulkCopy. Форма идентична: потоковая передача строк из reader в бинарный протокол, обходящий парсер SQL.

Бенчмарк

Эти цифры взяты из бенчмарка массовых вставок в SQL Server Milan Jovanović, запущенного на .NET 9 против локального экземпляра SQL Server 2022 с пятистолбцовой таблицей Customer. Я перепроверил форму на конфигурации .NET 11.0.0 + Microsoft.Data.SqlClient 6.1.3 + EF Core 11.0.0 (одноразовые замеры, AMD Ryzen 9 7900X, SQL Server 2022 Developer в Docker на той же машине, BenchmarkDotNet 0.14.0). Относительный порядок идентичен. Абсолютные числа смещаются на несколько процентов в зависимости от оборудования и конфигурации SQL Server, но ни один метод не меняет места.

Метод100 строк1000 строк10 000 строк100 000 строк1 000 000 строк
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

Методология: BenchmarkDotNet 0.14.0, [MemoryDiagnoser] на каждом методе, SQL Server 2022 в Docker на том же хосте, таблица очищена между запусками, индексирована только по Id. Число Dapper использует наивный паттерн “передать список в ExecuteAsync”; написанный вручную INSERT ... VALUES с 1000 кортежами на оператор закрывает часть разрыва, но не догоняет SqlBulkCopy.

Три прочтения таблицы:

  1. При 100 строках любой метод быстр. Выбирайте то, что подходит коду. EF Core выигрывает в эргономике, Dapper выигрывает, если вы уже там, SqlBulkCopy выигрывает на волос, который ни один пользователь никогда не заметит.
  2. При 10 000 строк SqlBulkCopy в 3 раза быстрее EF Core и в 15 раз быстрее Dapper. Здесь решение начинает иметь значение для задержки, видимой пользователю.
  3. При 1 000 000 строк SqlBulkCopy в 3 раза быстрее EF Core и в 15 раз быстрее Dapper, и разница составляет минуты вместо секунд. Здесь это перестаёт иметь значение для пользовательской задержки и начинает иметь значение для бюджетов ETL-окон.

EFCore.BulkExtensions находится в пределах 15 процентов от чистого SqlBulkCopy, потому что под капотом он и есть SqlBulkCopy, обёрнутый в API в стиле EF Core, который читает вашу конфигурацию маппинга. Если вы хотите скорость SqlBulkCopy без написания шаблонного кода маппинга столбцов и у вас уже есть EF Core в проекте, эта библиотека — то самое место. Если вы не можете взять зависимость (или хотите поддерживать PostgreSQL с его другим массовым путём), оберните свой собственный helper вокруг SqlBulkCopy и NpgsqlBinaryImporter.

Для взгляда на тот же trade-off со стороны PostgreSQL бенчмарк массовых операций в EF Core 10 на .NET 10 + PostgreSQL 17 показывает EFCore.BulkExtensions.BulkInsert в 8 раз быстрее AddRange для 100K строк, с 77 процентами меньшей памяти. Сырой COPY через Npgsql ещё быстрее.

Подводные камни, которые выбирают за вас

Несколько ограничений вынуждают принять решение независимо от предпочтений.

Мнение, повторённое

По умолчанию — AddRange + SaveChangesAsync EF Core 11 для всего, что меньше 1000 строк. Переключайтесь на SqlBulkCopy (или EFCore.BulkExtensions, если хотите сохранить маппинг EF) для всего, что больше 10 000. Середина принадлежит той стороне границы, на которой уже живёт ваш код. Используйте Dapper для того, в чём он действительно лучший (точные чтения и небольшие команды), а не для массовых вставок.

Два следствия, которые стоит трактовать как домашние правила:

Связанное

Источники

Comments

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

< Назад