designing-application-transactions について
CockroachDB を使うアプリケーション開発者向けのトランザクション設計ガイドです。トランザクションの範囲、リトライ、並行制御、クエリパターンを取り上げ、正確性と性能の向上を支援します。
主な機能
- 01トランザクションの短時間化
- 02バックオフによるリトライ処理
- 03暗黙的・明示的トランザクションの選択
- 04業務上の不変条件を原子的な SQL で実装
- 05ホット行への選択的なロック
- 06集合指向操作とキーセットページネーション
活用シーン
- 01アプリケーションのトランザクション境界の設計
- 02競合時のシリアライゼーションエラーへの対応
- 03読み取り・変更・書き込み処理の原子的 SQL 化
- 04レポートや大規模結果セットのデータアクセス最適化
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ cockroachlabs/ cockroachdb-skills --skill designing-application-transactionsインストール後、Claude Code で/designing-application-transactionsを入力して読み込まれたか確認します。
同梱ファイルreferences/monitoring-and-concurrency-testing.md
詳細
アプリケーションのトランザクション設計
CockroachDB 上で正確、高性能、かつ堅牢なアプリケーションを構築するために必要な設計原則と実装パターンを、アプリケーション開発者向けに解説します。トランザクションのスコープ設定や再試行ロジックから、接続プーリングや可観測性まで、幅広く扱います。
SQL スキルを補完する内容です: SQL 構文、スキーマ設計、クエリ最適化については、cockroachdb-sql を参照してください。競合がある状況でトランザクションの記述方法をベンチマークするには、benchmarking-transaction-patterns を参照してください。
このスキルを使用する場面
- CockroachDB アプリケーションのトランザクション境界を設計する
- 指数バックオフを使ったクライアント側の再試行ロジックを実装する
- 暗黙的トランザクションと明示的トランザクションのどちらを使用するか判断する
- 楽観的同時実行制御と悲観的同時実行制御のどちらを選ぶか判断する
- 読み取り・変更・書き込みのループをアトミックな SQL に置き換える
- 接続プール(HikariCP、pgbouncer など)を設定する
- OFFSET/LIMIT の代わりにキーセットページネーションを実装する
- レポートや分析クエリでフォロワー読み取りを使用する
- ビジネスオーケストレーションとデータベーストランザクションを分離する
- パフォーマンスとセキュリティのためにプリペアドステートメントを使用する
- SELECT * ではなく、明示的な列の射影を選択する
- 同時実行時のアプリケーションの動作をテストする
- アプリケーションレベルのデータベースパフォーマンスを監視する
前提条件
- CockroachDB の SERIALIZABLE 分離レベルに関する知識
- ACID トランザクションのセマンティクスに関する理解
- トランザクション設計の変更に使用できるアプリケーションのソースコード
- CockroachDB クラスターへの SQL 接続(テストと検証用)
手順
1. トランザクションを短時間で完了させる
トランザクションには、1 つのアトミックな状態変更に必要な最小限の SQL 操作だけを含めてください。CockroachDB のトランザクション内に、リモート API 呼び出し、サービス間リクエスト、ループ、高コストな計算、意図的な待機を含めないでください。
CockroachDB の分散型オプティミスティック同時実行アーキテクチャでは、長時間にわたるトランザクションはインテントの存続時間、競合、再試行の可能性を増加させます。
アンチパターン:
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
paymentGateway.charge(order); // external call inside TX
}
推奨される方法 — ロジックを分割する:
@Transactional
public void createOrderRecord(Order order) {
orderRepository.save(order);
}
// Outside the transaction
paymentGateway.charge(order);
重要な理由:
- アクティブなインテントが同時実行する書き込みをブロックし、クラスターのスループットを低下させます
- 競合するトランザクションで
40001再試行エラーが発生しやすくなります - 再試行されるトランザクション内の外部処理が 2 回実行され、重複した副作用が発生する可能性があります
- 長時間のトランザクションは接続とメモリを占有し、同時実行性を低下させます
2. 単一ステートメントには暗黙的トランザクションを使用する
CockroachDB は、オートコミットモードでは個々の SQL ステートメントを自動的にトランザクションとしてラップします。単一の INSERT、UPDATE、DELETE、または SELECT ステートメントを明示的な BEGIN/COMMIT で囲まないでください。
推奨:
INSERT INTO orders (id, status)
VALUES (gen_random_uuid(), 'open');
避けるべき方法:
BEGIN;
INSERT INTO orders (id, status)
VALUES (gen_random_uuid(), 'open');
COMMIT;
メリット: コードパスの簡素化、レイテンシの低減(ラウンドトリップの削減)、リソース使用量の削減に加え、単一ステートメントのトランザクションは CockroachDB が自動で再試行しやすいため、再試行に関する懸念も少なくなります。
3. グループ化したステートメントには明示的なトランザクションを使用し、リトライを処理する
複数の SQL 操作をまとめて成功または失敗させる必要がある場合は、BEGIN/COMMIT を使った明示的なトランザクションを使用します。CockroachDB はデフォルトで SERIALIZABLE 分離レベルを使用するため、競合が発生する状況でトランザクションを正しく実行するには、リトライが通常の処理の一部となります。
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
指数バックオフを使ったクライアント側のリトライループ:
import random
import time
def execute_with_retry(conn, txn_logic):
backoff = 0.1
while True:
try:
with conn.transaction() as txn:
txn_logic(txn)
return
except SerializationFailure:
time.sleep(backoff + random.uniform(0, 0.1))
backoff = min(backoff * 2, 2.0)
cockroach_restart セーブポイントプロトコルを使った高度なリトライ:
BEGIN;
SAVEPOINT cockroach_restart;
-- transactional work
RELEASE SAVEPOINT cockroach_restart;
COMMIT;
警告:一般的なセーブポイントはリトライの仕組みとして機能しません。 CockroachDB は 40001 シリアライゼーションエラーが発生すると、トランザクション全体を中断します。通常のセーブポイントに対して ROLLBACK TO SAVEPOINT を使用しても、復旧できません。トランザクションは中断された状態のままです。サポートされているのは、クライアントがエラーを捕捉し、セーブポイントまでロールバックして処理を再実行する、SAVEPOINT cockroach_restart という特殊なプロトコルのみです。ほとんどのアプリケーションでは、トランザクション全体をリトライするループのほうがシンプルで、推奨されます。
SQLSTATE の指針:
| コード | 意味 | 対応 |
|---|---|---|
40001 | シリアライゼーション/リトライ可能 | バックオフとジッターを適用し、作業単位全体をリトライします |
40003 | 結果が不明/コミットの成否が不確定 | 冪等でない処理を安易に再実行しないでください |
08xx / 57xx | ネットワークまたはサーバーの一時的な問題 | コミット結果が不明な場合を考慮して、慎重にリトライします |
23xxx | 制約違反およびアプリケーションエラー | 通常、リトライすべきではありません |
4. 該当する場合は読み取り専用トランザクションを指定する
読み取り専用トランザクションはデータの取得のみを行い、書き込みは行いません。読み取り専用として指定すると、CockroachDB は不要な書き込みインテントを回避し、書き込み処理との競合を減らし、フォロワー読み取りや有界ステイルネス読み取りを有効にできます。
BEGIN;
SET TRANSACTION READ ONLY;
SELECT * FROM customers WHERE region = 'US-East';
COMMIT;
5. 不変条件を SQL に組み込む — 読み取り・変更・書き込みのループを避ける
状態をアプリケーションコードに取得し、メモリ上で変更してから書き戻す方法は避けてください。代わりに、アトミックな SQL、制約、条件付き UPDATE、UPSERT、INSERT ... ON CONFLICT、CTE ベースの変更処理を使用します。
アンチパターン:
balance = db.fetch("SELECT balance FROM accounts WHERE id = 123")
balance += 100
db.execute("UPDATE accounts SET balance = %s WHERE id = 123", (balance,))
推奨されるアトミックな SQL:
UPDATE accounts
SET balance = balance + 100
WHERE id = 123;
不変条件を適用する条件付き書き込み:
UPDATE customer_daily_limits
SET used_total = used_total + $2
WHERE customer_id = $1
AND day = current_date
AND used_total + $2 <= daily_limit;
アトミックな CTE パターン:
WITH limit_row AS (
SELECT customer_id, day
FROM customer_daily_limits
WHERE customer_id = $1 AND day = current_date
FOR UPDATE
), spend AS (
UPDATE customer_daily_limits AS l
SET remaining_limit = l.remaining_limit - $2,
used_total = l.used_total + $2
FROM limit_row
WHERE l.customer_id = limit_row.customer_id
AND l.day = limit_row.day
AND l.remaining_limit >= $2
RETURNING l.customer_id, l.day
), ins AS (
INSERT INTO transfers (customer_id, amount, direction, created_at)
SELECT $1, $2, 'debit', now()
FROM spend
RETURNING id AS transfer_id
)
SELECT transfer_id FROM ins;
主な手法:
- アトミックな更新を使用します:
UPDATE ... SET col = col + 1 - 楽観的同時実行制御には、WHERE 句でバージョンまたはタイムスタンプを確認します
UNIQUE、CHECK、NOT NULL、FOREIGN KEY制約でビジネスルールを適用します- 書き込み前に存在確認のための読み取りを行う代わりに、
UPSERTまたはINSERT ... ON CONFLICTを使用します - CTE を使って複数ステップのロジックをアトミックに保ちます
6. SELECT ... FOR UPDATE は選択的に使用する
CockroachDB はデフォルトで楽観的同時実行制御を使用し、ほとんどのワークロードで適切に機能します。頻繁に競合する行や、読み取り後に書き込む処理で競合が多い場合は、SELECT ... FOR UPDATE を使うと、競合する処理同士を待機させて競争させないことで、リトライの頻発を抑えられます。
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
次の場合に使用します:
- 多数の同時トランザクションによって同じ行が頻繁に更新される
- 楽観的リトライによって処理が過度に繰り返されている
- 書き込み前の一貫性が必要である(在庫、金融取引)
直感に反する競合に関する知見: 同じ競合の激しい行を対象とするアプリケーションのポッドやスレッドを増やしても、スループットは向上しません。むしろ低下します。同じ行に対して N 個の書き込み処理が同時に実行される場合、各ラウンドでコミットできるのは 1 つだけです。残りの N-1 個は 40001 で中断され、リトライが必要になります。競合の激しいデータに対する同時実行数が増えるほど、無駄な処理が増え、TPS は低下します。解決策として、SELECT ... FOR UPDATE でアクセスを直列化する、アトミックな UPDATE SET balance = balance + amount を使って読み取り・変更・書き込みのサイクルをなくす、または複数の行に書き込みを分散する方法があります。
トレードオフ: 悲観的ロックを使いすぎると、待ち行列やデッドロックが発生する可能性があります。競合の激しいワークロードや頻繁に競合が発生する処理に限って使用してください。
7. 1 行ずつのループではなく、集合ベースの操作を使用する
CockroachDB は、多数の小さなクライアント主導のステートメントより、集合指向の SQL で最も高いパフォーマンスを発揮します。これにより、ラウンドトリップを減らし、競合が発生する時間を短縮し、スループットを向上させます。
1 行ずつ処理するアンチパターン:
for row in rows:
db.execute(
"UPDATE accounts SET balance = balance + 10 WHERE id = %s",
(row.id,)
)
推奨される集合ベースの処理:
UPDATE accounts
SET balance = balance + 10
WHERE region = 'US-East';
バッチ INSERT:
INSERT INTO trades (id, symbol, price)
VALUES
(1, 'AAPL', 180),
(2, 'GOOG', 125),
(3, 'AMZN', 140);
UNNEST を使用したバッチ UPDATE:
WITH incoming AS (
SELECT *
FROM UNNEST(
ARRAY['u1', 'u2', 'u3']::STRING[],
ARRAY['active', 'inactive', 'active']::STRING[]
) AS t(id, new_status)
)
UPDATE users AS u
SET status = incoming.new_status,
updated_at = now()
FROM incoming
WHERE u.id = incoming.id;
LIMIT を使用したメンテナンス処理のバッチ化:
DELETE FROM sessions
WHERE expires_at < now()
ORDER BY expires_at
LIMIT 10000;
ORDER BY を指定すると、バッチが決定的になり、実行を重ねるごとに処理が進みます。指定しない場合、CockroachDB は反復のたびに異なる対象を選ぶことがあります。
JDBC バッチ処理(Java): 行ごとの executeUpdate ではなく、addBatch/executeBatch を使用してください。これにより、N 回の個別のラウンドトリップではなく、すべての行を 1 回のネットワークラウンドトリップで送信できます。頻繁に通信するワークロードでは、トランザクションレイテンシーの約 50%を占めることもあるアイドル時間をなくせます。
宣言的 TTL:
-- created_at must be TIMESTAMPTZ; the expression's result type must also be TIMESTAMPTZ.
-- Cast if the source column is plain TIMESTAMP.
ALTER TABLE events
SET (ttl_expiration_expression = '(created_at + INTERVAL ''7 DAY'')::TIMESTAMPTZ');
8. 重要度の低いクエリではフォロワー読み取りを使用する
分析、ダッシュボード、表示を目的としたクエリの多くは、絶対的に最新の値を必要としません。CockroachDB は、フォロワーレプリカから低レイテンシーで読み取るフォロワー読み取りと、許容範囲内の古さの読み取りに対応しています。
基本的なフォロワー読み取り:
SELECT * FROM orders
AS OF SYSTEM TIME '-5s';
許容範囲内の古さの読み取り:
SELECT * FROM inventory
AS OF SYSTEM TIME with_max_staleness(INTERVAL '10s');
重い読み取りの読み書き分離パターン: ワークフローで大きなペイロード(例:KYC JSON ドキュメント)を読み取った後にステータスフィールドを更新する場合は、次の 3 つのフェーズに分けます。(1)競合のないスナップショットを取得するため、トランザクションの外で AS OF SYSTEM TIME を使って読み取る。(2)アプリケーション層で処理する。(3)短時間の書き込み専用トランザクションを開始する。これにより、重い読み取り中に書き込みインテントを保持せずに済みます。
使用する場面: ダッシュボード、分析、ETL、表示専用の読み取り、または読み取りと書き込みを分離できる大きなペイロードを扱うワークフロー。
避ける場面: 後続の書き込み判断で、トランザクションの最新状態が必要なワークフロー。
9. OFFSET/LIMIT ではなく、キーセットページネーションを使用する
OFFSET が大きくなるほど、CockroachDB はより多くの行をスキャンして破棄しなければなりません。キーセットページネーションでは、直前のページの最終行の順序キー値を使って、次のページへ直接移動します。
OFFSET/LIMIT(深いページでは非効率):
SELECT id, order_date, customer_id
FROM orders
ORDER BY id
LIMIT 100 OFFSET 5000;
キーセットページネーション(推奨):
SELECT id, order_date, customer_id
FROM orders
WHERE id > 5000
ORDER BY id
LIMIT 100;
複数列のキーセット:
SELECT id, created_at, customer_id
FROM orders
WHERE (created_at, id) > ('2025-01-01 00:00:00', 5000)
ORDER BY created_at, id
LIMIT 100;
トレードオフ: キーセットページネーションは、次/前への移動には最適ですが、「73 ページ目へ移動」のような任意のページジャンプを行う UX には適していません。
10. パフォーマンスとセキュリティのために、プリペアドステートメントを使用する
プリペアドステートメントはクエリ構造を再利用し、新しい値をバインドします。これにより、実行計画の再利用でパフォーマンスが向上し、SQL インジェクションを防止できます。
安全でない動的文字列の連結:
query = f"SELECT * FROM users WHERE username = '{user_input}'"
cursor.execute(query)
プリペアド/パラメーター化された実行:
cursor.execute("SELECT * FROM users WHERE username = %s;", (user_input,))
実行計画の再利用:
PREPARE get_balance AS
SELECT balance FROM accounts WHERE id = $1;
EXECUTE get_balance(1001);
EXECUTE get_balance(2002);
11. SELECT * ではなく、列を明示して取得する
必要な列だけを選択してください。SELECT * はネットワーク転送量、メモリ使用量、CPU コストを増やし、より絞り込んだインデックスのみのスキャンを妨げます。
-- Avoid
SELECT * FROM users WHERE id = 101;
-- Preferred
SELECT name, email FROM users WHERE id = 101;
スキーマ変更の影響: 後からスキーマに profile_picture BYTEA を追加した場合、SELECT * を使うクエリは自動的にその追加データも取得します。列を明示して取得すれば、このような隠れたパフォーマンス低下を避けられます。
12. 負荷を分散するキーとインデックスを設計する
連番または単調増加する主キーは、書き込みホットスポットを生みます。キーとインデックスは、読み取りと書き込みが各レンジに均等に分散するよう設計してください。
ホットスポットを生むアンチパターン:
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id UUID,
region STRING
);
ランダム化されたキー:
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID,
region STRING
);
ハッシュシャーディングインデックス:
CREATE INDEX orders_by_id_hash
ON orders (id)
USING HASH SHARDED WITH BUCKET_COUNT = 16;
自然な分散を実現する複合キー:
CREATE TABLE sales (
region_id STRING,
order_id UUID DEFAULT gen_random_uuid(),
PRIMARY KEY (region_id, order_id)
);
クラスター全体で明示的な PK を強制する:
SET CLUSTER SETTING sql.defaults.require_explicit_primary_keys.enabled = true;
13. コネクションプーリングを設定する
データベースへの新しい接続を開くにはコストがかかります。プーリングでは、確立済みの接続を再利用してパフォーマンスを向上させ、過負荷を防ぎます。
HikariCP のガイダンス:
maximumPoolSize: (vCPUs * 4) / number_of_pool_instances
minimumIdle: equal to maximumPoolSize
maxLifetime: 30 min (add jitter +/- 5 min)
idleTimeout: 5-10 min typical
keepaliveTime: slightly shorter than infrastructure timeout (~5 min)
connectionTimeout: 10-30 s typical
autoCommit: true unless using explicit transactions only
安定した設定の例:
maximum-pool-size: 12
minimum-idle: 12
max-lifetime: 1800000
idle-timeout: 600000
keepalive-time: 300000
connection-timeout: 10000
auto-commit: true
pool-name: ingestionPool
14. ビジネスロジックとデータベースロジックを分離する
CockroachDB は、ACID に準拠した読み取りと書き込み、およびスキーマレベルの整合性を管理します。アプリケーション層では、ワークフロー、外部サービス、キュー、長時間実行される処理を調整します。
トランザクション内:
- 読み取り、書き込み、制約、短時間で完了するガード付きの状態遷移
トランザクション外:
- HTTP 呼び出し、RPC/サービス呼び出し、メール、決済プロバイダー、キューへの発行
非同期ワークフローパターン:
def handle_order(order):
db.execute("INSERT INTO orders (id, status) VALUES (%s, %s)", (order.id, 'PENDING'))
publish_event('process_order', {'order_id': order.id})
15. トランザクションのペイロード上限 16MB を守る
CockroachDB では、トランザクションのペイロード上限は実質的に約 16MB です。この上限は、個々の行だけでなく、単一のトランザクションで書き込まれるデータの合計に適用されます。
上限に達する 2 つの例:
- 1 つの大きな行(例:15MB の JSON ドキュメント)
- 1 つのトランザクション内の複数の中程度のサイズの行(例:500KB の INSERT を 25 回実行すると 12.5MB)
ガイドライン:
- 個々の行は 1MB 未満にします
- トランザクションのペイロード合計は 4MB 未満にします
- トランザクション内のステートメント数は 10 未満に制限します
- 大きなドキュメントは 64~256KB のチャンクに分割します
- 1MB を超える BLOB はデータベース参照とともにオブジェクトストレージ(S3/GCS)に保存します
- 複数ステートメントのトランザクションは小さなバッチに分割します(5~10 ステートメントごとにコミットします)
上限を超えると、split failed while applying backpressure to Put エラーが発生します。 大きな Raft 提案によってコンセンサスがブロックされ、レンジの分割が停滞し、システムにバックプレッシャーが適用されます。
16. セッションガードレールを使用する
開発時やテスト時に、実行が止まらないクエリや WHERE 句の欠落を検出するため、セッションレベルのガードレールを設定します。
SET transaction_rows_read_err = 10000;
SET transaction_rows_written_err = 1000;
これらのしきい値を超えたトランザクションは、クラスターのリソースを消費し続けるのではなく、明示的なエラーを返して失敗します。
17. 並行実行下でテストし、最適化する
単一ユーザーでの正しさだけでは不十分です。実際の状況に即した並行実行でテストし、再試行、ホットスポット、競合、ワークロード固有のボトルネックを明らかにします。
クイックスタート:
cockroach workload init bank 'postgresql://root@localhost:26257?sslmode=disable'
cockroach workload run bank --concurrency=64 --duration=10m
競合クエリ、検証チェックリスト、Prometheus メトリクスの詳細については、monitoring-and-concurrency-testing を参照してください。
18. パフォーマンスと競合を監視する
EXPLAIN ANALYZE、crdb_internal.transaction_contention_events、DB Console SQL Activity、Key Visualizer を使用して、クエリのレイテンシー、競合、再試行、データ分布を積極的に監視します。
ライブ競合クエリ、Prometheus メトリクス、外部監視との統合については、monitoring-and-concurrency-testing を参照してください。
判断ガイド
| シナリオ | 推奨パターン |
|---|---|
| 単一の SQL ステートメント | 暗黙的なトランザクション(自動コミット) |
| 複数のステートメントをすべて成功または失敗させる | 再試行ループを備えた明示的なトランザクション |
| ホットな行への書き込み前に現在の状態を読み取る | SELECT ... FOR UPDATE |
| 履歴、表示、レポート用の読み取り | AS OF SYSTEM TIME /フォロワー読み取り |
| メモリ内のレコードのバッチ | UNNEST / VALUES /バッチ SQL |
| 1 回の操作で複数段階のビジネスルールを適用する | 単一ステートメントの CTE |
安全性に関する考慮事項
40001シリアライゼーションエラーには必ず再試行ロジックを実装します- 再試行で副作用が重複しないよう、操作を冪等にします(
INSERT ... ON CONFLICT DO NOTHINGを使用します) - 書き込みの前提条件として、古いスナップショットからの読み取りを使用しないでください
- 本番環境で、データを変更するクエリに対して
EXPLAIN ANALYZEを実行しないでください - トラフィックの多い時間帯に、高トラフィックのテーブルへインデックスを追加する場合は注意してください
参考資料
- CockroachDB のトランザクションに関するドキュメント
- クライアント側の高度なトランザクション再試行
- SQL パフォーマンスのベストプラクティス
- フォロワー読み取りと有界な古さ
- ステートメントのパフォーマンスを最適化する
- 行レベル TTL
- スキーマ設計とインデックス
- SQL インジェクションの防止
- アーキテクチャ:トランザクションレイヤー
- JPA のベストプラクティス:明示的および暗黙的なトランザクション
- トランザクション再試行の失敗を詳しく解説
- 複数ステートメントと単一ステートメントのトランザクションを比較する
- CockroachDB でのセットベースの操作
- CockroachDB JDBC ドライバーでの一括書き換え
- データベースのホットスポットとは?
- CockroachDB トランザクションのデモ
- CockroachDB のベストプラクティスとアンチパターンのデモ -- 再試行、バッチ処理、主キーのホットスポット、ガードレール、チャンク処理、マルチリージョンを扱う、実行可能な Java デモ 10 件
- CockroachDB JDBC ラッパー -- Java/JDBC アプリケーション向けの自動再試行ライブラリ