一件を深く読む · 06

二重クリック、同時振込、返事のない送金

どれも「うまくいかなかった」に見える三つの場面。それぞれ違う約束で守る理由を、受付・認証・記帳の境目から考えます。

本文の説明・図・掲載コードだけで読み進められます。出典リンクは、権限のある人向け(非公開GitHub)です。GitHubで閲覧権限がある方だけ開けます。

この記事でわかること

  • 同じ依頼の再送と、別々の振込の同時実行を分ける
  • 認証は今回の依頼・本人・セッションへ結び付ける
  • 結果不明は新規振込を作らず、元の依頼を照会する
この記事の目次
01 / DEEP READING

似たトラブルに、同じ対策を当てない

架空の顧客・葵さんが、家賃として4万円を振り込もうとしたとします。ボタンを二度押した場合、別々の端末から二件の支払いを送った場合、最後の返事だけ届かなかった場合は、画面ではどれも戸惑う出来事です。しかし中で守りたいことは異なります。

二重クリックは「同じ依頼を一件にする」問題。同時支払いは「二件とも本物だが、同じ残高を二重に使わない」問題。通信断は「実際には成立したのか、まだ分からない」問題です。一つの失敗表示と一つの再試行ボタンへ押し込めると、別の問題を作ることがあります。

ここでは個人の内部振込を、設定未指定時の legacy 経路にあるSQLで読みます。TransferService は Java 経路へ切り替える設定も持ちますが、稼働環境がどちらを使うかは未確認です。設定値が不正なら止まり、Java が失敗したら自動で legacy へ送る、という説明にはしません。

01重複した依頼同じキーと内容を照合
02別件が同時に来る口座ロック後に条件を再確認
03返事だけ届かない元の受付IDを読み取り照会

根拠:バックエンドの選択と既定値 ↗(非公開GitHub・要権限)

根拠:結果不明時の画面 ↗(非公開GitHub・要権限)

02 / DEEP READING

同じキーなら、同じ内容まで確かめる

受付には Idempotency-Key という依頼の目印を添えます。request_transfer は、ログイン利用者ごとのロックを取ってから、その利用者とキーの組み合わせに既存の依頼があるかを調べます。このロックは、まだ口座や依頼の行がなくても、同じ人の受付を順番に確認するためのものです。

照合するのは宛先と金額から作る指紋に加え、前後の空白を整理した依頼人名とメモです。同じキーでも4万円を5万円へ変えたり、依頼人名やメモを変えたりすれば TR009 として拒否します。同じ内容で、既存の状態が認証待ちまたは完了なら同じ依頼を返します。失敗・期限切れの依頼に対して、同じキーが何でも再開できるわけではありません。

また、異なるキーを大量に作る対策として、一人の利用者の直近1分の受付が5件に達すると、新しい依頼を拒否します。これは記帳時の件数制限とは別です。重複をまとめる制御と、別件の受付頻度を抑える制御を重ねることで、「キーだけ変えれば無制限」という抜け道を小さくしています。

根拠:受付の利用者ロック、同一内容の確認、頻度制限 ↗(非公開GitHub・要権限)

03 / DEEP READING

ログイン済みでも、今回の4万円はまだ承認していない

現行の確認は、認証アプリのTOTPを今回の振込へ結び付ける方式です。関数名に otp が残っていても、依頼と無関係な6桁の数字を渡すだけで成立する、という意味ではありません。認証の対象には、利用者、セッション、依頼の種類とID、金額や宛先などの内容をまとめたハッシュが結び付きます。

仲介処理は本人とセッションの一致を確かめ、依頼が有効か、内容が変わっていないか、TOTPの検証記録が新しいかを確認します。後続の修正では、別のセッションで新しく認証しただけでは古い認証済みセッションへ権限を持ち込めず、今回のセッション自身に新しいTOTPの履歴があることを要求しています。

確定時には transfer_auth.consume が認証記録を確認して一度だけ消費します。利用者・セッション・期限・内容に加え、認証が直近60秒のものかも見ます。入力をやり直した後も昔の承認を流用できる設計より不便ですが、「認証したのは別の振込だった」というすり替えを防ぐための境界です。

根拠:認証内容の結び付けと確定時の消費 ↗(非公開GitHub・要権限)

根拠:同じセッション内の新しいTOTP履歴を要求する修正 ↗(非公開GitHub・要権限)

04 / DEEP READING

7万円を、二つの4万円に使わせない

葵さんの残高が7万円で、異なるキーを持つ4万円の振込が二件来たとします。両方が同時に「7万円ある」と読むだけなら、合計8万円の支払いを許してしまいます。これは同じ依頼の重複ではないため、冪等キーだけでは防げません。ここでは、認証などの前提を満たした金融処理の競合だけを取り出して考えます。実際のTOTP認証には、別途、認証間隔や有効期限の制限があります。

execute_transfer は送金元と送金先の口座IDを比べ、小さいID、大きいIDの順でロックします。葵さんから蓮さんへの送金と、蓮さんから葵さんへの送金が逆向きに来ても、同じ順で待つためです。順番がばらばらだと、一方が葵さん、もう一方が蓮さんの口座を握り、お互いの解放を待つ行き詰まりが起こり得ます。

ロック後に再び重複を調べ、元帳から利用可能な残高を求め、口座状態や本人確認状態も見直します。架空の二件では、先行する4万円が成立すれば残高は3万円。後続は待った後の3万円を見て残高不足になります。1日上限の集計時刻も待機後に取り、日本時間のその日の合計で判断します。速さより、判断と記帳が同じ状態に基づくことを優先する設計です。

実装の読み方:受付ロックと口座ロックを分ける

実際の関数名を含む読み取り用の順序図です。SQL実行例ではありません。

request_transfer
  利用者ごとに受付を直列化 → キーと内容の照合
confirm_transfer_otp
  依頼行をロック → 所有者・状態・期限・認証
execute_transfer
  口座IDの小さい順にロック → 再確認
  元帳残高・日本時間の当日合計 → 記帳

根拠:口座の順序ロックと待機後の再検査 ↗(非公開GitHub・要権限)

05 / DEEP READING

失敗の履歴を残しながら、お金だけは途中にしない

confirm_transfer_otp は依頼の行をロックし、所有者と状態を調べます。自分の完了済み依頼なら、その時点で同じ振込IDを返して終わります。ここでは新しく認証を消費したり、二回目の記帳を行ったりしません。「成立したがHTTPの返事を失った」という再試行を、成功済みの結果へ戻すためです。

未完了なら期限と認証を確認し、処理中へ進めた後、内側の金融処理を実行します。その途中で残高不足や凍結、監査書込みなどの例外が発生すると、その内側で行った変更を取り消して、依頼の失敗状態と安全な失敗コードを記録する構造です。したがって、失敗しても依頼や監査の証拠が残ることがあります。

これは「全部なかったことにする」と同義ではありません。お金の移動が半分だけ残らないことと、失敗した試みを後から説明できることを両立させています。ただし、その外側の処理自体が確定しなかった場合まで記録が必ず残るとは言えません。コードで囲まれている範囲と、最終的なコミットの成否を分けて読みます。

根拠:完了済み再試行と内側の金融失敗処理 ↗(非公開GitHub・要権限)

06 / DEEP READING

返事がないときは「失敗」と決め付けない

4万円の振込がデータベースで確定した直後、通信が切れた場面を想像してください。画面が成功を受け取れなくても、蓮さんの残高には既に4万円が入っているかもしれません。ここで別のキーの新規振込を作れば、二回目の正当な振込として処理される危険があります。

画面は結果不明のとき元の取引を確認するよう案内し、確定段階なら同じ受付IDの確認へ戻る道を用意します。本人用の照会は、依頼IDまたは冪等キーのどちらか一方で探し、ログイン利用者が所有する依頼だけを返します。照会自体は認証コードを発行せず、金融処理も行いません。

戻る状態も段階が分かれています。依頼が認証待ちで振込IDがなければ INSTRUCTION_ACKNOWLEDGED。完了依頼・完了振込・結び付いた仕訳がそろえば FINANCIAL_COMMITTED。失敗・期限切れ・取消しで振込IDがなければ NO_POSTING_CONFIRMED。それ以外を RESULT_UNKNOWN として残します。単に受付IDが見つかっただけでは、金融処理の成立とは呼ばない作りです。

根拠:本人所有の読み取り照会と金融観測の区別 ↗(非公開GitHub・要権限)

根拠:結果不明時の同一依頼への導線 ↗(非公開GitHub・要権限)

07 / DEEP READING

安全性は、速い正常系だけで判断しない

これらの対策には役割分担があります。冪等性は同一依頼の効果を増やさず、ロックは別件どうしの競合を整え、依頼に結び付いた認証は本人の今回の意思を確かめ、照会は結果の不確かさを減らします。どれか一つで他の三つを置き換えることはできません。

そのためテストも、同一キーを並列送信する場合、別キーで残高を取り合う場合、確定後に同じ依頼を再確認する場合を分けています。最終的な返事だけでなく、振込が一件、仕訳が一件、内訳が二行という件数や両口座の残高まで見ます。

この記事はその試験コードと実装を読んだ説明です。銀行の試験を今回実行したわけでも、通信断を稼働環境で再現したわけでもありません。外部ネットワークや実在の金融機関への送金保証ではなく、教育用アプリの内部振込が何を守ろうとしているかを、境界ごとに理解するためのものです。

根拠:重複・競合・失われた応答の再試行を分けた検査 ↗(非公開GitHub・要権限)

SOURCE NOTES

出典と、確認できた範囲

コードの説明と、稼働環境の検証を分けます

以下のリンクは非公開GitHubへの出典で、閲覧権限が必要です。本文はリンク先を開かなくても読める構成です。固定コード・設計資料を参照していますが、今回、この読み物のために銀行の取引やDB操作、本番試験は実行していません。実装や資料にある検査と、こちらで実施した検証を混同しないようにしています。

  1. 依頼とTOTP認証の結び付け ↗(非公開GitHub・要権限)
    受付の重複、認証消費、完了済み再試行、失敗記録
  2. 同一セッションの新しい認証 ↗(非公開GitHub・要権限)
    古いAAL2セッションへの認証流用を拒否
  3. 金融処理のロックと再検査 ↗(非公開GitHub・要権限)
    口座ID順ロック、元帳残高、日本時間の当日集計
  4. 本人所有の結果照会 ↗(非公開GitHub・要権限)
    受付・金融確定・未記帳確認・結果不明を区別
  5. バックエンド設定 ↗(非公開GitHub・要権限)
    未指定の既定値はlegacy、Java設定と稼働実態は別
  6. 不変条件のテスト ↗(非公開GitHub・要権限)
    同一依頼の一回記帳、別件競合、失敗時の件数検査

MiniBankは学習・検証用の銀行アプリです。架空の金額や人物は、仕組みを説明するための例です。

つながる読み物

ここまで読んだ記録を、ひとつ。

自分のペースで。あとから取り消すこともできます。

次の読み物テストの緑色は、何を証明しているのか
→