iOSエンジニアから転向して在庫補充通知機能をリリースしました!

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

連載でも触れている方が多いですが、ソウゾウでは全員Software Engineerという文化があります。
自分は新卒から昨年の10月までずっとiOSエンジニアとして働いてきましたが、ソウゾウへの入社にあわせて転向し、現在はバックエンド・フロントエンドの開発を行っています。

先日「在庫補充通知」機能をリリースしました。連載の公開表の段階ではまだリリースできてなかったので(予定)をつけていたのですが、無事リリースできたため本稿では外しています(よかった)
その際、どのようにアーキテクチャを考え、実装したのかを説明します。

※ iOSの話は一切出てきません
※ 現在はまだ一部のお客さまにのみ公開されています。

在庫補充通知とは

CtoCマーケットのメルカリでは「一点物」の商品が扱われますが、メルカリShopsでは事業者さんが販売を行うので、「在庫」が存在します。
人気商品は販売から数時間で売り切れ、のようなことが起こり得ます。

「在庫補充通知」とは、欲しい商品が売り切れていた場合、在庫が補充されたときに通知を受け取ることができる機能です。

「入荷時に通知」ボタンの画像

プッシュ通知とE-mailにより通知を行います。
一度通知が送信されると、設定は解除されます。

在庫補充通知のプッシュ通知

前提条件

メルカリShopsではマイクロサービスアーキテクチャを採用しています。現在50程度のマイクロサービスがあり、gRPCで接続されています。各マイクロサービスは異なるDBを利用しており、処理に必要なデータはすべてgRPC経由で渡す必要があります。

設計を考える上で、どれだけ使われるかは大事な要素です。メルカリShopsには「いいね!」機能があり、商品をピンすることができます。この機能は在庫補充通知の登録数を見積もる上で参考になりそうです。

メルカリShopsでは2021/11/29に「メルカリShops Come to Meatキャンペーン」と称して、5500円のお肉を500円で買えるキャンペーンを実施しました。販売開始から数秒程度で売り切れてしまうほどの盛況で、人気商品は2.5万件の「いいね!」を獲得していました。
よって、数万件の通知送信に耐えられる設計にする必要があると考えました。

¥5,500のお肉が¥500で買える!

ナイーブな実装

単純な実装方法として思いつくのは、出店者が在庫数を登録する処理の中で、通知を行うことです。

在庫情報はinventoryサービスが管理しており、在庫登録時にはBFFなどを経由して呼び出されます。メール送信やプッシュ通知は既に実装があり、notificationサービスのAPIを呼び出すと非同期で実行することができます。
在庫更新の一連の処理の中でこのAPIを呼び出せば、在庫補充通知を送信することができそうです。

restock_subscription_naive_sequence

しかし、実際の通知の作成のためには他のサービスを呼び出して商品名など通知に必要なデータを集めたり、通知登録者をDBから読み込んだりする必要があるため、上記の方法をとると以下のような問題が起きてしまいます;

  • データ収集・通知作成に時間がかかる分、レスポンスが遅くなる
  • 通知処理だけが一時的にうまく動かない場合、在庫登録はできる状態であっても全体がエラーになってしまう

Pub/Subによる疎結合化

理想的には、データ収集や通知に失敗しても在庫登録自体は成功させたいです。また通知失敗時には通知だけを再試行したいです。
そのため、Pub/Subを使って処理を分割することにしました。

Cloud Pub/Subはpublisherがイベントをブロードキャストし、subscriberがそのイベントを購読することで非同期に処理を行う仕組みです。
疎結合化のため、新しいマイクロサービスとして”restocksubscription”サービスを作り、「在庫更新イベント」を購読して、在庫補充通知を行うことにしました。

単に通知処理を非同期で実行するのも一つの手ではありますが、「在庫更新イベント」は今後、検索用インデックスの更新、キャッシュの削除など、他の用途で利用される可能性があります。
よって、inventoryサービスはあくまで「在庫更新イベント」として発行をし、restocksubscriptionサービスはそのイベントを購読して通知を行う、以下のような構成にしました。

restock_subscription_pubsub_sequence

ちなみにPub/Subの購読では、subscriberのAPIがPub/Subから呼び出されるpush方式と、subscriberがPub/Subを呼び出してイベントを受け取るpull方式とがありますが、今回はpush方式を利用しています。

これにより、先程挙げた以下の2つの問題が解消し、たとえ通知処理がエラーになっても在庫登録処理自体は成功するようになりました!

  • データ収集・通知作成に時間がかかる分、レスポンスが遅くなる
  • 通知処理だけが一時的にうまく動かない場合、在庫登録はできる状態であっても全体がエラーになってしまう

また新しくできたrestocksubscriptionサービスは独立しており、たとえダウンしていてもシステム全体には影響を与えないので、その意味でもマイクロサービスの利点を生かせています。

処理時間の問題

しかしまだ懸念はあります。

通知作成の際、最大数万件分もの通知を行う必要があります(シーケンス図ではloopで囲っている部分です)
バッチのAPIを作成して100件ごとにまとめて呼び出しても、5万件だと500回呼び出す必要があります。

通知処理のループ

たとえ1リクエストのレイテンシが0.1秒だったとしても、500回呼び出せば500回 * 0.1 秒 = 50秒かかることになります。
通知作成は非同期化されているとはいえ、ログのテーブルに書き込んだり、通知登録の削除もするので、数百回の呼び出しを現実的な時間で終えるのは難しそうです。また連続的に大量の処理を実行すると途中でエラーが発生することも多く、再試行の際にも余計な時間がかかってしまいます。

この処理時間の問題に対する1つの解決策は、goroutineなどを使って並列で呼び出すことです。
しかし、それではネットワークにもnotificationサービス側にも負荷がかかりますし、総流量の制御も難しく、一気に様々な商品に対して通知イベントが実行されると、ネットワークが詰まる(輻輳)などの危険性が高まります。
また、再試行の際に余計な時間がかかる問題は解決していません。

Cloud Tasksによる更なる非同期化

上記の問題を解決するために採用したのが、Cloud Tasksによる更なる非同期化です。

Cloud Tasksは、非同期なタスクの実行、ディスパッチなどを管理する仕組みです。HTTP Target Tasksを使うと、タスクに基づいて任意のAPIを呼び出す事ができます。並列実行数や総流量も制御することができます。

これを使って、一気にすべての通知を行うのではなく予め分割されたタスクを作成し、それぞれの通知処理を非同期で実行することにしました。各タスクがそれぞれの送信先のリストを保持しています。
Cloud Tasksの作成だけなら応答性も高く、数百回呼び出しても現実的な時間で終わりそうです。またエラー時の再試行の粒度も小さくなるので、余計な時間がかかる問題も解決できそうです。

restock_subscription_tasks

データ収集が重複して実行されてしまう問題が起こりますが、今後負荷をみながら必要に応じてキャッシュなどを検討する予定です。

冪等性の担保

一連の処理が途中で失敗したときのことも考えなくてはなりません。

Pub/Subにはat-least-onceという特徴があり、最低でも一回はイベント(メッセージ)がsubscriberに渡されることが保証されています。逆に言えば、複数回渡される可能性もあります。その場合、イベントを受け取るたびに通知を実行していると、重複した通知が送られてしまいます。

この問題を防ぐため、Pub/Subから渡されるイベントにはmessageIdという固有のIDが含まれています。実行時にmessageIdを保存し、処理済みかどうかを判断することで通知の重複を防ぐことができます。
しかし、イベント受取時にこれを判定するだけではまだ十分ではありません。

もし分割されたタスクを作っている途中で失敗し、全体のタスク生成に対して再試行が行われた場合、同じ対象に対して異なるタスクが生成されることになります。イベント受取時にmessageIdを見るだけだとこの場合は通知が重複してしまいますが、もちろんこのケースでも防ぐ必要があります。

そのため、通知送信時にmessageIdと送信先を組み合わせてハッシュを計算し、それをキーにして重複を排除するようにしています。
これにより、タスクやイベントが何度再試行されても、一度しか通知が送信されないようになっています。

システムデザイン

実際は更に複雑になっていますが、最終的に出来上がったのは概ね以下のような構成です。

在庫補充通知システム全体のシーケンス図

これでめでたく在庫補充通知を送信することができるようになりました!

今後大量に通知登録されても障害が発生しないことを願います。乖乖*。

* 「いい子、いい子」という意味のお菓子で、よく台湾でサーバの上に置かれている

おわりに

iOSエンジニアから転向したばかりですが、今回バックエンド・フロントエンドの実装はもちろん、DB、システム全体の設計やTerraformを使ったインフラ設定、デプロイまで自分で触って、かなり学びになりました。モニタリングやDataflowの設定も行いました。

大変そうに聞こえるかもしれないですが、「メルカリShops における Cloud Run service の Canary Deployment」「メルカリ Shops の開発を支える Automation 化」にあるように、開発を支える色々な仕組みがあります。

機能開発はもちろん実装だけではなく、仕様策定から始まります。「メルカリShopsにおける開発の進め方」にあるようにユーザストーリからかなり議論を行いました。

メルカリShops全体のアーキテクチャについては「メルカリShops の技術スタック、その後」をご参照ください。

今回の設計についても「メルカリShopsでのDesign Docs運用について」にあるように、設計段階から議論をおこなっていますが、開発中に気づいた問題にも柔軟に対応していった結果、上記のような設計になりました。他の事例として、「souzohでのmicroservice開発の事例: 売上履歴サービスの開発」もぜひご参照ください!

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

またカジュアルに話だけ聞いてみたい、といった方も大歓迎です。Twitter(@_ha1f)などで個人的に連絡いただくか、こちらの申し込みフォームよりぜひご連絡ください。

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