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

Привет, Хабр! Меня зовут Степан Пестерников, мы с командой делаем Алису и активно используем СУБД Яндекса. Недавно коллеги из 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. Именно здесь описанные ниже оптимизации дают ощутимый выигрыш.
Все работы по этому решению можно посмотреть в 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-режим просто не включится.
Все работы по этому решению можно посмотреть в 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, который мы и убирали.
Максимальный эффект достигается при совместном использовании обеих оптимизаций — с пяти 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 будет интересно поговорить с теми, кто пользуется базой!