読者です 読者をやめる 読者になる 読者になる

Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

nginxとGoでつくるメルカリのプッシュ通知システム

原稿の締め切りを越えた先にあるもの、それは校正です。「俺達の戦いはこれからだ!」的な展開に胸が熱く・・・いえ、ちょっと胃のあたりがチクチクする@cubicdaiyaです。

今回はメルカリのプッシュ通知システムとその変遷について紹介します。

メルカリとプッシュ通知

メルカリではアプリ内でのさまざまなイベントの発生時に対象ユーザに対してプッシュ通知を行うようになっています。アプリ内で発生するイベントというのは例えば、

  • 商品の購入
  • 購入した商品の発送
  • 商品に対するいいね!コメント

といったものです。通常アプリ内でイベントが発生した際はAPIサーバにHTTPSリクエストを発行し、MySQLデータベースへの参照・更新が行われた後ユーザにレスポンスを返します。そして必要であればプッシュ通知を行います。

メルカリのプッシュ通知システムの課題と変遷

メルカリのプッシュ通知システムはこの1年間で2度刷新され、早くも3世代目を迎えています。

すべて同期処理だった1年前

1年前、メルカリのアプリ内で発生したイベントのプッシュ通知処理はすべてApacheとmod_phpによるAPIサーバ上で同期処理(!)として実装されていました。

f:id:cubicdaiya:20150810120101j:plain

スマートフォンアプリでよく利用されるAPNsやGCM(あるいはAmazon SNS)によるプッシュ通知処理は外部サーバとの通信を伴うため非常にオーバーヘッドが大きく(数百msecから数sec)、APIサーバのパフォーマンス低下の原因になっていました。

プッシュ通知を非同期で行う

そこでまずはプッシュ通知処理がAPIサーバのパフォーマンスに影響を与えないようにメッセージキューとジョブワーカーを利用してすべてのプッシュ通知を非同期で行うように変更しました。これでAPIサーバで行われるのはメッセージのエンキューのみとなり、特定APIのパフォーマンスが大きく向上しました。

f:id:cubicdaiya:20150810120123j:plain

メッセージキューにはQ4Mを利用し、ジョブワーカーはphp-parallel-preforkによるマルチプロセスデーモンとして実装しました。元々メルカリにはこの二つを利用したプログラムの非同期実行のための仕組みが用意されており、APIサーバで実行するには重たい処理を非同期で実行するのによく利用されています(例:検索インデックス、キャッシュの更新等)。そのためこのアーキテクチャへの移行はかなり簡単に行うことができました。(実働1週間程度)

スケーラビリティ向上のためプッシュ通知プロキシサーバを開発・導入

Q4Mとphp-parallel-preforkによる非同期プッシュ自体はうまく稼働していましたが、一方であまりスケールしないという問題があり、キューやジョブワーカーのサーバを増やして対処するのに限界を感じるようになりました。

そこでプッシュ通知システムのスケーラビリティを引き上げるため、GCMとAPNsへのプッシュ通知処理を独自のAPIでラップしてプロキシするHTTPサーバ(Gaurun)をGoで書き、前段にnginxを配置してロードバランスする構成にしました。

f:id:cubicdaiya:20150810120134j:plain

前述のQ4Mとphp-parallel-preforkによるメッセージキューとジョブワーカーの役割をGaurunが担うことでシステムがシンプルになると共にジョブワーカーの性能を大幅に引き上げることに成功しました。また、一方通行のシステムになったことでスケールさせやすい構成になっています。

この構成になって半年ほど経ちますが安定して稼働しており、以前と比べてサーバ負荷もかなり低く抑えられるようになりました。

また、上記のプッシュ通知プロキシサーバのGaurunはOSSで公開しています。

github.com

サーバクラッシュ時の対応

一方でこの構成には欠点があってメッセージキューの実態がGoのチャネルなのでサーバクラッシュによってプッシュ通知に必要な情報が失われる可能性があります。この問題に対するGaurunのアプローチは以下のようになっています。

  • アクセスログを一種のジャーナルログとして扱う
  • サーバがクラッシュした際はアクセスログを舐めて必要なプッシュだけ再実行

Gaurunは以下のタイミングでアクセスログにJSON形式でログを出力します。

  • プッシュ通知リクエストを受信(accepted-request)
  • プッシュ通知データをエンキュー(accepted-push)
  • プッシュ通知が成功(succeeded-push)

プッシュ通知が成功した際は「accepted-push」と「succeeded-push」の両方がログに出力されます。また、各プッシュにはIDが割り当てられます。

[info] {"type":"accepted-push","time":"2015/08/09 00:19:07 JST","id":1,"platform":"android","token":"xxx","message":"bokko他3名が「...」にいいね!しました","ptime":0,"error":""}
[info] {"type":"succeeded-push","time":"2015/08/09 00:19:07 JST","id":1,"platform":"android","token":"xxx","message":"bokko他3名が「...」にいいね!しました","ptime":0.129,"error":""}

プッシュ通知が失敗した際は「accepted-push」のみがアクセスログに出力されます。(失敗の情報はエラーログに出力されます)

[info] {"type":"accepted-push","time":"2015/08/09 00:19:07 JST","id":1,"platform":"android","token":"xxx","message":"bokko他3名が「...」にいいね!しました","ptime":0,"error":""}

この性質を利用してアクセスログから「accepted-push」しか出力されてないプッシュを抽出して再実行するというわけです。(Gaurunにはこのためのコマンドが用意されています)

Gaurun〜A general push notification server in Go〜

Gaurunについては今年の6月に開催されたGo Conference summer 2015での発表資料があるのでさらに詳しく知りたい方はご覧下さい。

Gaurun / A general push notification server in Go

まとめ

メルカリのプッシュ通知システムとその変遷について解説しました。大量のプッシュ通知処理をスケールさせるにはプログラムに高い並行性が要求されるためPHPだと難しかったのですが、Goだと言語レベルで並行処理がサポートされていることもあって比較的楽にスケールできる構成にすることができました。