こんにちは、トレタのモバイルオーダー事業でプロダクトマネージャーを行なっている北川です。
こちらはトレタアドベントカレンダー2日目の記事です。この記事では2022年の振り返りとして、今年の前半に取り組んだモバイルオーダー・トレタO/Xでの決済トラブルについてどう対応していったのかを記そうと思います。
決済でのトラブルというのは基本的に起こしてはならないものであり、それを記事にすることは憚られましたが、自分が苦闘しているなかで同様に決済への試みをしている記事を大いに参考にさせていただいたので、自分も誰かの助けになればと思いこの記事を公開します。
あらためて、導入店の飲食店様と来店してご利用いただいていたお客様には、多大なご迷惑をおかけしたことをこの場を借りてお詫び申し上げます。
トレタO/Xの決済の概要
まずはトレタO/Xでの決済について説明します。 トレタO/Xでは店員を介さずにユーザーが自分のスマホから飲食代金を決済することが可能です。 ユーザーは飲食後にトレタO/Xでお会計画面を開き、支払い方法として、オンライン決済かオフライン決済かを選択することができます。
オフライン決済は従来の決済のように、店員を呼び出しレジでの会計となります。 オンライン決済であれば、クレジットカード情報を入力して決済を完了すればそのまま退店することができます。
会計待ちなどの煩わしさが減り、店員と来店者の両者の手間が省かれる体験となっています。
多重決済の問題
運用が少しずつ軌道にのりサーバーでの処理リクエスト数が日に日に増えていく中で、お会計が重複して行われてしまう多重決済の問題が発生しました。
調査したところ、多重決済が発生するケースには2パターンがありました。
- 決済が途中で失敗したケース
- ひとつのお会計に対して複数の決済が同時に行われたケース
まず1つ目の決済が途中で失敗したケースについてですが、これはシステムの構成が関わっています。
トレタO/Xのバックエンドはマイクロサービスの構成となっており、決済においては「注文サービス」と「決済サービス」を跨いだ処理となります。また、それらの処理をオーケストレーションするAPIのGatewayサーバーがあります。
決済の処理としては、
- オーダーアプリからGatewayサーバーへ決済のリクエストを送る
- Gatewayサーバーは、注文サービスへ会計情報を問い合わせる
- Gatewayサーバーは、取得した会計情報の金額で決済サービスへ決済処理をリクエストする
- 決済サービスは、決済プラットフォームのStripeを利用しており、Stripeへ決済をリクエストする
- 決済が完了したらGatewayサーバーは、注文サービスに該当の注文に対しての支払いレコードを登録し、会計を支払い済みのステータスに更新する
さて、今回発生したトラブルでは決済サービスからレスポンスが返ったあとに注文サービスへの書き込みでエラーが発生していました。
注文や決済などが重なりリクエストが集中する時間帯においてサーバーへの負荷が上がり、一部のリクエストでタイムアウトが発生したためです。
決済サービスへの書き込みに失敗するとユーザー側にはエラーの表示がされますが、決済処理をバックオフができないため、決済サービスでの決済は完了されてたままに注文サービスでの会計は未会計のステータスのまま、というデータの不整合が起きた状態となります。
そして会計が未会計のステータスなので、ユーザーから支払いを再試行することが可能であり二重の支払いが行われてしまいます。
リトライと冪等性
これを解決するには、エラーとなった箇所でのリトライをすればよいと考えられます。
ただし、サーバーからタイムアウトのレスポンスが返ってきた場合にはその処理の結果が成功/失敗かがわからないため、単純にリトライしてしまうと1つの決済に対してリトライ回数分の支払いレコードが登録されてしまうことになります。
そのため注文サービスの支払いレコード登録APIには冪等性の担保が必要となります。
冪等性対応の仕様としては、The Idempotency-Key HTTP Header Fielddraft-ietf-httpapi-idempotency-key-header-02を基に一部簡略化して実装しました。
- HTTPリクエストのリクエストヘッダーにIdempotency-Keyを設定する
- 過去に同一のIdempotency-Keyの値でのリクエストがあれば、処理は行わずにステータスコードを409(Conflicted)でレスポンスを返す
また、StripeのAPIにも同様に冪等キーを付与する仕組みがあるので決済サーバー側もこちらを用いて冪等対応をします。
では冪等キーには何を使用するのが妥当でしょうか。
注文をとりまとめた会計レコードがもつ会計IDがあるので、一見すると会計IDを使うと会計に対して一回以上支払いができないように制限でれるように考えられます。
しかし、支払いは会計に対して本当に一度だけでしょうか。
オンライン決済やオフライン決済が複合して行われることを想定すると、会計に対して複数回の支払いを行うケースはいくつか考えられます。
- クーポン(金券)で一部を払い、残りをクレジットカードで払う
- クレジットカードで一部を払い、残りを現金で払う
- グループ内で割り勘し、それぞれがクレジットカードで払う※
※ 現在は機能として未実装
会計に対して支払いは一回とは限らず、複数回行えるとした方がよさそうです。 そのため冪等キーは支払いに対して毎回ユニークなキー(UUIDなど)を発行することになりました。
同時支払いの問題
次に、2つめの問題である同時支払いについて考えていきます。
同時支払いはいわゆるECサイトなどでの決済では起きづらいですが、複数人の会計がひとつにまとめられている飲食店での決済においては発生する頻度が高まります。
同時支払いが起こるシチュエーションは主に2パターンあります。
複数のユーザーが同時に払うケース
飲食店における会計はグループ内で共有しているため、グループ内の誰でも会計を行うことが可能です。 そのため一人が支払いを開始して完了する前に別の人が支払いを開始してしまうと、両者が決済してしまうことにます。
なお、前述の通り1会計につき1支払いとは限らないので支払いを1回だけに絞ることはできません。
オンライン会計とオフライン会計が同時に行われてしまうケース
複数人の同時支払いと同様に、オンライン会計とレジでの現金払いが同時に行われてしまうケースも可能性としてあります。
オフライン決済だと現金以外にクーポン利用もあり、クーポンであれば過払いも許容されます。(例. 1,000円のお会計に3,000円のクーポンで支払う)
注文の排他制御
この問題を解決するには、支払いに対しての排他制御が考えられます。
誰かが支払い行為を開始した時点で支払い枠にロックをかけ、他の支払いは通さないようにさせます。他の人がロックを取得している状態で新しくロックの取得をしようとすると失敗するため、支払いは常に1つずつ処理されることになります。
排他制御を加えた決済の処理としては、
- オーダーアプリからGatewayサーバーへ決済のリクエストを送る
- Gatewayサーバーは、注文サービスへ会計情報を問い合わせ、ロックを取得する
- Gatewayサーバーは、取得した会計情報の金額で決済サービスへ決済処理をリクエストする
- 決済サービスは、Stripeへ決済をリクエストする
- 決済が完了したらGatewayサーバーは、注文サービスに該当の注文に対しての支払いレコードを登録し、会計を支払い済みのステータスに更新する
対応方針のまとめ
2つの多重決済の問題に対して、以下の対応方針で解決できそうなことがわかりました。
- 支払いの排他制御を行う
- 各処理で失敗したら冪等性を担保しつつリトライを行う
補償トランザクション
ではリトライを何度も行っても解決しない場合はどうすればよいでしょうか。
決済サービスへの決済処理が完了するまであればバックオフが可能です。 その場合、取得したロックを解除してユーザーからの再試行を行える状態にします。
では決済処理完了後に、注文サービスへの支払い完了登録が行えない場合はどうでしょうか。
バックオフはできませんし、エラー終了すると支払いのロックは残ったままでデッドロックとなってしまいます。 同様に、バックオフ中にロックの解除に失敗し続けた場合もデッドロックのままとなってしまいます。
現状ではこの状況に陥った場合には、お客様からは支払いを頂かずに後日トレタ側で飲食店へ補填する対応としています。 そもそも注文サービスが何度リトライをしても失敗してしまうケースとしては、サーバーがダウンしている可能性が高いので、その他の処理も続行が難しいと考えられるためです。
最善の策ではないので、発生頻度などを注視しながらあるべき対応を引き続き検討している状況です。
実装手段
これらの実装にあたって、今回はGCPのWorkflowsの利用を取り入れました。
Workflowsはタスクを登録することで外部API呼び出しなどの定義されたステップを順に実行してくれるというサービスです。 AWSであれば似たようなサービスとしてStepFunctionsがあります。
リトライの仕組みを持ち合わせているため、リトライ回数やインターバルなど柔軟なリトライ処理を容易に組むことができます。
今回はこのWorkflowsを使い、外部API呼び出しとそのレスポンスに応じたリトライ、バックオフ処理を実装しました。また、タスク登録時にタスクのIDを得られるので、冪等キーにはこれを利用しました。
ちなみにWorkflowsでの使いづらい点を挙げておくと、基本的に定義はすべてyaml形式であるため、プログラマブルな細かい制御は難しく、テストもしづらい点です。
さいごに
以上が今回行ったトレタO/Xにおける多重決済に対しての対応のアプローチでした。
分散トランザクションの難しさに改めてて対峙し、自分の未熟さを痛感しましたがここで得た教訓とノウハウを今後の開発に活かし、より安全なシステムの開発に向けて精進していこうと思います。
なお、トレタではエンジニアの募集を全方位で行なっております。
コロナ禍を乗り越えた飲食店の新しい姿を探求する仲間をお待ちしております。