コンピューターサイエンス:アルゴリズム / OS / ネットワーク / DB
トランザクションとは
引いたところで落ちると、お金が消える
Alice の口座から Bob の口座へ 1000 円を移す処理を、2 本の UPDATE で書いたとします。
SQL クエリ
UPDATE accounts SET balance = balance - 1000 WHERE name = 'Alice';
UPDATE accounts SET balance = balance + 1000 WHERE name = 'Bob';1 本目が終わり、2 本目が走る前にサーバのプロセスが落ちたとします。Alice の残高は 1000 円減っていて、Bob の残高は増えていません。1000 円がどこにも存在しない状態で、DB は止まります。
厄介なのは、壊れたデータがエラーの顔をしていないことです。残高はどちらも数値として正しく入っていて、制約にも引っかかりません。誰かが帳尻の合わなさに気づくまで、正常なデータとして残り続けます。落ちる場所も 2 本の間だけではありません。ネットワークが切れた、デプロイで再起動が入った、アプリが例外を投げて途中で戻った。どれでも同じ結果になります。
全部やるか、何もしないかに括る
原因は「2 本のうち片方だけが残った」ことです。逆に言えば、片方だけ残る状態を DB の側で作れなくすれば起きません。それがトランザクションです。
SQL クエリ
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE name = 'Alice';
UPDATE accounts SET balance = balance + 1000 WHERE name = 'Bob';
COMMIT;BEGIN から COMMIT までは、外から見て 1 つの操作です。途中で落ちても、DBMS は再起動時に「COMMIT に届いていない変更」をまとめて巻き戻します。Alice の残高も元に戻り、送金は初めからなかったことになります。半端な状態が残らないので、後から手作業で辻褄を合わせる仕事も発生しません。
自分の判断で取り消すときは ROLLBACK を書きます。アプリ側では、例外を捕まえたら必ず ROLLBACK を通る形にしておきます。
JavaScript
const trx = await db.transaction();
try {
await trx.query("UPDATE ...");
await trx.query("UPDATE ...");
await trx.commit();
} catch (err) {
await trx.rollback();
throw err;
}どこからどこまでを括るか
MySQL も PostgreSQL も、既定では 1 文ごとに自動でコミットされます。BEGIN を書いていないコードは、括っていないのではなく「1 文ずつ括られている」状態です。
括る範囲は、整合性を保つのに必要な最小限にします。外部 API の呼び出しやファイル生成、利用者の入力待ちを内側に入れると、その間ずっと他のトランザクションを待たせることになります。Web アプリでは 1 リクエスト 1 トランザクションが出発点で、そこから広げるかどうかを都度考えます。
短いトランザクションは速くて安全。長いものは事故の元。