SAVEPOINT はトランザクション内に置く名前付きのしおり
通常のトランザクションは「全部成功するか、全部捨てるか」の二択です。BEGIN から COMMIT までに書いた変更はまとめて反映されるか、まとめて破棄されるかのどちらかになります。たいていの場面ではこれで十分なのですが、もう少し細かく制御したいケースもあります。たとえば「この一連の処理を試してみて、ダメだったらその部分だけ取り消したい。でもトランザクション自体は生かしておきたい」というような場面です。
そこで登場するのが SAVEPOINT(セーブポイント)です。名前付きのしおりを挟んでおき、そこまでの作業を確定するなら RELEASE、なかったことにしたいなら ROLLBACK TO でしおりの位置まで巻き戻す、という使い方ができます。
Ada の引き落としと Boris の入金はどちらも残ったまま、Nobody への誤った更新だけがロールバックされました。トランザクション全体を捨てずに済んだわけです。
3 つのコマンド
API と言ってもステートメントは 3 つだけです。
SAVEPOINT name— しおりを挟む。RELEASE SAVEPOINT name— しおり以降の変更を確定させ、しおりを外す。ROLLBACK TO SAVEPOINT name— しおり以降の変更を取り消す。しおり自体は残るので、もう一度やり直せる。
なお、RELEASE と ROLLBACK TO の後ろの SAVEPOINT というキーワードは省略可能です。RELEASE risky でも ROLLBACK TO risky でも動きます。
ステップ2 試行 の行は、最終的なデータベース上では存在しなかったことになります。それ以外の変更はすべて反映されます。
外側のトランザクションなしで使う SAVEPOINT
ちょっとした小ネタですが、SAVEPOINT は BEGIN なしでもいきなり実行できます。この場合、SQLite が裏でトランザクションを開いてくれて、いちばん外側のセーブポイントがそのままトランザクションの役割を果たします。そのセーブポイントに対して RELEASE すればコミット、ROLLBACK TO すればコミットせずに巻き戻し、という動きになります。
そのため、SAVEPOINT は「名前付きトランザクション」と呼ばれることもあります。とはいえ、実際のコードで両方のスタイルを混在させると分かりにくくなるので、どちらかに統一するのがおすすめです。一般的には、外側の境界には明示的な BEGIN ... COMMIT を使い、内側の部分的なロールバック地点にだけ SAVEPOINT を使うパターンがよく採用されています。
SAVEPOINT のネスト(ネストトランザクション)
SAVEPOINT はスタックのように積み重ねられます。外側に影響を与えずに、内側の SAVEPOINT だけをロールバックすることも可能です。
最終的なテーブルの中身は a、b、d になります。inner までロールバックしたことで c は取り消されましたが、inner より前に行った作業(b の挿入)はそのまま残り、トランザクション自体も継続しています。
外側 のセーブポイントへロールバックすると、それより内側で行われた処理もすべて破棄されます。つまり、その名前より上に積まれていたスタックが一気に巻き戻るイメージです。
b も c も消えています。ROLLBACK TO outer は outer を設定して以降の変更をすべて巻き戻すので、inner も c の INSERT もまとめてなかったことになります。
SAVEPOINT を使うのはどんなとき?
定番のユースケースは、バッチ処理で一部のアイテムが失敗してもバッチ全体を捨てたくないケースです。各アイテムを SAVEPOINT で囲んでおけば、失敗したものだけロールバックして次に進めます。
実際のアプリケーションコードでは、失敗した INSERT がエラーを投げると、アプリ側でそれをキャッチして ROLLBACK TO を発行し、処理を続行します。正常な 2 行はそのまま保存され、問題のある 1 行だけがバッチ全体を巻き込まずに済むわけです。
このパターンは、ORM やマイグレーションツールが「ネストしたトランザクション」を実装するときの定番でもあります。SQLite は BEGIN ブロックの入れ子を許さないので、実際には BEGIN をネストせず、ネストした呼び出しを SAVEPOINT にマッピングしているのです。
SAVEPOINT を使うときの注意点
SAVEPOINT に慣れていない人がハマりがちなポイントをいくつか挙げておきます。
COMMITは常にトランザクション全体をコミットする。 SAVEPOINT がいくつ開いていようが関係ありません。COMMIT(別名END)は外側のトランザクションをまとめて閉じます。RELEASEを「部分コミット」のように考えてはいけません。外側のトランザクションがコミットされるまで、何ひとつ永続化されないからです。TOを付けないROLLBACKはすべてを破棄する。 トランザクションを終了させ、開いている SAVEPOINT もまとめて捨てます。トランザクション自体は残したいときは、必ずROLLBACK TO 名前を使ってください。- SAVEPOINT は RELEASE するか、そこまでロールバックされるまで開いたまま残る。
RELEASEを忘れてもデータが消えるわけではありませんが、ブックマークだけがトランザクション終了まで残り続けます。 - 名前は一意である必要はない。
SAVEPOINT sを 2 回設定した場合、ROLLBACK TO sは最も新しい方を見つけます。再帰処理では便利ですが、意図せず重複させると混乱の元です。
次回: ビュー
SAVEPOINT は 書き込み をきめ細かく制御するための仕組みでした。次は 読み取り 側を整える話です。クエリを名前付きの再利用可能なオブジェクトとして保存し、テーブルのように SELECT できるようにする —— それが「ビュー」です。次回はこのビューを取り上げます。
よくある質問
SQLiteのSAVEPOINTとは何ですか?
SAVEPOINTは、トランザクションの途中に打っておく「名前付きのしおり」のようなものです。あとから ROLLBACK TO 名前 でそのしおり以降の変更だけを取り消したり、RELEASE でしおりを外して変更を確定させたりできます。トランザクションの中に、もっと小さな「やり直しの単位」を作れるイメージですね。
SAVEPOINTと普通のトランザクションは何が違うの?
通常のトランザクションは BEGIN で始めて COMMIT か ROLLBACK で終わります。一方、SAVEPOINTは SAVEPOINT 名前 の形でトランザクションの中に設置するもので、部分的なロールバックポイントとして機能します。SAVEPOINTまで巻き戻しても外側のトランザクションは終わらないので、そのまま処理を続けて最後にまとめてコミットできます。
SQLiteのSAVEPOINTはネストできますか?
はい、できます。違う名前のSAVEPOINTを積み重ねていけば、ROLLBACK TO outer で外側のSAVEPOINTまで一気に戻せて、内側のSAVEPOINTもまとめて取り消されます。同じ名前を再利用してもOKで、その場合SQLiteは一番直近のものを参照します(いわゆるスタックの挙動です)。