残高は足りていた — 二つの窓口が同じ残高を見ただけだ
一言でいうと
残高の確認と残高の差し引きが2つの文に分かれていると、その間に別のリクエストが割り込み、同じ残高を根拠に2回承認されます。直す方法は、確認を更新文の中に押し込み、承認するかどうかを「自分が読んだ値」ではなく「実際に変わった行数」で判定することです。
なぜ必要なのか
障害レポートに最もよく書かれる一文の1つが、「残高がマイナスになりました」です。コードを開いてみると、たいていこんな形をしています。まずSELECT balanceで残高を読み、PythonやJava側でif balance >= amountで判断し、そのあとUPDATE account SET balance = balance - amountを打ちます。一度に1つのリクエストしか入らないテスト環境では、永遠に正常です。
問題は、同じ口座にリクエストが重なる瞬間です。給料日の自動振替と顧客のアプリからの出金が、同じ100ミリ秒の間に届くと、どちらも先にSELECTを行い、どちらも残高が十分だと判断します。そのあと、2つのUPDATEが順に出ます。どちらも承認され、どちらも元帳に書かれ、残高は負の数になります。このとき興味深いのは、元帳と残高が互いにずれないことです。差し引きが2回とも正しく反映されたからです。そのため、「元帳の合計 == 残高」だけを確認する締めの点検は、この事故を黙って通過させます。
この種の欠陥は、再現が難しいという理由で長く残ります。負荷をかけてみろという話はよく出ますが、負荷は確率です。運が悪いと1時間回しても出ず、運がよく1回出たとしても、次の実行でまた出る保証はありません。再現が確率なら、直したかどうかも確率でしか語れなくなります。
どう動くのか
まず、再現を決定的にする方法です。負荷を上げる代わりに、重なる地点を合わせます。2つのリクエストが「両方が読んだあとで初めて両方が書く」という順序で進むようにバリアを1つ置くと、ラウンドごとに必ず同じ残高を読みます。リクエスト40件なら1秒もかからず、回すたびに同じ結果が出ます。再現が決定的であって初めて、直したという言葉に根拠が生まれます。
直す側は、1つの文です。
UPDATE account SET balance = balance - :amt
WHERE acct_id = :acct AND balance >= :amt;
この文が変えた行が1なら承認、0なら拒否です。変えた行数はSQLiteではchanges()で読み、Pythonのsqlite3モジュールではカーソルのrowcountで読みます。ここで重要なのは、先に読んでおいた残高を判定に使わないという点です。読んだ値は記録と説明にだけ使い、承認するかどうかはデータベースが決めます。
1日の累計限度額も、同じ形で付けられます。累計額を別の欄に持っていると、その欄と元帳がずれた瞬間から限度額が嘘をつくので、元帳でその日の出金を数えるサブクエリを条件に入れます。
UPDATE account SET balance = balance - :amt
WHERE acct_id = :acct AND balance >= :amt
AND (SELECT COALESCE(-SUM(amount),0) FROM ledger
WHERE acct_id = :acct AND biz_date = :d AND amount < 0) + :amt <= daily_limit;
そのあとが、トランザクションとロックです。SQLiteのBEGINのドキュメントは、読み取りトランザクションは複数が同時に開けるが、書き込みトランザクションは一度に1つだけだと明記しています。そして、1つの落とし穴を明示しています。デフォルトのBEGIN DEFERREDで始めてSELECTを先に行うと、読み取りトランザクションが開きますが、そのあとの書き込み文は読み取りを書き込みに昇格しようとし、ほかの接続がすでにデータベースを変更している場合は昇格ができず、SQLITE_BUSYで失敗します。BEGIN IMMEDIATEは最初から書き込みトランザクションを開き、この昇格自体をなくします。そのため、「読んでから書く」トランザクションは、最初からIMMEDIATEで開くのが安全です。
待機はPRAGMA busy_timeoutが担当します。ロックがかかっているとき、すぐに失敗する代わりに、指定したミリ秒だけ眠ってから再試行します。デフォルトは0なので、何も待ちません。ロックがどの段階で上がり下がりするかは、ファイルロックと同時実行のドキュメントに段階ごとに書かれており、どのエラーコードがどんな状況で出るかは、結果コードのドキュメントが整理しています。
最後がリトライです。リトライは、もう一度かけてよい失敗にだけかけます。ロックの競合は時間がたてば解けるので、もう一度かけてよく、残高不足や限度額超過は、何回かけ直しても同じ答えが出るので、かけてはいけません。上限のないリトライは障害を大きくします。上限と待機間隔、そしてどの失敗にかけるかをポリシーとして書いておき、試行回数を記録に残します。
現場での姿
第一に、「トランザクションで囲んだから大丈夫」という誤解です。トランザクションは原子性を与えるのであって、ほかのリクエストがその間に同じ行を変えられないようにしてくれるわけではありません。分離レベルとロックがその役割を担い、条件を更新文の中に入れるのが、最も安く同じ効果を出す方法です。
第二に、リトライが事故を大きくする場合です。あるチームがすべてのデータベースエラーに無限リトライをかけていたところ、ロック1つが長引いた瞬間に待機中のリクエストが積み上がり、接続数が上限に達しました。リトライは、失敗を遅らせるだけで減らしません。上限と間隔がポリシーになければ、そのリトライは爆弾です。
第三に、点検項目を1つしか置かない場合です。元帳の合計と残高が合っているかだけを見る締めの点検は、今回の事故を通過させます。マイナス残高と限度額超過を別に立てておいて初めて捕まります。点検は複数を並べて置き、何を見たかと観測値をあわせて残します。
実務で本当に大切なこと
- 判定は、アプリケーションではなく更新文が行います。読んでおいた値は、説明と記録にだけ使います。
- 再現は、負荷ではなく重なりで作ります。確率的な再現は、直したという証拠になりません。
- 読んでから書くトランザクションは、
BEGIN IMMEDIATEで開きます。昇格は待ってくれません。 - リトライは、ロックの競合にだけ、上限とともにかけます。業務の判定には、決してかけません。
- 点検は、複数の不変条件を並べて置きます。1つだけ見ると、通過してしまう事故が出ます。
次のラボですること
口座6個のスナップショットを自分で作り、読み取り・更新・書き込みの経路で、マイナス残高を再現可能な形にします。そのあと、条件付き更新で同じ重なりを防ぎ、書き込みロックとbusy_timeoutを自分で体験します。1日の累計限度額を同じ文の中に入れ、上限付きのリトライを加えたあと、3つの不変条件で口座6個を点検し、証拠レポートで締めくくります。