😱

多重決済を防ごうとしてwith_lockでハマった話

に公開

はじめに

こんにちは、レンティオでエンジニアをしている川添です。
レンティオではカメラに始まり、防振望遠鏡、スーツケース、生活家電などのとても多様な製品を貸し出しています。
商品をレンタルする上で主に2つのプランが存在しています。

  • ワンタイムプラン: レンタル期間を決めてレンタルするプラン、レンタル時に料金を一括決済する。
  • 月額制プラン: レンタル期間を決めずに月毎にレンタル料金を決済する。一定期間支払い続けると所有権をレンタルしたユーザーへ移転する(一部商品を除く)

今回は月額制プランで実際にあった話を書きたいと思います。

月額制プランの決済について

月額制プランでは毎月請求データを作成し決済する必要があります。
大まかな決済のしくみとしては以下のとおりになっています。

  1. 月額料金の請求レコードを作成
  2. 請求レコードの情報を元に決済を実行
  3. 決済完了後、請求レコードのステータスを変更

決済処理はバッチと手動の二通りの方法で実行することが可能になっています。
手動での決済は運用メンバーが複数人で実行するのでオペミスなどで同じ請求レコードに対し同時に決済を実行しても
二重に決済が行われないように対策する必要がありました。
手動決済処理のソースは以下の通りになっていました。(実際のコードとは異なります)

# 請求モデル
class Invoice
  # 請求の状態管理(statemachines)
  # 初期状態は started
  state_machine :state, initial: :started do
    event :complete do
      transition from: :started, to: :completed
    end
  end

  # 請求の実行
  def execute!
    # 決済処理を実行(詳しくは割愛)
    # 決済処理完了後に自身の状態を請求済みにする
    complete!
  end
end

# 画面から手動で決済を実行するための請求コントローラー
class InvoicesController < ApplicationController
  # Invoice(請求)への決済を実行するアクション
  # @invoiceはApplicationController内部で初期化される
  # before_actionでcancancanによる@invoiceの権限チェックを行う
  def charge
    # 多重決済を防ぐために行ロックをかけて実行する
    @invoice.with_lock do
      @invoice.execute!
    end
  rescue StateMachines::InvalidTransition => e
    redirect_to invoices_path(@invoice), flash: { error: "多重決済を検知したため、決済を中断しました。" }
  end
end

# パーミッション設定(cancancan)
class Operator
  # 完了状態の請求に対しては決済実行できないようにする
  cannot :execute!, Invoice do |invoice|
    invoice.completed?
  end
end

同時リクエストによる多重決済を防ぐしくみ

多重決済を防ぐしくみとしては以下の通りになっています。

モデル

  • statemachineを利用して請求の状態カラムを管理、請求完了(complete)状態のレコードが請求開始(started)などの状態にならないように状態遷移で制御する。
    • 請求完了した請求レコードに対し、再度請求処理が行われるとcompletedからcompletedの状態遷移が発生し、StateMachines::InvalidTransitionがraiseされる。
  • cancancanによって請求が完了した請求書は請求処理を実行できないようにする。
    • すでにcompleted状態の請求レコードに対して請求(execute)を実行するとCanCan::AccessDeniedがraiseされる。

コントローラ

  • with_lockによって請求レコードを書き込みロックして決済処理を行う。これにより同時にリクエストされても後続のリクエストはDBへの書き込みロック取得のため待機する。

同時決済を防げていない?

上記のコードで同時決済を防ぐようになっていましたが1つ気になる点があります。
chargeアクション内でStateMachines::InvalidTransitionをrescueしています

コントローラのコードから推測すると決済処理の流れは以下の通りになるはずです。

  1. 請求レコードの状態をcancancanで権限チェックし請求を実行可能か判定
  2. 権限チェックがOKの場合、請求レコードをロック
  3. 請求処理を実行
  4. 請求処理の完了後、請求レコードの状態を完了に変更
  5. ロックしたレコードを解放

上記から推測すると同時リクエストされると請求完了状態のレコードは1で弾かれるため、
StateMachines::InvalidTransitionは本来発生しえないはずです。
そこで、ブレークポイントを仕込みトランザクション内部の請求レコードの状態を確認しました。
以下のコードで複数ウィンドウを開いて決済を実行すると同時実行を再現できます。

  # 決済実行アクション
  def charge
    # 多重決済を防ぐために行ロックをかけて実行する
    @invoice.with_lock do
      sleep 10 # 決済が一瞬で終わらないようにする
      binding.irb # @invoiceの状態をチェック
      @invoice.execute!
    end
  rescue StateMachines::InvalidTransition => e
    redirect_to invoices_path(@invoice), flash: { error: "多重決済を検知したため、決済を中断しました。" }
  end

推測した処理の流れの通りにコードが実行される場合、1つ目のリクエストで請求レコードが完了状態になります。
そうすると、2つ目のリクエストは1つ目のリクエストの完了まで待機した後に処理を開始するため、権限チェックで弾かれて処理が終了する想定でした。
しかし、コードを実行すると結果は以下のとおりになりました。

# 1つ目のリクエストの請求レコード
irb:rdbg(#<InvoicesContr...):001> @invoice.state
"started"

# 2つ目のリクエストの請求レコード
irb:rdbg(#<InvoicesContr...):001> @invoice.state
"completed"

2つ目のリクエストは、処理が中断されずwith_lock内のブロックに到達しており請求完了したレコードに対して請求処理が実行されていました。

with_lockと書き込みロックというしくみ

with_lockはlock!によって行ロックを行った後にトランザクションを発行します。
lock!を実行するとPostgre SQLではSELECT * FOR UPDATE文を発行し、
UPDATEとDELETE文からレコードをロックします。
そのため、ロックされたレコードの書き込みは防ぐことができますが読み取りは防ぐことができません。
同時リクエストされると、後続リクエストはトランザクションがコミットされる前の請求レコードを読み取ってしまいCancancanの権限チェックをパス、with_lock内部で通常通り決済と状態遷移まで実行するためStateMachines::InvalidTransitionが発生していました。
このときのコントローラの処理の流れを図にすると以下のようになっていました

つまり同時リクエストによる多重決済を防ぐことができていませんでした...。
(たまたま別の改修をしていたときに見つけたおかげで顕在化しなかった。)

解決方法としてトランザクション開始直後に請求レコードの再取得と
権限チェックを行うようにしました。
これにより、直前にコミットされた請求レコードの状態を参照できるようになり
同時にリクエストしても、決済が完了したレコードへの再決済処理を防ぐことができるようになりました。
変更後の処理の流れは以下の図のようになります。

ソースは以下の通りになりました。

# 請求コントローラー
class InvoicesController < ApplicationController
  # Invoice(請求)への決済を実行するアクション
  # @invoiceはApplicationController内部で初期化される
  def charge
    # 多重決済を防ぐために行ロックをかけて実行する
    @invoice.with_lock do
      # with_lock文は実行後に対象インスタンスをreloadするため
      # 自動で直前にコミットされたレコードの値が取得される
      # reloadされたレコードに対し、請求可能なレコードかどうか権限チェックする
      authorize! :execute, @invoice
      @invoice.execute!
    end
  rescue CanCan::AccessDenied => e
    redirect_to invoices_path(@invoice), flash: { error: "請求不可能なため、決済を中断しました。" }
  end
end

終わりに

transactionやwith_lock、行ロックについて雰囲気しか理解しておらず、学びの機会となったので記事にしてみました。
RailsはActive Recordのサポートが強力でDB側を意識する機会が少ないと感じていましたが
Active Recordインスタンスが保持している値が現在のDB上での値であると過信せずに
処理の実行タイミングに応じて適切に同期を取っていくかが大切なのかなと思います。

採用情報

現在レンティオでは、Ruby on Railsエンジニアを中心とした採用を進めています。もしご興味のある方がいらっしゃいましたら、ぜひ採用ページをご覧ください!
https://recruit.rentio.co.jp/engineer

https://www.rentio.jp/

Discussion