Go SDK для YDB: уменьшаем количество запросов к СУБД для интерактивных транзакций

—

от автора

Привет, Хабр! Меня зовут Степан Пестерников, мы с командой делаем Алису и активно используем СУБД Яндекса. Недавно коллеги из YDB провели большой рефакторинг в YDB Go SDK, где по умолчанию теперь используется новый Query Service.

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

В статье я покажу фрагменты кода и расскажу, как мы делали улучшения, которые вошли в релизы v3.126.0 и v3.126.5 Go SDK. Фрагменты кода получились небольшие, и на их примере удобно показать, как этими оптимизациями пользоваться.

Проблема

Когда бизнес-логика требует интерактивных транзакций — нескольких последовательных запросов, где результат предыдущего влияет на следующий, — каждый шаг превращается в отдельный RPC-вызов к YDB. Это сетевой round-trip — сериализация, передача по сети, обработка на сервере, обратный путь. Типичная интерактивная транзакция выглядит вот так:

От трёх Execute бизнес-логики никуда не деться, но BeginTx и Commit — это служебные round-trip, которые можно объединить с соседними запросами. Два дополнительных RTT на каждую транзакцию — при высоком RPS это становится ощутимым.

Если все запросы можно сложить в одну строку, неявная транзакция через TxControl выполнит всё в один round-trip:

row, err := db.Query().QueryRow(ctx, `    UPDATE accounts SET balance = balance - 100 WHERE id = 1;    UPDATE accounts SET balance = balance + 100 WHERE id = 2;    SELECT balance FROM accounts WHERE id = 1;`,    query.WithTxControl(query.SerializableReadWriteTxControl(query.CommitTx())),)if err != nil {    return err}var balance int64if err := row.Scan(&balance); err != nil {    return err}

Но часто по бизнес-логике запросы взаимосвязаны: нужно сначала прочитать данные, на основе результата что-то обновить, а для части записей — удалить. Склеить такие запросы в одну строку невозможно, и тогда в дело вступают интерактивные транзакции, а с ними и лишние round-trip. Именно здесь описанные ниже оптимизации дают ощутимый выигрыш.

Решение 1: Lazy Transactions — убираем лишний BeginTx

Все работы по этому решению можно посмотреть в pull request #2016.

Lazy Transactions откладывают реальный BeginTx до первого запроса в интерактивной транзакции. Вместо отдельного round-trip для начала транзакции BeginTx объединяется с первым Execute в один вызов.

С Lazy Transactions BeginTx объединяется с первым Execute — получается четыре RTT вместо пяти:

Раньше ydb.WithLazyTx(true) можно было включить только на уровне драйвера — сразу для всех транзакций. Опция Per-transaction позволяет внедрять Lazy Transactions постепенно: включить для отдельных транзакций; убедиться, что всё работает корректно; и потом расширять. Также это даёт гибкое управление — при необходимости для конкретных транзакций можно явно отключить lazy-режим, даже если он включён глобально, в том числе для построения кастомной логики и специфичных бизнес-сценариев.

// Для query client — интерактивная транзакция с несколькими запросамиdb.Query().DoTx(ctx, func(ctx context.Context, tx query.TxActor) error {  // Читаем текущий баланс  row, err := tx.QueryRow(ctx, "SELECT balance FROM accounts WHERE id = $id",    query.WithParameters(ydb.ParamsBuilder().Param("$id").Uint64(accountID).Build()),  )  if err != nil {    return err  }  var balance int64  if err := row.Scan(&balance); err != nil {    return err  }  // Решение принимается на клиенте: без прочитанного баланса следующий шаг не выбрать  if balance < amount {    return errInsufficientFunds  }  // Списываем средства  if err := tx.Exec(ctx, "UPDATE accounts SET balance = $balance WHERE id = $id",    query.WithParameters(ydb.ParamsBuilder().      Param("$id").Uint64(accountID).      Param("$balance").Int64(balance-amount).      Build(),    ),  ); err != nil {    return err  }  // Записываем лог операции  return tx.Exec(ctx, "INSERT INTO operations (account_id, amount, ts) VALUES ($id, $amount, CurrentUtcTimestamp())",    query.WithParameters(ydb.ParamsBuilder().      Param("$id").Uint64(accountID).      Param("$amount").Int64(amount).      Build(),    ),  )}, query.WithLazyTx(true))// Для database/sql — аналогичноretry.DoTx(ctx, db, func(ctx context.Context, tx *sql.Tx) error {  var balance int64  err := tx.QueryRowContext(ctx, "SELECT balance FROM accounts WHERE id = $id", sql.Named("id", accountID)).Scan(&balance)  if err != nil {    return err  }  _, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = $balance WHERE id = $id", sql.Named("balance", balance-amount), sql.Named("id", accountID))  if err != nil {    return err  }  _, err = tx.ExecContext(ctx, "INSERT INTO operations (account_id, amount, ts) VALUES ($id, $amount, CurrentUtcTimestamp())", sql.Named("id", accountID), sql.Named("amount", amount))  return err}, retry.WithLazyTx(true))

Настройка Per-transaction переопределяет настройку драйвера ydb.WithLazyTx(true). Обе опции работают только поверх Query Service. На версиях SDK до 3.130.0 коннектор нужно создавать с ydb.WithQueryService(true), иначе lazy-режим просто не включится.

Решение 2: Commit with Query — убираем лишний Commit

Все работы по этому решению можно посмотреть в pull request #2023.

Зачем делать отдельный round-trip для Commit, если можно отправить коммит вместе с последним запросом в интерактивной транзакции?

Commit объединяется с последним Execute — убираем ещё один RTT:

Использование:

Пример намеренно показан без retry, чтобы не загромождать его. В продакшене интерактивную транзакцию нужно оборачивать в retry.DoTx: YDB прерывает её по TLI, и повторить попытку должен клиент.

tx, err := db.BeginTx(ctx, nil)if err != nil {    return err}defer tx.Rollback() // страхует только ошибки до запроса с WithCommitTxContext// Первые запросы выполняются как обычноvar balance int64err = tx.QueryRowContext(ctx, "SELECT balance FROM accounts WHERE id = $id",    sql.Named("id", accountID)).Scan(&balance)if err != nil {    return err}_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = $balance WHERE id = $id",    sql.Named("balance", balance-amount), sql.Named("id", accountID))if err != nil {    return err}// Последний запрос — коммитим вместе с ним_, err = tx.ExecContext(ydb.WithCommitTxContext(ctx), "INSERT INTO operations (account_id, amount, ts) VALUES ($id, $amount, CurrentUtcTimestamp())",    sql.Named("id", accountID), sql.Named("amount", amount))if err != nil {    return err}return tx.Commit() // по транзакции no-op, но database/sql требует завершить tx, иначе соединение не вернётся в пул// Для query client — та же оптимизация: коммит уходит с последним Execdb.Query().DoTx(ctx, func(ctx context.Context, tx query.TxActor) error {  // ... предыдущие запросы транзакции ...  return tx.Exec(ctx, "INSERT INTO operations (account_id, amount, ts) VALUES ($id, $amount, CurrentUtcTimestamp())",    query.WithParameters(ydb.ParamsBuilder().      Param("$id").Uint64(accountID).      Param("$amount").Int64(amount).      Build(),    ),    query.WithCommit(), // коммиты выполняется на сервере вместе с этим Exec  )}) // DoTx коммитит транзакцию сам, но после WithCommit она уже закоммичена, // поэтому финальный commit внутри DoTx - no-op

Для QueryContext коммит произойдёт после полного вычитывания строк результата:

tx, err := db.BeginTx(ctx, nil)if err != nil {    return err}defer tx.Rollback()if _, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - $amount WHERE id = $id", sql.Named("amount", amount), sql.Named("id", fromID)); err != nil {    return err}if _, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + $amount WHERE id = $id", sql.Named("amount", amount), sql.Named("id", toID)); err != nil {    return err}rows, err := tx.QueryContext(ydb.WithCommitTxContext(ctx), "SELECT balance FROM accounts WHERE id IN ($fromID, $toID)", sql.Named("fromID", fromID), sql.Named("toID", toID))if err != nil {    return err}defer rows.Close()for rows.Next() { /* проверяем итоговые балансы */ }if err := rows.Err(); err != nil {    return err} if err := rows.Close(); err != nil { // транзакция на клиенте завершается    return err}if err := tx.Commit(); err != nil { // no-op: сервер закоммитил транзакцию вместе с запросом    return err}

Порядок здесь важен. Если вызвать tx.Commit() до того, как строки вычитаны, драйвер отправит на сервер ещё один CommitTx — то есть ровно тот round-trip, который мы и убирали.

Комбинируем: Lazy Tx + Commit with Query

Максимальный эффект достигается при совместном использовании обеих оптимизаций — с пяти RTT до трёх. Было пять round-trip, а стало три:

Итого: экономия на каждой интерактивной транзакции

Для интерактивной транзакции с тремя запросами:

Сценарий

Round-trip

Экономия

Без оптимизаций (BeginTx, 3x Execute, Commit)

5

—

Lazy Tx (BeginTx + Execute, 2x Execute, Commit)

4

− 1 RTT

Commit with Query (BeginTx, 2x Execute, Execute + Commit)

4

− 1 RTT

Lazy Tx + Commit with Query

3

− 2 RTT

На каждую интерактивную транзакцию мы убираем до двух RTT — вне зависимости от того, сколько времени занимает один round-trip в конкретной инсталляции.

Помимо прямой экономии latency, сокращение общего времени жизни транзакции уменьшает вероятность конфликтов транзакций за одни и те же строки (TLI — transaction locks invalidation). Чем короче транзакция — тем меньше шанс, что параллельная транзакция затронет те же данные и приведёт к retry.

Как начать использовать

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

Параметры query.WithLazyTx и retry.WithLazyTx доступны начиная с версии SDK 3.126.0, а параметр ydb.WithCommitTxContext — начиная с версии 3.126.5.

Также с версии 3.130.0 Query Service стал дефолтом в database/sql, так что самое время воспользоваться этими оптимизациями тем, у кого установлена YDB. Для максимальной экономии рекомендуется комбинировать оба подхода.

Мы общаемся с нашими пользователями в Telegram и на Хабре: пишите комментарии к этой статье, мне как контрибьютору YDB Go SDK будет интересно поговорить с теми, кто пользуется базой!

ссылка на оригинал статьи https://habr.com/ru/articles/1070138/