何百人もの開発者を面接してきた。データベーストランザクションについて聞くと、ほとんどが「ACID」と答えて終わる。READ COMMITTEDがREPEATABLE READと何が違うかを聞くと、沈黙。
トランザクションは「BEGIN...COMMIT」以上のニュアンスがある。ほとんどの開発者が使うデフォルトは、驚くべき動作を許容する。
ACIDは原子性、一貫性、分離性、永続性を表す。しかし、教科書が強調しないことがある:分離性にはレベルがある。そして、ほとんどのデータベースのデフォルトレベルは、直感的な期待に反することを許容する。
分離レベルは4つある。READ UNCOMMITTEDはほとんど使わない—ダーティリードなど何でも許容する。READ COMMITTEDはPostgreSQLとOracleのデフォルトで、非反復可能読み取りとファントムリードを許容する。REPEATABLE READはMySQL InnoDBのデフォルトで、一部のDBではファントムリードを許容する。SERIALIZABLEはオプトインのみで最も遅いが、奇妙なことは何も起きない。
分離レベルを明示的に設定したことがなければ、READ COMMITTEDを使っている。そしてREAD COMMITTEDには驚きがある。
READ COMMITTEDでは、トランザクション内で同じ行を2回読むと、異なる結果が得られる可能性がある:
トランザクションAは2つの同一クエリを実行した。2つの異なる答えを得た。両方のクエリは同じトランザクション内だった。
これはビジネスロジックが一貫した読み取りに依存する場合に重要:
REPEATABLE READ(MySQLのデフォルト)でも、新しい行が現れる可能性がある:
カウントが変わった。「ファントム」行が現れた。最初のクエリは10件の保留中注文と言ったので、10人のワーカーを割り当てた。しかし今は11件ある。
金融/在庫操作には、SERIALIZABLEまたは明示的ロックを使う:
または明示的ロックを使う:
読み取り多めのレポートには、REPEATABLE READまたはスナップショット分離を使う:
長いトランザクションは危険だ。5分間で10,000回の更新をかける1つのトランザクションは、触れたすべての行を5分間ロックする。
問題はロック競合—他のクエリが待機またはタイムアウトする。トランザクションログが増大し、データベースは潜在的なロールバックのためにすべての変更を追跡する必要がある。コネクションプールが枯渇し、このコネクションは5分間占有される。レプリケーション遅延も起き、レプリカはコミットまで変更を適用できない。
より良い方法は小さなトランザクションでバッチ処理:
各バッチは独自のトランザクション。ロックは短時間保持される。進捗は段階的にコミットされる。
全体を中断せずにトランザクションの一部をロールバックできる:
セーブポイントは、単一トランザクション内での「これを試して、失敗したら別のことを試す」パターンに便利。
2つのトランザクションがお互いを待つとデッドロックになる。トランザクションAが行1をロックし、トランザクションBが行2をロックする。そしてAが行2をロックしようとして待機、Bが行1をロックしようとして待機。両方が永遠に待つ。データベースはこれを検出し、1つのトランザクションを中断する。
防止戦略は2つ。まず一貫した順序でロックすること—常に同じ順序で行をロックする(例:ID昇順)。次にトランザクションを短く保つこと—ロック保持時間が短いほどデッドロックの可能性が低い。 3. 行レベルロックを使う — 可能な限りテーブルレベルロックを避ける
間違い1:知らずにオートコミット
多くのドライバはデフォルトで各文をオートコミットする。トランザクション内にいると思っているが、そうではない。
常に明示的に:
間違い2:ネットワーク呼び出しをまたぐトランザクション
データベーストランザクション内で外部サービスを呼び出さないこと。代わりにSagaパターンまたはOutboxパターンを使う。
間違い3:例外をキャッチして続行
いずれかの文が失敗した場合、通常はトランザクション全体をロールバックすべき。
分離レベルを理解する — READ COMMITTEDはおそらくあなたが期待するものではない。
トランザクションを短く保つ — 長いトランザクションはロック競合とログ肥大を引き起こす。
一貫した順序でロック — デッドロックを防ぐ。
トランザクションと外部呼び出しを混ぜない — 分散操作用に設計されたパターンを使う。
明示的にする — オートコミット動作に依存しない。
トランザクションはそうでなくなるまで単純に見える。本番で噛まれる前にエッジケースを知っておく。
-- トランザクションA
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000を返す
-- トランザクションB(Aの読み取りの間にコミット)
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
-- トランザクションAに戻る
SELECT balance FROM accounts WHERE id = 1; -- 500を返す!
COMMIT;// 危険:残高がチェックと更新の間に変わる可能性がある
var balance = await db.QuerySingleAsync<decimal>("SELECT balance FROM accounts WHERE id = @id", new { id });
if (balance >= amount)
{
await db.ExecuteAsync("UPDATE accounts SET balance = balance - @amount WHERE id = @id", new { id, amount });
}
// 競合状態:SELECTとUPDATEの間に残高が変わった可能性がある-- トランザクションA
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 10を返す
-- トランザクションB
INSERT INTO orders (status) VALUES ('pending');
COMMIT;
-- トランザクションAに戻る
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 11を返す!
COMMIT;BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1;
-- 並行する変更があれば、このトランザクションは中断される
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT; -- 別のトランザクションがこのデータに触れていれば失敗BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 行がロックされた。他のトランザクションは待機する。
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;BEGIN ISOLATION LEVEL REPEATABLE READ;
-- すべての読み取りはトランザクション開始時の一貫したスナップショットを見る
SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '1 day';
SELECT * FROM order_items WHERE order_id IN (...);
-- クエリ中にデータが変わっても一貫性が保証される
COMMIT;var ids = await db.QueryAsync<int>("SELECT id FROM products WHERE category = 'electronics'");
foreach (var batch in ids.Chunk(1000))
{
await using var tx = await connection.BeginTransactionAsync();
await db.ExecuteAsync(
"UPDATE products SET price = price * 1.1 WHERE id = ANY(@ids)",
new { ids = batch.ToArray() }
);
await tx.CommitAsync();
}BEGIN;
INSERT INTO orders (customer_id, total) VALUES (1, 100);
-- 注文が作成された
SAVEPOINT before_items;
INSERT INTO order_items (order_id, product_id, quantity) VALUES (1, 999, 1);
-- おっと、製品999は存在しない、これは失敗するかもしれない
ROLLBACK TO SAVEPOINT before_items;
-- order_itemsの挿入だけがロールバックされる
-- ordersの挿入はまだ保留中
INSERT INTO order_items (order_id, product_id, quantity) VALUES (1, 1, 1);
-- 有効な製品で再試行
COMMIT;
-- 注文と修正されたアイテムの両方がコミットされる// 悪い例:予測不可能なロック順序
foreach (var item in items.OrderBy(i => i.Price))
{
await db.ExecuteAsync("UPDATE products SET stock = stock - 1 WHERE id = @id", new { id = item.Id });
}
// 良い例:予測可能なロック順序
foreach (var item in items.OrderBy(i => i.Id))
{
await db.ExecuteAsync("UPDATE products SET stock = stock - 1 WHERE id = @id", new { id = item.Id });
}// これはほとんどのドライバでトランザクションではない
await db.ExecuteAsync("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
await db.ExecuteAsync("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
// 2番目の文が失敗しても、1番目は既にコミットされているawait using var tx = await connection.BeginTransactionAsync();
// これで本当のトランザクション
await tx.CommitAsync();await using var tx = await connection.BeginTransactionAsync();
var order = await CreateOrder(tx);
var paymentResult = await paymentApi.Charge(order.Total); // StripeへのHTTP呼び出し!
// Stripeが遅い場合、トランザクションは数秒間オープン
// Stripeがタイムアウトした場合、コミットするかロールバックするか?
await tx.CommitAsync();try
{
await db.ExecuteAsync("INSERT INTO orders ...");
}
catch (Exception)
{
// ログして続行
}
await db.ExecuteAsync("INSERT INTO order_items ...");
await tx.CommitAsync();
// 最初の挿入が失敗した場合、孤立したアイテムをコミットしている