Sergey Sorokin@AcheronSoft
High-Load Архитектор | Visor ORM (.NET 10)
Information
- Rating
- 300-th
- Location
- Армения
- Registered
- Activity
Specialization
Бэкенд разработчик, Архитектор программного обеспечения
Ведущий
From 5,200 €
C#
.NET
Базы данных
REST
PostgreSQL
SQL
ООП
Redis
Docker
Apache Kafka
Это как палец указывающий на луну. Не смотрите на палец, иначе не увидите луну
я архитектор и разработчик, а не профессиональный копирайтер. Да, я использую LLM, чтобы причесать свои мысли, структурировать аргументы и исправить опечатки. Мне важнее, чтобы вы поняли суть технического решения, а не наслаждались моим авторским стилем перепалки в комментариях. Код в репозитории - настоящий. Бенчи - настоящие. А запятые пусть робот расставляет.
1. Про SqlClient и ParameterPeekAheadValue: Вы правы, риск есть. Но поскольку Visor передает явные
SqlMetaDataв конструктор записи, драйверу не нужно "подглядывать" вперед для вывода типов. Сейчас это работает стабильно как штатная оптимизацияSqlClientдля стриминга. Если поведение драйвера изменится - мы просто перенесемnewвнутрь цикла (Safe Mode), пожертвовав "нулем аллокаций" ради надежности.2. Про Dapper AOT: Dapper AOT решает проблему запуска (trimming), но не удобства разработки. Там вы всё еще пишете SQL-запросы в строках и вручную настраиваете TVP. Visor же дает строгие контракты (интерфейсы) и полностью автоматизирует рутину с параметрами.
Спасибо за глубокий разбор! Приятно общаться с человеком, который знает внутренности 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. Про "отдельный 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) и отсутствие накладных расходов на рефлексию/эмиссию в рантайме. Это просто разные весовые категории и подходы.Visor реализует паттерн «Database as an API». Я рассматриваю базу данных не как набор таблиц, к которым можно писать произвольные запросы из кода, а как сервис с четко определенными эндпоинтами (хранимыми процедурами).
Поддержка LINQ требует трансляции Expression Trees в SQL во время выполнения (Runtime). Это:
Создает накладные расходы (CPU/Memory) на компиляцию запроса.
Размывает ответственность (разработчик может написать неоптимальный запрос, который "убьет" базу).
Visor создан для сценариев, где важна максимальная производительность и предсказуемость. Вы вызываете процедуру GetActiveUsers, и вы точно знаете, что она выполнится по заранее подготовленному плану, а оверхед на вызов будет близок к нулю (Zero Allocation).
Если вам нужен LINQ для построения произвольных запросов на лету - EF Core делает это великолепно, и нет смысла с ним конкурировать. Visor занимает другую нишу - High-Load и работа с процедурами.
Да, вы правы насчет ручного управления - это плата за скорость и Zero Allocation.
Но я планирую решить это не за счет магии в рантайме (что ударило бы по производительности), а через инструментарий разработчика. В Roadmap проекта на GitHub уже есть пункт «CLI Tool: Database-First scaffolding».
Идея в том, чтобы отдельная утилита подключалась к базе и генерировала (или обновляла) C# контракты и DTO автоматически. Так мы получим удобство Database First, сохранив при этом железобетонную производительность Code First, так как весь код будет готов еще до компиляции.
Да,
DbTypeесть, но он слишком общий («наименьший общий знаменатель»).Неточность:
DbType.Stringне говорит драйверу, что это -NVarCharилиVarChar. В MSSQL это может убить производительность индексов (implicit conversion).Нет спецификации: В
DbTypeнетStructured(для TVP),Jsonb(для Postgres) илиArray.VisorDbType- это надмножество, которое на этапе компиляции однозначно мапится в нативный тип драйвера (SqlDbTypeилиNpgsqlDbType), обеспечивая доступ ко всем возможностям конкретной БД.Вы абсолютно правы, под капотом это действительно две разные реализации (стратегии). Но это осознанное архитектурное решение.
Я не стал пытаться унифицировать поведение драйверов в рантайме (через плагины или обертки), чтобы не терять в производительности. Моя философия: «Используй нативные механизмы драйвера на максимум».
Для MSSQL нативнее всего стриминг через IEnumerable.
Для PostgreSQL нативнее передача массивов/композитов, которую Npgsql поддерживает из коробки.
Где происходит унификация? Она происходит на этапе компиляции. Я ввел абстракцию VisorDbType (единый Enum типов). Разработчик пишет один и тот же универсальный C# код с атрибутами:
А генератор (Source Generator) выступает в роли того самого "плагина":
Если провайдер MSSQL - он генерирует код с SqlDbType.Int и SqlMetaData.
Если провайдер Postgres - он генерирует код с NpgsqlDbType.Integer и маппингом массивов.
В итоге: код пользователя - универсальный, а исполняемый код - максимально специализированный и быстрый для конкретной БД, без лишних абстракций в рантайме.
Вы правы, технически можно подружить 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 лишних преобразований.
Спасибо за дельный комментарий! Вы затронули важные архитектурные моменты, давайте проясним.
Про 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.