golang

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

  • вторник, 4 августа 2026 г. в 00:00:08
https://habr.com/ru/companies/ydb/articles/1066130/

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

Я воспользовался этим рефакторингом, чтобы уменьшить количество сетевых запросов для интерактивных транзакций. Клиентские 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 int64
if 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 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(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 — та же оптимизация: коммит уходит с последним Exec
db.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 будет интересно поговорить с теми, кто пользуется базой!