Pull to refresh
6
26.1
Sergey Sorokin@AcheronSoft

High-Load Архитектор | Visor ORM (.NET 10)

Send message

Это как палец указывающий на луну. Не смотрите на палец, иначе не увидите луну

я архитектор и разработчик, а не профессиональный копирайтер. Да, я использую LLM, чтобы причесать свои мысли, структурировать аргументы и исправить опечатки. Мне важнее, чтобы вы поняли суть технического решения, а не наслаждались моим авторским стилем перепалки в комментариях. Код в репозитории - настоящий. Бенчи - настоящие. А запятые пусть робот расставляет.

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к записей). Для большинства бизнес-задач эта разница несущественна по сравнению с удобством использования хранимых процедур. Но за наводку спасибо, это отличный вектор для будущих версий!

1. Про SqlClient и ParameterPeekAheadValue: Вы правы, риск есть. Но поскольку Visor передает явные SqlMetaData в конструктор записи, драйверу не нужно "подглядывать" вперед для вывода типов. Сейчас это работает стабильно как штатная оптимизация SqlClient для стриминга. Если поведение драйвера изменится - мы просто перенесем new внутрь цикла (Safe Mode), пожертвовав "нулем аллокаций" ради надежности.

2. Про Dapper AOT: Dapper AOT решает проблему запуска (trimming), но не удобства разработки. Там вы всё еще пишете SQL-запросы в строках и вручную настраиваете TVP. Visor же дает строгие контракты (интерфейсы) и полностью автоматизирует рутину с параметрами.

Спасибо за глубокий разбор! Приятно общаться с человеком, который знает внутренности SqlClient.

  1. Про SqlDataRecord и гарантии: Вы абсолютно правы - переиспользование инстанса SqlDataRecord (Flyweight паттерн) опасно, если потребитель решит буферизировать коллекцию (например, сделает .ToList()). В этом случае мы получим список ссылок на один и тот же измененный объект.

    Но: В Visor этот метод-генератор (MapToSqlDataRecord) является приватной деталью реализации. Единственный его потребитель - это SqlParameter внутри метода ExecuteAsync. Мы полагаемся на поведение драйвера Microsoft.Data.SqlClient: он работает в поточном режиме. Он запрашивает IEnumerator.MoveNext(), считывает данные из Current (сериализует их в TDS-пакет) и только затем переходит к следующему шагу. Драйвер не сохраняет ссылки на объекты записей. Поэтому гарантия здесь - это специфика работы драйвера, для которого этот код и генерируется. Это осознанная оптимизация ради Zero Allocation.

  2. Про IAsyncEnumerable (чтение): В точку. Сейчас Visor для чтения (SELECT) материализует результат в List. Для сценариев "вычитать миллион строк и обработать" это не идеально. Поддержка IAsyncEnumerable для чтения (чтобы прокидывать SqlDataReader напрямую в поток обработки) - это первый кандидат в бэклог на следующую версию. Спасибо, что подсветили!

  3. Про 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 есть, но он слишком общий («наименьший общий знаменатель»).

  1. Неточность: DbType.String не говорит драйверу, что это - NVarChar или VarChar. В MSSQL это может убить производительность индексов (implicit conversion).

  2. Нет спецификации: В DbType нет Structured (для TVP), Jsonb (для Postgres) или Array.

VisorDbType - это надмножество, которое на этапе компиляции однозначно мапится в нативный тип драйвера (SqlDbType или NpgsqlDbType), обеспечивая доступ ко всем возможностям конкретной БД.

Вы абсолютно правы, под капотом это действительно две разные реализации (стратегии). Но это осознанное архитектурное решение.

Я не стал пытаться унифицировать поведение драйверов в рантайме (через плагины или обертки), чтобы не терять в производительности. Моя философия: «Используй нативные механизмы драйвера на максимум».

Для 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 и маппингом массивов.

В итоге: код пользователя - универсальный, а исполняемый код - максимально специализированный и быстрый для конкретной БД, без лишних абстракций в рантайме.

Вы правы, технически можно подружить 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 лишних преобразований.

Спасибо за дельный комментарий! Вы затронули важные архитектурные моменты, давайте проясним.

  1. Про PostgreSQL и DataTable: Вы абсолютно правы, Npgsql не работает с TVP через DataTable так, как это делает MSSQL. Именно поэтому в Visor реализован паттерн Strategy. Для MSSQL генератор создает код с SqlDataRecord, а для PostgreSQL - использует нативную передачу массивов (Arrays) и композитных типов, которые Npgsql поддерживает из коробки. Никаких DataTable в реализации для Postgres нет, я работаю с драйвером напрямую.

  2. Про DbDataReader vs SqlDataRecord: Идея с DbDataReader отличная для SqlBulkCopy, но я работаю с хранимыми процедурами. В Microsoft.Data.SqlClient параметр типа Structured (TVP) принимает либо DataTable, либо IEnumerable. Передать туда DbDataReader нельзя - API драйвера этого не позволяет. Мой подход с IEnumerable + yield return как раз и реализует ленивый стриминг данных (по сути то, что вы предлагаете), но в рамках ограничений SqlParameter.

  3. Про аллокации и пулинг: Пулинг DataTable - это рабочее решение, но оно требует управления состоянием (вернуть в пул, очистить строки). Мой подход - Zero Allocation. Я вообще не создаю промежуточных буферов (ни DataTable, ни DataRow). Данные читаются из свойств POCO-объекта и сразу пишутся в сетевой поток через SqlDataRecord. Это проще (не нужен пул) и быстрее, так как нет накладных расходов на обслуживание структур данных.

Итог: Я не тяну Microsoft.Data.SqlClient в Postgres. У меня модульная архитектура: Visor.SqlServer зависит от SqlClient, а Visor.PostgreSql - только от Npgsql.

Information

Rating
300-th
Location
Армения
Registered
Activity

Specialization

Бэкенд разработчик, Архитектор программного обеспечения
Ведущий
From 5,200 €
C#
.NET
Базы данных
REST
PostgreSQL
SQL
ООП
Redis
Docker
Apache Kafka