MySQL Savepoint:小さなエラーで大規模トランザクションを「台無し」にしない技術

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

実務での課題:「全か無か」が負担になる時

MySQLを扱ったことがあれば、ACID特性を持つTransaction(トランザクション)の概念はご存知でしょう。通常、私たちはSTART TRANSACTIONで開始し、COMMITで確定、エラーがあればROLLBACKで「巻き戻す」という使い方をします。この考え方は単純なタスクには適していますが、現実はもっと複雑です。

以前、あるECサイトのプロジェクトに携わっていた際、MySQL 8.0で約50GBのデータを扱い、1日1万件の注文を処理していました。当時の決済フローは非常に複雑でした:注文作成 -> 在庫減算 -> 電子マネー決済 -> 特典ポイント付与 -> 通知メール送信。

当初は基本的なBEGINROLLBACKのみを使用していました。しかし、これが災いとなりました。例えば「メール送信」のステップでメールサーバーがダウンしてエラーになると、システム全体がROLLBACKされてしまいます。顧客の決済は完了しているのに注文が消え、在庫が戻ってしまうといった事態が発生しました。これはリソースの無駄であるだけでなく、顧客に多大なストレスを与えます。本来なら、注文と決済は維持し、メール送信だけを後で再試行したかったのです。

なぜ通常のROLLBACKだけでは不十分なのか?

MySQLのデフォルトのROLLBACKコマンドは、すべての状態をリセットする「リセットボタン」のようなものです。データベースをSTART TRANSACTION直前の状態にまで完全に引き戻します。

しかし、長い一連の操作において、すべてのエラーが全体の取り消しに値するわけではありません。ポイント付与やログ記録のような局所的なエラーであれば、より重要な操作を維持するために無視できる場合もあります。

一時的な保存ポイントがない場合、トランザクションを細かく分割せざるを得ません。しかし、この方法は非常にリスクが高いです。処理の途中でサーバーがダウンした場合、データが「中途半端な状態」になり、復旧が極めて困難になります。ここで救世主として登場するのがSAVEPOINTです。

複雑なトランザクション管理における3つのアプローチ

方法1:入れ子構造のトランザクション(Nested Transactions)

初心者の多くは、BEGINの中に別のBEGINを書こうとします。しかし、これは避けてください。MySQLでは、START TRANSACTIONを実行すると、現在開いているトランザクションが暗黙的にCOMMITされます。手動でトランザクションを入れ子にすることはできません。

方法2:アプリケーション側(PHP, NodeJS, Pythonなど)でのロジック処理

エラーが発生した際に、逆の操作を行うコードを自分で書く方法です。例えば、在庫減算でエラーが出たら在庫を加算するクエリを呼び出します。この方法はPHP PDOなどのライブラリを利用している場合でも、レースコンディション(競合状態)を引き起こしやすく、コードが非常に煩雑になるため危険です。

方法3:SAVEPOINTとROLLBACK TOの利用(最適解)

これはMySQL(特にInnoDBストレージエンジン)の強力な機能です。長いトランザクションの中にチェックポイントを設定できます。エラーが発生した際、その特定のチェックポイントまで戻るだけで済みます。それ以前の重要なデータは保持され、最後に一括でCOMMITすることができます。

実践:5分でマスターするSAVEPOINT

実際の決済シナリオを通して、この機能の威力を見てみましょう。

-- 1. トランザクションの開始
START TRANSACTION;

-- 2. 注文作成(必須)
INSERT INTO orders (id, user_id, total) VALUES (101, 1, 500000);

-- 3. 注文作成後のチェックポイントを設定
SAVEPOINT after_order_created;

-- 4. 在庫を減らす
UPDATE products SET stock = stock - 1 WHERE id = 10;

-- 在庫更新が成功したら、次のチェックポイントを設定
SAVEPOINT after_stock_updated;

-- 5. 特典ポイント付与(付随的な操作、エラーが発生しやすい)
-- データ型エラーなどでこの行が失敗したと仮定
INSERT INTO member_points (user_id, points) VALUES (1, 'abc'); 

-- 6. ステップ5でエラーをキャッチした場合、在庫更新後の地点まで戻す
ROLLBACK TO SAVEPOINT after_stock_updated;

-- 7. 確定!注文と在庫は維持され、ポイント付与のみがキャンセルされる
COMMIT;

覚えておくべきコマンド:

  • SAVEPOINT name;: チェックポイントを作成します。
  • ROLLBACK TO SAVEPOINT name;: 指定したポイントまで戻ります。注意:トランザクションは終了していません。最後にCOMMITが必要です。
  • RELEASE SAVEPOINT name;: チェックポイントを削除してメモリを解放します。

実務から得た「血の滲むような」教訓

長年大規模システムを運用してきた経験から、トラブルを避けるための4つの重要な注意点を共有します。

1. チェックポイントを乱用しない

SAVEPOINTはInnoDBの管理リソースを消費します。以前扱った50GBのDBでは、大量のデータ処理ループ内で数十個のチェックポイントを設定した結果、パフォーマンスが約15〜20%低下しました。特に書き込み負荷が高いシステムでは、チェックポイントは本当にリスクのあるステップにのみ設定しましょう。

2. SAVEPOINT名は一意にする

同じ名前を付けると、後のものが前のものを上書きします。管理しやすいようにsp_[ステップ名]という形式を使うのがおすすめです。これによりコードが明快になり、初歩的なロジックエラーを防げます。

3. 「暗黙のコミット(Implicit Commit)」に注意

CREATE TABLEALTER TABLEなどの一部のSQLコマンドは、実行すると即座にトランザクションを確定(COMMIT)させます。これらを誤って実行すると、それまでのSAVEPOINTはすべて消滅し、ROLLBACK TOができなくなります。また、複雑なスキーマ変更はメタデータロックによるハングアップの原因にもなるため注意が必要です。

4. アプリケーションコードとの連携

MySQLはツールを提供しますが、判断を下すのはアプリケーション(Java, Pythonなど)のコードです。特にSpring BootとHikariCPを組み合わせた環境などでは、try-catchブロックを使ってロジックを厳密に囲みましょう。

# Pythonでの処理例
try:
    cursor.execute("START TRANSACTION")
    cursor.execute("INSERT INTO orders ...")
    cursor.execute("SAVEPOINT sp1")
    
    try:
        cursor.execute("UPDATE inventory ...")
    except Exception:
        cursor.execute("ROLLBACK TO SAVEPOINT sp1")
        print("在庫エラーが発生しましたが、注文データは保持されました")
    
    cursor.execute("COMMIT")
except Exception:
    cursor.execute("ROLLBACK") # 重大なエラーの場合は全ロールバック

このテクニックは、バッチ処理や高い信頼性が求められる分散システムを扱う際の強力な武器になります。この記事が、複雑なトランザクションに立ち向かう皆さんの助けになれば幸いです。データベース最適化の成功を祈っています!

Share: