Привет, Хабр! Меня зовут Степан Пестерников, мы с командой делаем Алису и активно используем СУБД Яндекса. Недавно коллеги из YDB провели большой рефакторинг в YDB Go SDK, где по умолчанию теперь используется новый Query Service.Я воспользовался этим рефакторингом, чтобы уменьшить количество сетевых запросов от SDK к YDB. Клиентские SDK устанавливают к распределённой СУБД Яндекса gRPC-подключения, поверх которых отправляются низкоуровневые команды. Какие-то из этих команд можно объединять: например, команду начала транзакции и выполнения первого запроса.В статье я покажу фрагменты кода и расскажу, как мы делали улучшения, которые вошли в релизы v3.126.0 и v3.126.5 Go SDK. Фрагменты кода получились небольшие, и на их примере удобно показать, как этими оптимизациями пользоваться. Читать далее

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