ECサイトの購入・決済システムのステップアップガイド(脱初級レベル)
背景
弊社(SNKRDUNK)の購入機能では、新機能追加が続き複雑度が高まり手に負えなくなりつつある。これは弊社に限らずECサイトではよくある悩みであろうので、本稿では、購入システムを1段階ステップアップさせる設計について議論する。特に購入時の決済処理についてフォーカスする。
(1) 購入&決済処理の一貫性を守る
現状、購入決済処理の一貫性の担保が曖昧であり、不整合が発生したり、例外時に複雑な補償トランザクションを実行したりしている。一貫性を守るための基本設計を考察したい。
全体図

詳細説明
トランザクションを2フェーズに分ける
- 1st: 商品を在庫確保する
- 決済を行う
- 2nd: 商品の購入を成立させて、注文情報を作成する
失敗時は補償トランザクションを実行(それまでの変更を打ち消す)
- 決済が失敗した場合は商品を在庫確保前の状態に戻す
- 2nd TXが失敗->決済をキャンセル + 商品を在庫確保前の状態に戻す
- その他、エッジケースとして、これらの補償処理が失敗したり、1st~2ndの間でプログラムがクラッシュしたりすると、一貫性が破れてしまうので、バッチやキューイングで救済させるか、アラートを入れて手動対応する
フェーズ間でトランザクションは分ける
- APIリクエストを挟んで占有ロックを取り続けると、長時間ロックを取ってしまう危険性がある
- 他でデータを参照している処理などで障害に繋がるリスクがある
- MySQLはデフォルトでロック待ち時間は90秒で、それを超えるとエラーになる
- DBの整合性は確保できるが、APIで連携している外部サイトと整合性が取れるわけではない
フェーズ間の整合性は楽観ロックで取る
- 実際の実装としては2択が考えられる
- (1)フェーズごとにマスタDBからデータを取り直してバリデーションする
- (2)versionカラムを使って楽観ロック機構を使う
- いずれも1st~2ndの間でデータが上書きされていればエラーにする
1st TXは何のため? なぜ在庫を確保するのか?
- 同時購入の防止
- 巻き戻しが容易い
教訓: 購入決済処理は、自社とPSP(外部決済サービス)の分散システムによって成り立つことの意識が必要
自社がマイクロサービスでなくとも、「2フェーズコミット」「Sagaパターン」といった分散システムで一貫性を保つ手法を知っておくと参考になる
(2) 決済を購入から分離する
現状、購入処理と決済処理の責務が曖昧であり、一つの巨大な購入決済処理となっている。ここから決済処理を分離したい。
なぜ購入・決済の分離をしたいか
- 購入・決済ともにビジネスインパクトが大きく改修頻度が高く複雑度も大きくなりやすい。分離をすることでそれぞれの複雑度を低減することが期待できる。
- 決済は外部サービスの知識を強く求められる領域であり、購入は自社サイト側の知識を強く求められる領域であり、別の知識が必要である。分離して管理者を分けることでお互いの認知負荷を下げることが期待できる。
- 決済のアーキテクチャは外部サービスの要件(セキュリティやのレートリミット等)によって制約を受けることがある。よって決済を分離することでその要件を満たしやすくなるし、購入処理がその要件の影響を受けずに済ませることが期待できる。
購入・決済の依存関係

- 購入->決済 を基本とする
- 自然なユーザフロー
- お互いの認知負荷を下げるための分割ルール
- 購入は外部決済サービスのことを知らない
- 決済は購入のことを知らない
- 購入が決済のことを知るのはOK(ここを完全に疎にするのは困難で、決済フローによって購入の実装が変わっていくことは起こりうる)
(3) 非同期アーキテクチャを導入する(部分的に)
外部決済サービスが非同期処理となっている場合がある、また今後決済システムの可用性を高めたくなるかもしれない、といったことから非同期アーキテクチャを適切に導入できるようにしたい。ただし非同期処理はデメリットもあるので、非同期処理・同期処理の選定基準を明確にしておきたい。
(前提)本書でいう非同期処理の説明
リクエスト〜レスポンスとは別のライフサイクルでで実行され、リクエストしたサービスがレスポンスを受け取った時点ではジョブの実行結果は不明であるようなもの。

Why 非同期処理?
- 可用性・スケーラビリティ向上
- リクエストがスパイクしてもキューに積んでおいてワーカーのキャパシティで処理できる
- ワーカーがダウンしてもキューにイベントが残っているので復旧した時に再開できる
- その他何かソフトウェア例外が起きてもリトライして復旧する仕組みを設けやすい
- サービス間の結合度を下げる
- 呼び出し元から見て呼び出し先の知識が不要
- 呼び出し先の負荷や異常に引っ張られることがない
- etc. (時間の掛かる処理を逃す目的や1対Nの通信など)
非同期アーキテクチャのデメリット
- 実装のオーバヘッドが大きくなる
- ソースコード、テスト、トレーシングそれぞれ若干手間が増える
- ユーザ体験の低下
- ユーザジャーニーに沿って同期・非同期を選択すべき
- etc.
同期処理を非同期処理に置き換える時に考えるべきこと: 進捗管理と多重実行防止
- 同期処理を非同期処理に置き換える際の懸念事項
- 進捗管理: リクエストした呼び出し元のサービスが非同期処理の進捗を把握したい場合がある
- 多重実行防止: リトライその他の理由から複数回実行される可能性がある
- 双方解決するためにjobsテーブルの導入
- 非同期処理の開始時と終了時にjobsテーブルを読み書き
- ステータスと冪等キーを保持して進捗管理と多重実行防止を実現する

[Advanced] 外部決済サービスからのコールバックの設計
ここまで購入・決済の分割や、その処理の流れについて述べた。
よくある処理の流れは2種類ある。「ユーザからスタートして購入->決済と流れるフロー」と「決済サービスからスタートして決済->購入と流れるフロー」の2種類である。後者のケースではイベント駆動として決済->購入の依存をできるだけ作らないような設計を考えている。

しかし、購入・決済の処理フローには他のパターンもある。例えばクレジットカード決済などで購入をリクエストして追加認証が必要になったケースがある。このケースではユーザは外部決済サービスと直接通信をして、その結果をECサイトに送信する。ECサイトは認証結果を確認して購入確定処理をする。
このAPIは販売・決済のどちらに置くべきか?

- 選択肢A: 販売ドメインにAPIを置いて、決済ドメインのロジック(与信の取得)を利用
- 選択肢B: 決済ドメインにAPIを置いて、販売ドメインのロジック(購入確定)を利用
このAPIには厄介な二面性がある。
- 入力 (リクエスト): 外部決済サービスが生成するデータで、「決済」に強く依存
- 出力 (レスポンス): 購入成功/失敗ページへのリダイレクトURLなど、「購入」に強く依存
つまり、購入と決済のどちらに寄せても、もう片方のドメインへの依存が避けられない。
とはいえ、今回は以下のように購入サービスのAPIとして定義した。
- 同期処理において購入->決済の依存方向を守ることができる
- 購入APIと相似形になるので購入フロー全体を理解しやすい
- APIの入力型は決済サービスで型定義して公開しておくことで責務を分けることも可能

[Advanced] 信用チェックパラメータの取得
課題
- 一部決済手段は、決済時に、その決済を通すかどうかを判断するためのパラメータの送信を求める
- 購入履歴, 決済履歴, ログイン履歴, ユーザのプロフィール, 商品情報...
- その情報を元に追加認証や決済の可否を判断する
- これの取得を決済モジュールに任せると、他モジュールとの結合度が大きく上がってしまう
- 決済は基本的に依存される側のモジュールとしたく、ECサイト側に強く依存する作りは避けたい

どうする?
- 購入側で収集して決済モジュールに渡す
- 依存の方向性としては悪くなさそうだが、購入の責務は減らしたい
- 信用チェックパラメータ収集用のモジュールを作る
- 購入処理から呼び出して、受け取ったものをそのまま決済モジュールに渡す
- この選択肢が妥当か?
- 決済モジュールがユーザの行動をイベントソーシングして信頼性パラメータを自前で持つ
- ユーザの行動のイベント(購入, ログイン, プロフィール更新)を出版してもらう。それを決済モジュールが購読して、決済モジュール内のDBに保存する。決済時にはそのDBからデータを取り出して信用チェックパラメータとして、外部決済サービスに渡す
- 複雑なシステム構築であったり、ユーザ情報を決済モジュールが抱えたりしなければならない
株式会社SODAの開発組織がお届けするZenn Publicationです。 是非Entrance Bookもご覧ください! → recruit.soda-inc.jp/engineer
Discussion