Comments 29
Это главная боль. Чтобы передать список объектов в процедуру, в Dapper нужно либо создавать
DataTable(что дико аллоцирует память и медленно), либо писать вручную кастомные классы‑наследникиIEnumerable<SqlDataRecord>. Это тонна бойлерплейта.
npgsql драйвер не поддерживает DataTable, т.е с PostgreSQL не заведется.
Вместо SqlDataRecord можно использовать реализовать DbDataReader, который будет итерировать по свойствам-колонкам и отдавать данные. Поддерживается ADO из коробки, кроме того, нет надобности тянуть Microsoft.SqlServer.*специфичные пространства имен.
Что касается аллокации и скорости, то тут можно пулинг использовать, постоянно пересоздавать DataTable необязательно (но не уверен на счет DataRow, вероятно их потребуется пересоздавать, но экономия все-таки будет). Метаданные (информация о колонках) так или иначе статичная, ее можно сохранить на уровне класса и DataTable (если пулить).
И не факт, что кода будет больше при таких модификациях (но скорость, конечно, надо замерять), но точно проще чем код для Source Generator
Спасибо за дельный комментарий! Вы затронули важные архитектурные моменты, давайте проясним.
Про PostgreSQL и DataTable: Вы абсолютно правы, Npgsql не работает с TVP через DataTable так, как это делает MSSQL. Именно поэтому в Visor реализован паттерн Strategy. Для MSSQL генератор создает код с SqlDataRecord, а для PostgreSQL - использует нативную передачу массивов (Arrays) и композитных типов, которые Npgsql поддерживает из коробки. Никаких DataTable в реализации для Postgres нет, я работаю с драйвером напрямую.
Про DbDataReader vs SqlDataRecord: Идея с DbDataReader отличная для SqlBulkCopy, но я работаю с хранимыми процедурами. В Microsoft.Data.SqlClient параметр типа Structured (TVP) принимает либо DataTable, либо IEnumerable. Передать туда DbDataReader нельзя - API драйвера этого не позволяет. Мой подход с IEnumerable + yield return как раз и реализует ленивый стриминг данных (по сути то, что вы предлагаете), но в рамках ограничений SqlParameter.
Про аллокации и пулинг: Пулинг DataTable - это рабочее решение, но оно требует управления состоянием (вернуть в пул, очистить строки). Мой подход - Zero Allocation. Я вообще не создаю промежуточных буферов (ни DataTable, ни DataRow). Данные читаются из свойств POCO-объекта и сразу пишутся в сетевой поток через SqlDataRecord. Это проще (не нужен пул) и быстрее, так как нет накладных расходов на обслуживание структур данных.
Итог: Я не тяну Microsoft.Data.SqlClient в Postgres. У меня модульная архитектура: Visor.SqlServer зависит от SqlClient, а Visor.PostgreSql - только от Npgsql.
Microsoft.Data.SqlClient параметр типа Structured (TVP) принимает либо DataTable, либо IEnumerable
Либо я что-то путаю, либо внутри оно все-таки работает. Некоторое время назад, как раз, реализовывал то, о чём писал выше (+ npgsql плагин для обработки DataTable, DbDataReader, логика представления композитных типов точно такая же).
Входных ограничений на тип параметра нет, но под котопом он как раз перепроверяется (ссылка выше).
Получается обертка, которая реализует DbDataReader, внутри держим IEnumerable<TRow>, внутри вызова Read вызывается MoveNext для нашей коллекции и все работает отлично (и по ходу дела драйвер дергает методы вроде GetDataTypeName/GetChar/GetString/etc в которых мы отдаем метаданные/данные нашей коллекции).
С Microsoft.Data.SqlClient версии 6.0.2 точно работает.
Вы правы, технически можно подружить Npgsql с DataTable через расширения или плагины, и реализовать DbDataReader для стриминга тоже можно (это валидный паттерн).
Но моя цель была не просто "сделать, чтобы работало", а убрать накладные расходы (Zero Allocation).
Про DataTable: Даже если заставить драйвер его принять, сам DataTable - это тяжелая структура. Его создание, заполнение DataRow (боксинг значений) и хранение в памяти - это лишние аллокации. В высоконагруженной системе (3.5 млн RPS) это создает давление на GC. Мой подход берет обычный List, который уже есть в бизнес-логике, и мапит его в базу без промежуточного создания DataTable.
Про DbDataReader vs IEnumerable: Реализация кастомного DbDataReader - это отличное решение для стриминга, но оно требует написания большого количества бойлерплейта. Метод с yield return new SqlDataRecord (для MSSQL) делает ровно то же самое - обеспечивает потоковую передачу данных драйверу по мере их чтения, но генерируется гораздо проще и работает "из коробки" с SqlClient.
По сути, я боролся за то, чтобы между List в коде и сетевым пакетом в базу было 0 лишних преобразований.
В итоге, как я понимаю, сейчас две отдельные реализации, которые работают совершенно по-разному. В случае единой системы типов можно написать более универсальный код, но для этого потребуется плагин к драйверу npgsql (как в моём случае).
Мысли вслух
Вы абсолютно правы, под капотом это действительно две разные реализации (стратегии). Но это осознанное архитектурное решение.
Я не стал пытаться унифицировать поведение драйверов в рантайме (через плагины или обертки), чтобы не терять в производительности. Моя философия: «Используй нативные механизмы драйвера на максимум».
Для MSSQL нативнее всего стриминг через IEnumerable.
Для PostgreSQL нативнее передача массивов/композитов, которую Npgsql поддерживает из коробки.
Где происходит унификация? Она происходит на этапе компиляции. Я ввел абстракцию VisorDbType (единый Enum типов). Разработчик пишет один и тот же универсальный C# код с атрибутами:
[VisorColumn(0, VisorDbType.Int32)]
public int Id { get; set; }А генератор (Source Generator) выступает в роли того самого "плагина":
Если провайдер MSSQL - он генерирует код с SqlDbType.Int и SqlMetaData.
Если провайдер Postgres - он генерирует код с NpgsqlDbType.Integer и маппингом массивов.
В итоге: код пользователя - универсальный, а исполняемый код - максимально специализированный и быстрый для конкретной БД, без лишних абстракций в рантайме.
Я ввел абстракцию VisorDbType (единый Enum типов).
Но ведь уже есть System.Data.DbType?
Да, DbType есть, но он слишком общий («наименьший общий знаменатель»).
Неточность:
DbType.Stringне говорит драйверу, что это -NVarCharилиVarChar. В MSSQL это может убить производительность индексов (implicit conversion).Нет спецификации: В
DbTypeнетStructured(для TVP),Jsonb(для Postgres) илиArray.
VisorDbType - это надмножество, которое на этапе компиляции однозначно мапится в нативный тип драйвера (SqlDbType или NpgsqlDbType), обеспечивая доступ ко всем возможностям конкретной БД.
Кстати, не посмотрел, метаданные задаются исключительно вручную? Если да, то можно было добавить возможность их загрузки напрямую из БД (потребует на стороне клиента хранимой процедуры).
Позволит не выставлять типы, порядок колонок и т.п вручную. Например, поменяли порядок колонок в типе - на клиенте вылетит исключение, т.к требуется точно соответствие. В случае динамической подгрузки изменения бы подхватились бы при перезапуске сервиса (ну либо инвалидировать в момент перехвата исключения, хотя Fail Fast мне больше нравится).
Или динамический триминг. Добавили новую колонку, которая пока не используется или используется отдельными версиями приложения - вылетит исключение. В случае динамической подгрузки можно было бы откинуть лишние колонки, что может быть полезно для обратной совместимости.
В случае ручного управления каждое изменение в БД типе потребует изменения в коде приложения, пересборки, релиза (или расширения текущего релиза в котором разворачиваются изменения схемы) и т.п
Да, вы правы насчет ручного управления - это плата за скорость и Zero Allocation.
Но я планирую решить это не за счет магии в рантайме (что ударило бы по производительности), а через инструментарий разработчика. В Roadmap проекта на GitHub уже есть пункт «CLI Tool: Database-First scaffolding».
Идея в том, чтобы отдельная утилита подключалась к базе и генерировала (или обновляла) C# контракты и DTO автоматически. Так мы получим удобство Database First, сохранив при этом железобетонную производительность Code First, так как весь код будет готов еще до компиляции.
У меня буллшитометр сбоит или от имени автора статьи с Вами общается ИИ? Слишком неприкрытый слог...
То есть теперь не только статьи с ИИ, но и комменты с ИИ? -_-
я архитектор и разработчик, а не профессиональный копирайтер. Да, я использую LLM, чтобы причесать свои мысли, структурировать аргументы и исправить опечатки. Мне важнее, чтобы вы поняли суть технического решения, а не наслаждались моим авторским стилем перепалки в комментариях. Код в репозитории - настоящий. Бенчи - настоящие. А запятые пусть робот расставляет.
Но вроде все ответы по делу, хотя и воды многовато.
Хороший подход. Но у меня не знтерпрайз -приложения и в общем широкое использование хранимых процедур, где сконцентрирована обработка данных и возврат результатов. Насчет использования tvp пока не задумывался, возможно надо тему лучше изучить. Пока хватает оптимизации в sql server планов запросов, приемов создания sql кода и использования cte,
А ваш ORM поддерживает использование LINQ?
Visor реализует паттерн «Database as an API». Я рассматриваю базу данных не как набор таблиц, к которым можно писать произвольные запросы из кода, а как сервис с четко определенными эндпоинтами (хранимыми процедурами).
Поддержка LINQ требует трансляции Expression Trees в SQL во время выполнения (Runtime). Это:
Создает накладные расходы (CPU/Memory) на компиляцию запроса.
Размывает ответственность (разработчик может написать неоптимальный запрос, который "убьет" базу).
Visor создан для сценариев, где важна максимальная производительность и предсказуемость. Вы вызываете процедуру GetActiveUsers, и вы точно знаете, что она выполнится по заранее подготовленному плану, а оверхед на вызов будет близок к нулю (Zero Allocation).
Если вам нужен LINQ для построения произвольных запросов на лету - EF Core делает это великолепно, и нет смысла с ним конкурировать. Visor занимает другую нишу - High-Load и работа с процедурами.
Рассматривали Linq2DB? Там вполне можно не тащить сырые данные в приложение, а с помощью выражений изменять их
Интересное решение, но сильно специализированное. Кажется, что для подобной задачи отдельный orm не нужен, достаточно генерировать mapper для табличного параметра.
По поводу конструктивной критики :)
Заголовок полный кликбейт :) Не новый orm обогнал dapper, а tvp обогнал insert...into :) Хотя в тексте и упоминается об этом.
В MapToSqlDataRecord шарится SqlDataRecord. Даже если такой финт сейчас работает, нет гарантий, что не поломается в будущем.
Нет ни одного упоминания о linq2db, там тоже есть поддержка tvp.
Спасибо за конструктивную критику! Давайте разберем по пунктам:
1. Про "отдельный ORM не нужен, достаточно маппера": Если задача - просто сгенерировать TVP, то да, маппера достаточно. Но Visor решает задачу комплексно: это и вызов процедур, и маппинг результатов (с проверкой схемы), и управление транзакциями (UnitOfWork), и работа с Output параметрами. Я делаю так, чтобы вызов БД выглядел как типизированный вызов API, скрывая всю "кухню" ADO.NET, а не только часть с параметрами.
2. Про "Кликбейт" и Dapper: Сравнение корректное в контексте "Developer Experience vs Performance". Чтобы заставить Dapper работать с TVP, нужно либо скармливать ему DataTable (что убивает перфоманс), либо писать свой класс-наследник IEnumerable<SqlDataRecord> (что долго). "Из коробки" Dapper предлагает цикл INSERT, и именно с этим сценарием ("как делают обычно") я и сравнивал. Я сделал "быстрый путь" (TVP) путем по умолчанию.
3. Про SqlDataRecord (Важно): Тут вы немного ошиблись в анализе кода. SqlDataRecord не шарится между потоками или вызовами. Он создается как локальная переменная внутри метода-итератора. Каждый вызов метода ImportUsers создает свой собственный экземпляр SqlDataRecord. Переиспользование одного инстанса внутри foreach (yield return record) - это стандартный паттерн для потоковой передачи данных в SqlClient. Драйвер вычитывает данные из record синхронно в момент итерации, поэтому состояние гонки здесь невозможно технически, зато мы получаем Zero Allocation, так как не создаем новый объект на каждую строку.
4. Про linq2db: linq2db - отличная библиотека, и она действительно умеет в TVP. Но она (как и Dapper, и EF) полагается на Expression Trees и компиляцию лямбд в рантайме (или при первом запуске). Visor - это Source Generator. Весь код доступа к данным генерируется в Compile Time. Это дает моментальный старт (cold start) и отсутствие накладных расходов на рефлексию/эмиссию в рантайме. Это просто разные весовые категории и подходы.
1... 2...
Ну здесь каждый решает для себя сам. Все-таки dapper не ограничивает использование tvp, и если использовать мапперы, то работать будет вполне удобно.
3... Про SqlDataRecord (Важно)
Если код, работающий с итератором, будет работать более чем с одной записью, например через буфер, то будут проблемы без всяких гонок. Если же есть гарантия, что в конкретный момент только одна запись обрабатывается, то да, работать будет. Только как получить такую гарантию?
4... Про linq2db
Когда идет работа с процедурами, то Expression Trees не используются, там чистый маппинг.
Еще не нашел реализаций для IAsyncEnumerable, для хайлоада это важно :)
Спасибо за глубокий разбор! Приятно общаться с человеком, который знает внутренности SqlClient.
Про SqlDataRecord и гарантии: Вы абсолютно правы - переиспользование инстанса SqlDataRecord (Flyweight паттерн) опасно, если потребитель решит буферизировать коллекцию (например, сделает .ToList()). В этом случае мы получим список ссылок на один и тот же измененный объект.
Но: В Visor этот метод-генератор (MapToSqlDataRecord) является приватной деталью реализации. Единственный его потребитель - это SqlParameter внутри метода ExecuteAsync. Мы полагаемся на поведение драйвера Microsoft.Data.SqlClient: он работает в поточном режиме. Он запрашивает IEnumerator.MoveNext(), считывает данные из Current (сериализует их в TDS-пакет) и только затем переходит к следующему шагу. Драйвер не сохраняет ссылки на объекты записей. Поэтому гарантия здесь - это специфика работы драйвера, для которого этот код и генерируется. Это осознанная оптимизация ради Zero Allocation.
Про IAsyncEnumerable (чтение): В точку. Сейчас Visor для чтения (SELECT) материализует результат в List. Для сценариев "вычитать миллион строк и обработать" это не идеально. Поддержка IAsyncEnumerable для чтения (чтобы прокидывать SqlDataReader напрямую в поток обработки) - это первый кандидат в бэклог на следующую версию. Спасибо, что подсветили!
Про Dapper и linq2db: Никто не умаляет достоинств этих библиотек. linq2db прекрасен. Visor - это попытка зайти с другой стороны: Source Generators. Мы хотим получить код, который максимально близок к тому, что написал бы человек руками на голом ADO.NET, без накладных расходов на построение деревьев выражений или эмиссию IL в рантайме (что важно, например, для AOT).
1... Мы полагаемся на поведение драйвера Microsoft.Data.SqlClient
Такое поведение драйвера точно документировано? Вот например в исходниках есть класс ParameterPeekAheadValue с полем FirstRecord типа SqlDataRecord, то есть сохраняется ссылка на первый элемент, данные в котором у вас будут перезаписываться. Возможно, это никак не повлияет на результат, а возможно станет причиной плавающих багов. Ну и смотреть, что поменялось в SqlClient, при каждом обновлении, так себе удовольствие :)
3... Source Generators... АОТ
У dapper есть генераторы для aot.
1. Про SqlClient и ParameterPeekAheadValue: Вы правы, риск есть. Но поскольку Visor передает явные SqlMetaData в конструктор записи, драйверу не нужно "подглядывать" вперед для вывода типов. Сейчас это работает стабильно как штатная оптимизация SqlClient для стриминга. Если поведение драйвера изменится - мы просто перенесем new внутрь цикла (Safe Mode), пожертвовав "нулем аллокаций" ради надежности.
2. Про Dapper AOT: Dapper AOT решает проблему запуска (trimming), но не удобства разработки. Там вы всё еще пишете SQL-запросы в строках и вручную настраиваете TVP. Visor же дает строгие контракты (интерфейсы) и полностью автоматизирует рутину с параметрами.
Нашел в документации рекомендацию использовать один SqlDataRecord. Так что получается, что некоторая гарантия все-таки есть :)
Не рассматривали COPY в Postgres?
using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync();
using var writer = connection.BeginBinaryImport(
"""
COPY users (name, is_active, external_id)
FROM STDIN (FORMAT BINARY)
""");
foreach (var user in _data)
{
await writer.StartRowAsync();
await writer.WriteAsync(user.Name);
await writer.WriteAsync(true);
await writer.WriteAsync(Guid.NewGuid());
}
await writer.CompleteAsync();Такой способ копирования ещё быстрее и значительно меньше использует память. Правда не уверен, насколько вписывается в парадигму База данных как API.
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated |
| ---------------- | ----------: | ---------: | ---------: | --------: | --------: | --------: | ----------: |
| Npgsql Bulk Copy | 52.37 ms | 4.443 ms | 4.754 ms | - | - | - | 5.38 KB |
| Visor | 75.31 ms | 0.809 ms | 0.899 ms | - | - | - | 5453.04 KB |
| Dapper | 9,134.09 ms | 254.033 ms | 260.873 ms | - | - | - | 10860.88 KB |
| EF | 239.85 ms | 3.981 ms | 4.088 ms | 5000.0000 | 3000.0000 | 1000.0000 | 69626.94 KB |1. Про COPY (Binary Import): Вы абсолютно правы!
Binary Import(COPY) в Postgres - это ультимативное оружие для скорости. Оно обходит парсер SQL, планировщик и оверхед на вызов функций. Но есть нюанс, который вы сами подметили: парадигма. Visor построен вокруг концепции RPC (Remote Procedure Call). Мы вызываем процедуры/функции.COPY- это прямая запись в таблицу. Если мы начнем генерироватьCOPY, мы потеряем возможность инкапсулировать логику вставки (валидация, триггеры, вставка в связанные таблицы) внутри базы, которую дает хранимая процедура.Тем не менее: Для сценариев "тупой заливки" данных (ETL)
COPYнезаменим. Я рассматриваю возможность добавить специальный атрибут[BulkInsert("table_name")]в будущих версиях, который будет генерировать код сBeginBinaryImport. Это даст выбор: либо умная процедура (через массивы), либо сверхбыстрая заливка (через COPY).2. Про цифры: Ваши замеры (75ms Visor vs 52ms Bulk Copy) показывают, что оверхед на вызов процедуры через массивы - минимален (всего ~20ms на 10к записей). Для большинства бизнес-задач эта разница несущественна по сравнению с удобством использования хранимых процедур. Но за наводку спасибо, это отличный вектор для будущих версий!
«База данных как API»: Почему я написал свою ORM на Source Generators и обогнал Dapper в 800 раз