Привет, Хабр! Меня зовут Кирилл Курдюков, и мы с командой делаем YDB (СУБД Яндекса). Как и с языками программирования, популярность СУБД определяется не только их возможностями, но и экосистемой.

В предыдущей статье я рассказал о том, как разработать Java-клиент для распределённой СУБД и интегрировать его с популярными ORM. А из этой статьи вы узнаете, как под капотом работает ADO.NET, почему управление пулом сессий может сильно влиять на ваш код работы с базой данных и какой стратегии обработки ошибок можно придерживаться для разработки отказоустойчивых сервисов. Статья буде�� полезна тем, кто изучает особенности взаимодействия в распределённых системах или просто хочет научиться лучше писать клиентский код, работающий с современными распределёнными системами.

Протокол коммуникации с YDB: распределённость и сессии

За годы работы с централизованными СУБД вроде PostgreSQL мы привыкли, что клиент устанавливает одно TCP-подключение к серверу, отправляет запрос и ожидает ответа. Нужно выполнять несколько запросов параллельно? Устанавливаем больше подключений. Нужно писать на primary, а читать с реплики? Больше подключений. Нужно переключаться на failover? Ну, вы поняли. А потом на сервере заканчиваются подключения.

Распределённая СУБД Яндекса работает по-другому. Для клиентского SDK кластер YDB в нескольких дата-центрах выглядит как набор равноправных узлов. Подключаясь к кластеру, клиентский SDK устанавливает gRPC-подключение к одному из узлов и получает актуальную топологию кластера.

Когда клиенту нужно выполнить транзакционную операцию, клиентский SDK запрашивает у сервера сессию. Сервер выбирает узел, который будет обслуживать сессию (обычно этот тот узел, подключение к которому использовалось для запроса сессии), создаёт на выбранном узле легковесный актор для обработки запросов и возвращает клиенту идентификатор сессии SessionId и идентификатор узла NodeId.

Такая архитектура хорошо масштабируется как со стороны клиента, так и со стороны сервера. В рамках одной сессии операции должны быть последовательны, поэтому если клиенту нужно выполнить параллельно нескол��ко запросов — то он просто запрашивает несколько сессий. Одно gRPC подключение к узлу можно использовать для всех сессий, который обслуживает этот узел. Протокол gRPC работает поверх HTTP/2, использует multiplexing и позволяет отправлять запросы и принимать ответы с использованием всего одного TCP-подключения.

Ценой такого масштабирования становится усложнение клиентского SDK. NodeId может отличаться для разных сессий, и клиенту рекомендуется устанавливать gRPC подключения к нужным узлам, а не полагаться на то, что СУБД будет самостоятельно проксировать запросы.

Ожидается, что клиенты будут запрашивать новые сессии на тех узлах, которые им лучше всего подходят по балансу равномерной нагрузки, времени отклика, предпочтений дата-центра. Поэтому клиентский SDK должен не просто хранить топологию кластера, но и выбирать, к каким узлам устанавливать подключения и запрашивать сессии.

Например, если менеджер смешанной нагрузки, о котором мы уже писали, определит, что узел перегружен — то всем клиентам будет отправлена нотификация о необходимости переподключиться к другому узлу. А ещё узлы могут добавляться, удаляться, на них могут проводить техобслуживание или узел может стать недоступен.

Более того, сессии являются низкоуровневым примитивом, специфичным для YDB. Протоколы взаимодействия с СУБД для популярных языков программирования оперируют высокоуровневыми концепциями «подключений» и «запросов». Чтобы адекватно использовать сетевые ресурсы, процессор, память и при этом обеспечить высокий RPS, клие��тский SDK должен «под капотом» держать пул gRPC-подключений к узлам YDB и пул сессий.

Что нужно C#-приложению для работы с СУБД

В мире .NET популярны ORM-фреймворки. Приложение использует такой фреймворк, например EF Core или Linq2db, для формирования запросов и получения ответов на высокоуровневом DSL LINQ. А фреймворк использует клиентский SDK, чтобы подключиться к конкретной СУБД. Чтобы клиентский SDK можно было использовать с фреймворками, он должен имплементировать несколько абстрактных классов стандарта ADO.NET, главными из которых являются:

  • DbConnection

  • DbCommand

  • DbDataReader

  • DbTransaction

Абстрактный класс DbConnection имплементируется классом YdbConnection. Этот класс запоминает, какую сессию из пула использовать для запросов. Если сессия станет недействительной (например, в случае недоступности узла), то класс получит из пула новую.

При первом использовании YdbConnection клиентский SDK устанавливает подключение к кластеру YDB и инициализирует оба пула: пул gRPC-подключений и пул сессий:

var builder = new YdbConnectionStringBuilder("Host=localhost;Port=2136;Database=local");

await using var ydbConnection = new YdbConnection(builder);
await ydbConnection.OpenAsync(); // <- инициализация advanced транспорта

Абстрактный класс DbCommand имплементирован классом YdbCommand, который отвечает за подготовку SQL-выражений и их выполнение. SQL-парсер автоматически преобразует запросы из «.NET‑стиля» к тому синтаксису, который ожидает YDB:

  • Заменяет параметры с @ на $.

  • Генерирует блок DECLARE для всех параметров запроса, если используется YDB версии до 25.1.

  • Трансформирует конструкции вида IN (@id1, @id2, @id3) в YQL‑стиль IN $ids и собирает значения в отдельный список параметров. Это помогает уменьшить размер запросов и ускоряет компиляцию.

Пример использования YdbCommand:

var ydbCommand = connection.CreateCommand();
ydbCommand.CommandText = """
                         SELECT series_id, season_id, episode_id, air_date, title
                         FROM episodes
                         WHERE series_id = @series_id AND season_id > @season_id
                         ORDER BY series_id, season_id, episode_id
                         LIMIT @limit_size;
                         """;
ydbCommand.Parameters.Add(new YdbParameter("series_id", DbType.UInt64, 1U));
ydbCommand.Parameters.Add(new YdbParameter("season_id", DbType.UInt64, 1U));
ydbCommand.Parameters.Add(new YdbParameter("limit_size", DbType.UInt64, 3U));
var ydbDataReader = await ydbCommand.ExecuteReaderAsync();

Класс YdbTransaction имплементирует абстрактный класс DbTransaction и предназначен для работы с интерактивными (состоящими ��ольше чем из одной операции) транзакциями ADO.NET. Классический пример использования такого интерфейса из документации SqlTransaction выглядит вот так:

try {
    command.CommandText = // Insert into ...;
    command.ExecuteNonQuery();
    command.CommandText = // Insert into ...;
    command.ExecuteNonQuery();
    transaction.Commit();
}
catch (Exception ex)
{
    try {
        transaction.Rollback();
    }
    catch (Exception)
    {
        // throw 
    }
}

Распределённая СУБД Яндекса работает с транзакциями не так, как централизованные СУБД. Вместо того чтобы блокировать выполнение транзакций, YDB использует для транзакций модель «оптимистичных блокировок» OCC. Одновременно выполняемые транзакции не блокируют друг друга, а проверка гарантий ACID выполняется во время коммита. Если транзакция нарушает гарантии (то есть данные были ранее модифицированы другой транзакцией), то YDB автоматически выполняет Rollback.

Так как стандарт ADO.NET создавался ещё в эпоху централизованных СУБД, то он требует явно вызывать метод Rollback в случае любых проблем с транзакций. Для YDB такой вызов Rollback приведёт к тому, что будет сделан лишний сетевой запрос, а на стороне сервера в логе окажется запись «транзакция не найдена». Поэтому клиентский SDK отслеживает состояние транзакций, и если вызывать Rollback для уже отменённой транзакции, то такой вызов будет игнорироваться.

Последний из ключевых абстрактных классов, DbDataReader, имплементируется классом YdbDataReader. Этот класс получает от узла СУБД результаты выполнения запросов и передаёт их клиенту. Через него проходит 95% gRPC-трафика при общении с СУБД. Этот класс:

  • Обрабатывает все сетевые ошибки и при необходимости удаляет ставшей невалидной сессию.

  • Распаковывает данные и предоставляет метаинформацию о текущем ResultSet.

  • Устанавливает флаг InFailed для YdbTransaction, который используется в описанной выше проверке для вызова Rollback.

Все эти классы и остальной код клиентского SDK покрыты ~750 юнит- и интеграционными тестами. Для тестирования на соответствие стандарту .NET мы используем библиотеку AdoNet.Specification.Tests от разработчиков Npgsql/MySqlConnector: это более 2200 кейсов на корректность реализации спецификации провайдера. SLO-тестирование проводится с помощью chaos monkey на реальном 5-узловом кластере, а подключение к Yandex Cloud мы тестируем и для serverless-, и для dedicated-режимов.

Как вы можете видеть, стандарт ADO.NET — это набор соглашений высокого уровня. Когда разработчики конкретной СУБД пишут свою реализацию этого стандарта, то именно они выбирают, как согласовать абстракции стандарта и принципы работы их СУБД. Прочитав эту статью, вы сможете лучше понимать, что происходит под капотом, и избегать некоторых проблем, связанных с протекающими абстракциями.

Управление пулом сессий

Создание новой сессии в YDB — легковесная операция. Тем не менее это запрос по сети и выделение ресурсов на стороне кластера. Чтобы сбалансировать низкое время отклика и утилизируемые ресурсы, клиентский SDK использует пул сессий. Новые сессии берутся из пула, неиспользуемые удаляются, а если сессии в пуле закончились — то клиентский SDK создаёт и добавляет новые.

Разрабатывая управление пулом сессий, я вдохновлялся Java, где реализация интерфейса java.sql.DataSource стало настоящим полем соревнования между различными пулами соединений. Уже в 2015 году победителем стал пул соединений HikariCP (горячие обсуждения в статье на Хабре).

Архитектура HikariCP похожа на ConcurrentBag, но вместо сложной логики work stealing используется общий список сессий с LIFO-семантикой (напоминающий стек).

Использование стека для хранения сессий позволяет управлять пулом с помощью простой и понятной настройки idle timeout. Пулы сессий, основанные на FIFO-контейнерах, используют более сложную эвристику и несколько настроек. Например, Npgsql использует контейнер Channel и две независимые настройки: Connection Idle Lifetime и Connection Pruning Interval.

В реальных приложениях пул почти никогда не работает на пределе, так что стабильность и понятная диагностика становятся важнее, чем сложные эвристики.

За основу реализации я взял пул из Npgsql и сделал следующие модификации:

  • Channel (который в стандартной библиотеке использует мьютексы) заменил на lock-free ConcurrentStack.

  • Добавил дополнительную очередь ожидания для задач, которые ждут сессию, если пул уже достиг максимального размера.

Чтобы эффективно очищать стек, сессии дополнительно хранятся в массиве, который удобно итерировать. А чтобы исключить состояние гонки, используются CAS-переходы по состояниям сессии: In (в пуле), Out (у клиента), Clean (помечена для удаления).

LIFO-lock-free пул по производительности уверенно лидирует в большинстве многопоточных сценариев — особенно при высокой конкуренции, когда число потоков превышает MaxPoolSize. В самом типичном кейсе — когда много (но меньше MaxPoolSize) параллельных потоков берут/возвращают сессии, LIFO-пул показывает себя быстрее других вариантов.

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

Нагрузка для Npgsql
Нагрузка для Npgsql
«Рубцы» показывают дрожание пула сессий
«Рубцы» показывают дрожание пула сессий
Меняем контейнер с FIFO на LIFO
Меняем контейнер с FIFO на LIFO
«Рубцы» исчезли, пул не дрожит!
«Рубцы» исчезли, пул не дрожит!

Контроль ошибок

Реализация ADO.NET и пул сессий, о которых я рассказал в предыдущих параграфах, позволяют клиентскому коду эффективно делать запросы к СУБД. Но если в процессе выполнения запроса что-то пошло не так, то клиентский SDK должен предлагать программисту простой и понятный способ контроля.

Абстрактный класс ADO.NET DbException в клиентском SDK имплементирован классом YdbException. Этот класс используется как исключение для всех типов ошибок:

  • Транспортные — сетевые сбои, таймауты, отмены.

  • Серверные — временная перегрузка кластера YDB, отмена транзакции по причине конфликта (Transaction Lock Invalidated) и тому подобное.

Важным атрибутом объектов этого класса является IsTransient. Он установлен в true, если операцию, завершившуюся с ошибкой, можно попробовать повторить. Например, если произошёл разрыв сетевого соединения или отмена транзакции, то повторение операции может завершиться успешно. А если, например, операция завершилась неудачей по причине отсутствия таблицы, то повторять такую операцию смысла не имеет.

Флаг IsTransient будет установлен в false в случае gRPC-ошибки Unavailable. Такая ошибка возникает, если сетевое соединение было разорвано до того, как от сервера YDB была получена информация об успехе или неудаче выполнения операции. В такой ситуации операция могла как выполниться, так и нет. И повторять её можно только в том случае, если она идемпотентна.

Ключевым в дизайне контроля ошибок является ответ на вопрос «что делать дальше?». Коды ошибок явно показывают программистам ассортимент возможных действий, самое частое из них — повторная попытка проведения операции, для безопасного автоматического выполнения которой используется флаг IsTransient.

Повторные попытки и jitter

В распределённых СУБД с optimistic concurrency control самая частая ошибка на стороне клиентского SDK — это конфликты транзакций.

YDB обеспечивает наивысший уровень изоляции транзакций, serializable, проверяя конфликты между параллельными транзакциями в момент коммита. Если такой конфликт обнаружен, то транзакция отменяется и её нужно выполнить заново. Такой подход позволяет обеспечить неограниченное горизонтальное масштабирование: количество узлов в кластере и количество одновременно выполняемых транзакций не влияют на время успешного выполнения каждой индивидуальной транзакции.

Если множество клиентов одновременно пытаются обновить строки с пересекающимися ключами, то возникает проблема, похожая на thundering herd. Один клиент выполнит обновление успешно, а транзакции всех остальных будут отменены. Если они сразу же выполнят повторную попытку, то ситуация повторится, создавая ненужную нагрузку на серве��.

Традиционно для решения подобных проблем используется jitter: добавление задержки между повторными попытками. Простой экспоненциальный backoff без jitter проблему не решает: вызовы по-прежнему группируются:

В своей статье разработчики DynamoDB предлагают несколько техник jitter с разной сходимостью. При этом экспоненциальный backoff без jitter настолько проигрывает, что не помещается по шкале рядом с другими вариантами:

Мы сформулировали следующую политику повторных попыток, которая автоматизирована с помощью RetryableYdbConnection:

  • При ошибках, не требующих backoff, повторная попытка выполняется немедленно. Например, BadSession, когда сессия стала невалидной и нужно создать новую.

  • При ошибках Aborted, включая Transaction Lock Invalidated, нужно использовать Full Jitter с fast backoff. Он в среднем требует меньше повторных попыток, чем decorrelated jitter.

  • При сетевых сбоях и при статусе сервера overloaded нужно использовать Equal Jitter. В случае overloaded также нужно использовать более медленный slow backoff.

Использование этой политики повторных попыток позволяет минимизировать конфликты транзакций, что, в свою очередь, снижает нагрузку на сервер и уменьшает время успешного выполнения операций. При проектировании распределённых систем повторные попытки запросов с jitter нужно закладывать в архитектуру: кластер YDB «живой», он постоянно адаптируется к нагрузке и в любой момент может быть масштабирован без прерывания работы.

Автоматические повторные попытки

Политику повторных попыток можно автоматически использовать с помощью YdbDataSource.OpenRetryableConnectionAsync. Кроме настроек по умолчанию и описанной выше YdbRetryPolicyConfig, можно настроить политику под себя, реализовав интерфейс IRetryPolicy. А с помощью флага EnableRetryIdempotence можно указать, что операции идемпотентны и для них безопасно выполнять повторные попытки в случае таких ошибок, как описанная выше gRPC Unavailable.

Такие подключения работают как обычные, но не поддерживают интерактивные транзакции и материализуют все результаты в память, чтобы корректно обрабатывать разные типы ошибок. Они не подходят для длинных потоковых чтений, для которых нужно использовать YdbConnection.

Также в YdbDataSource есть набор вспомогательных методов, которые оборачивают выполнение (с транзакцией или без) в политику повторных попыток:

await _dataSource.ExecuteInTransactionAsync(async ydbConnection =>
{
    var count = (int)(await new YdbCommand(ydbConnection)
        { CommandText = $"SELECT count FROM {tableName} WHERE id = 1" }
        .ExecuteScalarAsync())!;

    await new YdbCommand(ydbConnection)
    {
        CommandText = $"UPDATE {tableName} SET count = @count + 1 WHERE id = 1",
        Parameters = { new YdbParameter { Value = count, ParameterName = "count" } }
    }.ExecuteNonQueryAsync();
},
new YdbRetryPolicyConfig { MaxAttempts = 5 });

Всё готово

Современные клиентские SDK для СУБД гораздо сложнее, чем просто «установить подключение, выполнить запрос, получить ответ». Нужно реализовывать стандарты, управлять пулом сессий, продумывать политику контроля ошибок и повторных попыток, разрабатывать диалекты для популярных фреймворков.

Стандарт ADO.NET хорошо абстрагирует базу данных и позволяет писать один и тот же код для работы с YDB, SQLite или PostgreSQL. Но если адаптировать код для распределённых систем с их легковесными сессиями и оптимистичной блокировкой транзакций, то можно создавать сервисы с неограниченным горизонтальным масштабированием без сложных архитектурных решений.

YDB (СУБД Яндекса) доступна как опенсорс-проект и как коммерческая сборка с открытым ядром. Вы можете запустить её на своих серверах или воспользоваться нашим managed-решением в Yandex Cloud.

Мы общаемся с нашими пользователями в Telegram (есть отдельный чат для .NET) и на Хабре: пишите комментарии к этой статье, мне как разработчику СУБД будет интересно поговорить с теми, кто СУБД пользуется!