メルカリShops 注文システム反省会

こんにちは。ソウゾウ Software Engineer の @sou です。連載:メルカリShops 開発の裏側 Vol.2 の6日目を担当させていただきます。

この記事ではメルカリShops 注文システム反省会として、リリースから半年を迎える中で注文システムにどのような問題が起こり、どのような改修が必要になったかを振り返ってみたいと思います。

注文システムの概要

メルカリShops はマイクロサービスで構成されており、注文の受付を行うために様々なマイクロサービスにまたがって API を呼び出す必要があります。
まずは購入画面をご覧ください。

メルカリShops の購入画面
メルカリShops の購入画面

「購入する」を押すとローディングアイコンが表示されて待ちに入り、クライアントがレスポンスを受け取ると購入の完了を示す画面が表示されます。

一方裏では購入を完了させるまでに在庫確保や決済など様々な操作を行っています。
本記事ではそれぞれの操作をフェーズと呼んでおり、各フェーズはそれぞれひとつの API にまとめられています。

オーケストレーションには Cloud Workflow を用いており、購入のリクエストを受けてから注文が成立するまで、 Cloud Workflow を通して各フェーズの API を呼び出し処理を進めています。

お客さまから見たとき購入処理は同期的ですが、裏側で Cloud Workflow による API コールは非同期に行われています。
そのため注文リクエストを受けたプロセスが結果を受け取って同期的に処理できるようポーリングを行っています。

Cloud Workflow が呼び出すフェーズは次のようなものです。

  1. 在庫を確保する
  2. 配送先情報を登録する
  3. 購入代金を仮押さえする(チャージする)
  4. 購入代金を確定する(チャージをキャプチャする)
  5. メールを送信する。また購入処理を完了する

シーケンス図にすると次のようになります。
内容は簡略化してあります。

簡略化したシーケンス図

途中でエラーが発生した場合はどうなるのでしょうか。
エラーは二つに大別できます。
Abort のようにデータベースが瞬間的に利用できずに発生するようなリトライで解消される可能性のあるエラーと、残高不足のようにリトライを行っても解消されないエラーです。
本記事ではそれぞれ前者を retryable、後者を non-retryable と言い表します。

Retryable なエラーが発生した場合は API が retryable であることを示すエラーコードを Cloud Workflow に返し、それを受け取った Cloud Workflow はもう一度 API を呼び出してフェーズ全体を再実行します。
注文処理は Order Service と呼ばれるマイクロサービスが主に受け持っており、そのため Order Service 内の処理と Order Service がコールする各マイクロサービスの API は冪等に設計されています。

冪等とは、ある操作の結果が実行の回数に関係なく一定であることが保証されている性質を指します。
安全にリトライを行うためには、この冪等性の担保が重要になります。

Non-retryable なエラーである場合は同様にエラーコードを返し、non-retryable なエラーコードを受け取った Cloud Workflow は注文を取り消すフェーズを呼び出します。
またリトライの上限に達したりタイムアウトとなった場合も、この注文を取り消すフェーズに処理が流れます。

注文取消のシーケンス図

ここまでがリリース当初の注文システムの説明です。
メルカリShops の急速な成長にも耐え、大体のキャンペーンも事故なく乗り切るなど、おおむねよく動いてくれました。
ただ幾つかのエッジケースをカバーできていなかったり、急激な注文数のスパイクを受けた際に潜在的な問題が顕在化するなど、半年の間に修正されるべき問題も見つかっています。

次に実際にどんな問題が起きたのか、事例を列挙してみようと思います。
尚、お客さまに影響を与えた事例も含まれており、ご迷惑をおかけしたことを改めてお詫び申し上げます。

注文メールが送信されたのち、注文が取り消される

注文処理の最後のフェーズでは、お客さまにメールを送信したのち、データベースを更新してステータスを書き換え、注文の確定を行っています。

シーケンス図抜粋

メールの内容は店舗に注文が成立したことをお知らせするもので、商品を発送してくださいという旨をお伝えしています。

メール送信に使われる API は実装上の都合からキャンセルを行う API が無く、リクエストに成功するとそのままメールが送られます。
リリース当初はメール連絡が重要であったため、メール送信に失敗した場合はフェーズ全体をリトライして確実にメールを送信し、そののちデータベースを更新して注文を確定させるといった順番になっていました。

ところがキャンペーン開始に伴う瞬間的な大量のアクセス増を受けた際にデータベースが不安定になったことがあり、メール送信が成功したのちにデータベースの更新に失敗したままリトライ回数の上限に達し、その結果、注文の取り消しが行われてしまう、といったことがありました。

メールは送信されているため、店舗はメールを見て商品を発送される可能性があります。
一方で注文は取り消されており、不整合が生じています。

この問題が発生した時期にはメール通知の重要性が下がっていたため、止血対応としてデータベース更新のあとにメール送信を行うよう順番を変更しました。
ただメールが送られない可能性が残るため、これはあくまで止血対応です。
根本的な解決については後ほど他の問題と合わせてお話させてください。

取消の発生率が上がる

前述で non-retryable なエラーをワークフローが受けると自動で注文を取り消すフェーズが実行されることをお伝えしました。
これ自体は単純で扱いやすいものですが、中長期の運用を考えると問題があります。

注文処理は EC における中核的な機能のひとつで、シーケンス図に見るように多くの API コールを通じて成り立っています。
仮に成功率 が 99.9% の API を三つコールする必要があるとすると、それに依存する API の成功率は 99.6% 以下になります。

新たな配送方法の導入やキャンペーンロジックの追加など、注文処理が依存する API の数は増えることはあれ減ることはなく、時間経過とともに成功率が低下していく構造は問題だといえます。

また API の重要性に関わらずエラーひとつで注文の取り消しに突入する仕組みは機会損失を増やす構造になっていると言えます。

実際、新たにメッセージを送信する機能を追加した際、リリースを行ったときにうまく動かず、本番へのデプロイからロールバックを行うまで、その間の注文が全件取り消しになっていたという問題がありました。
本来であればメッセージ送信のみ失敗させればよく、注文の取り消しは不要であるはずです。

処理が重厚になるにつれ注文の取り消し確率が上がることに加え、付帯機能でエラーが生じても注文全体が取り消されてしまう構造はアーキテクチャに問題があると言えます。

決済のキャンセルに失敗することがある

購入代金の決済を行う際は購入代金を仮押さえするチャージという処理を行い、購入代金分を与信枠から差し引いています。
チャージが発生したあとに注文を取り消す場合はこのチャージをキャンセルし、与信枠を戻す必要があります。

チャージ処理はクレジットカードなどの支払手段を扱う決済代行会社の処理にも依存しており、非同期に行われています。
また、比較的時間のかかるものです。
場合によってはリクエストの処理時間内で終了せず、処理中のままタイムアウトを迎えることがあります。

具体的には次のようにして問題が起こります。
チャージのリクエストが送信され、リクエストが処理中であることを示すレスポンスが返ります。
Retryable であるのでワークフローはリトライを行いますがここでも処理中を示すレスポンスが返り、やがてリトライが上限に達して失敗扱いとなり、注文を取り消すフェーズが実行されます。
取り消し処理の中でチャージのキャンセルも行われますが、まだチャージは処理中であり、未確定のチャージはキャンセルできないためリクエストは失敗します。
リトライが行われますが、やがてこれも上限に達するかワークフロー全体がタイムアウトして、取り消し処理自体が失敗して終了します。
その後、非同期に行われていたチャージ処理が成功し、購入代金が仮押さえされた状態で注文が残ってしまうといった不整合が生じます。

問題が発生した際はバッチによってキャンセル処理を再開し復旧を行っていましたが、これも暫定的な処置です。
取り消し処理自体が失敗しないことが一番です。
根本的な解決策については次にまとめて述べさせて頂きます。

よりよい設計を考える

ここまで問題を列挙してきましたが、本来どのような設計がよかったのでしょうか。

改めて振り返ってみると、注文システムを構成する処理は次のように分類されることが分かります。

  • リソースを仮押さえする(仮押さえ)
  • 仮押さえに成功したものを確定する(確定)
  • キャンセルを行い、仮押さえを取り消す(取り消し)
  • 注文確定後、付帯的な処理を行う(付帯処理)

リソースを仮押さえする処理は注文の成否に影響を与えます。
例えば残高不足である場合は購入代金の仮押さえがエラーになり、注文は不成立となります。

仮押さえに成功してしまえば、注文は成立したとみなすことができます。
仮押さえの処理を行っている間、お客さまは「購入する」を押して結果が返るのを待っています。そのため仮押さえの処理はタイムアウトを持ちます。

確定処理はすでに注文が成立しているため、タイムアウトを気にする必要がありません。
また不成立に巻き戻ることもないため、成功完了するまでリトライを行うのがよさそうです。
取り消し、付帯処理も同様に、それぞれの処理が完了するまでリトライを行えばよさそうです。

改修後のシーケンス図を考えてみると次のようになります。

改修後のシーケンス図

最後の「4. 注文を確定する」フェーズでは、注文確定を行うと同時にメッセージングに用いるメッセージを同じトランザクション内でデータベースに保存しています。
別プロセスがこのメッセージを拾ってメッセージキューや Pub/Sub に送り、確定処理や付帯的な処理を行います。

残高不足などで失敗になった場合も同様に、注文不成立としてデータベースに記録すると同時に同じトランザクション内で取り消し処理を行うためのメッセージをデータベースに保存します。

これで上記にあげた問題が構造的に解決されるのかどうかを確認してみましょう。

「注文メールが送信されたのち、注文が取り消される」という問題はどうでしょう。
メール送信は付帯処理として実行されます。
この段階では既に注文が確定されており巻き戻ることはないため、構造的にこの問題が発生しなくなっていることがわかります。

「取消の発生率が上がる」はどうでしょう。
仮押さえとそれ以外の処理を区分しているため、注文の取り消しにつながる API の数は最小に絞られていることが分かります。
例えば新たにメッセージを送るような機能が追加されたとしても、注文確定後に実行される付帯的処理の中で行うことになるため、注文の成功率に影響を与えることはありません。

「決済のキャンセルに失敗することがある」についてはどうでしょうか。
問題を簡単に振り返ると、チャージリクエストが処理中に入り、その後なにかしらの理由で注文が失敗してチャージをキャンセルしようとするものの処理中であるために失敗、ワークフロー全体がタイムアウトを持っているために失敗したまま終了して不整合が生じるというものでした。
新しい構造では取り消し処理はワークフローから分離されており、成功するまでリトライを重ねます。
リトライを重ねるうちにチャージの処理が終わってキャンセルが行えるようになり、このタイミングで取り消し処理が成功します。
これも構造的に解決されていることがわかります。

改修計画を立てる

新たなアーキテクチャに移行すれば問題が解決することはわかりました。
ただ実際に行うにはコストもリスクも大きく、計画的に進める必要があります。

また上記に挙げた問題以外にも大小様々な課題があり、自分ではまだ把握できていない潜在的なものもありそうだと思いました。
そのためチームメンバーからヒアリングを行い、題点や懸念を洗い出す機会を作りました。
ヒアリングで拾い上げたものはリストにまとめ、個々に調査や確認を行って整理し、それぞれどのような対応が望ましいかを明らかにしました。

その上で新アーキテクチャの移行案とまとめて整理を行い、タスク分解して Notion にまとめています。
新アーキテクチャも一度に移行するのではなく、ステップを踏んで徐々に移行していくようタスクに分解されています。
スプリントの中でチームでタスクを消化しながら、よりよいシステムを目指して改修を積んでいければよいと思っています。

改修に向けたタスクが積まれている

タスク化したことでインシデントリスクの高いものやお客さま影響のあるものなど、投資効果の高い順に消化していくことができます。

また独立性の高いシンプルなタスクはオンボーディングにも使えるでしょう。
リリースからこれまで様々な機能が追加がされてきましたが、今後もさらなる機能開発が予定されています。
新たなチームメンバーに既存のドメイン知識を渡していくためにも、既存機能の改修はよいきっかけになると思います。

振り返って

ソフトウェアエンジニアとして様々なシステムの開発に携わってきましたが、メルカリShops は開発期間に比べ、サービスの成長速度がこれまで経験した中でもトップクラスに位置するものでした。
日々注文数が伸びていく姿にわくわくしながら、同時にシステムの安定稼働に心を砕いていました。
自分で組んだ車がいつの間にか F1 レースに参戦することになり、未体験のスピードを目指してアクセルを思いっきり踏み込んでいく、そんな日々が続いていたと思います。

システムの問題を公開することは自分の未熟さを公開することでもあり、何よりインシデントでご迷惑をおかけしたお客さまを考えると記事にすべきかどうかためらいましたが、失敗こそ学びが多いと思い、書かせていただきました。
自分にとってもメルカリShops のような成長スピードの早いシステムをリリース前から Zero to One で開発できたことは失敗の経験も含めて貴重なものであり、もしこれをご覧いただいている方のお役に立てる部分あればうれしく思います。

不勉強な部分、またよりよく出来る部分が多くあると思います。
ここに記せなかった課題や問題も多く存在します。
もし指摘できる部分を見つけられたり更に突っ込んだ話をしてみたいと興味を持たれた方は、是非気軽に話しかけていただけると嬉しく思います。

ここまでお付き合いいただき有り難うございます。
もし本記事が貴方のお役に立てば幸いです。


ソウゾウではメンバーを大募集中です。ソウゾウに興味を持った方がいればぜひご応募お待ちしています。詳しくは以下のページをご覧ください。

またカジュアルに話だけ聞いてみたい、といった方も大歓迎です。こちらの申し込みフォームよりぜひご連絡ください!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加