Mercari Engineering Blog

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

pvpool〜メルカリの商品閲覧数カウントアップの裏側〜

SREチームの@cubicdaiyaです。今回はメルカリの商品閲覧数カウントアップの裏側について紹介します。

f:id:cubicdaiya:20180226144601p:plain

メルカリの商品閲覧数

メルカリでは出品されている商品の閲覧数を「出品した商品」の一覧や「いいね!した商品」の一覧画面から見ることができます。以下は「いいね!した商品」の一覧画面です。(開発版アプリの画面になります)

f:id:cubicdaiya:20180222150303p:plain

赤い枠で囲まれている部分がそれぞれの商品の閲覧数になります。今回紹介する閲覧数のカウントアップのバックエンドはGoで開発されています。

データベース上の商品閲覧数のカウントアップ

メルカリでは日々大量のリクエストを処理していますが、そういった中でもデータベースへのアクセスはINSERTやUPDATE等の書き込み処理よりもSELECTによる読み込み処理が圧倒的多数を占めます。(メルカリでは、データベースには主にMySQLを利用していますが、サービスやリージョンによってはGCPが提供しているCloud DatastoreCloud Spannerを利用している箇所もあります)

商品が閲覧される時に実行される処理もロギング等の処理を除けば基本的に読み込み処理のみになります。しかし、商品の閲覧数を都度インクリメントする必要があるとなると話は変わります。商品閲覧リクエストは全リクエストの中でも結構な比率を占めるので、単純にそれらすべての商品閲覧リクエストを処理する度に閲覧数をインクリメントしようとすると、データベースに非常に大きな負荷がかかってしまいます。そのため非同期処理は必須と言えます。

加えて、商品閲覧数のカウントアップには以下のような性能上の要件があります。

  1. 出来る限り低遅延で応答できるAPIを提供する(目安:数ミリ秒以内)
  2. 出来る限りリアルタイムに閲覧数を最新の状態に反映する(目安:数秒以内)

こういった事情から商品閲覧数のカウントアップの仕組みはPHPで書かれたメインのコードベースから切り離された別の内部サービスとして開発されることになりました。この仕組みは社内ではpvpoolと呼ばれています。

pvpool〜High performance count up infrastructure〜

以下の図はpvpoolのおおまかなシステム構成です。nginxとGo、そしてMySQLで構成されています。pvpooldはGoのnet/httpパッケージを利用したAPIサーバで、商品IDのリスト等を含んだJSONペイロードを処理して定期的にMySQLに閲覧数のデータを反映します。

f:id:cubicdaiya:20180226144601p:plain

pvpoolは、与えられた商品IDのリストに紐付けられた閲覧数をインクリメントするAPIと取得する2つのAPIを提供します。また、APIにはConsulで登録されたサービスのエンドポイントにHTTPでアクセスします。

pvpoold Internals

pvpooldは閲覧数のカウントアップリクエストを受け取ると、受信したJSONペイロードから商品IDのリストを取り出し、各商品IDをSlotと呼ばれる複数のプロセスユニットにシャーディングします。 各スロットは渡された商品IDの閲覧数をマップに集約したり、定期的にMySQLにデータをフラッシュする役割を果たします。

f:id:cubicdaiya:20180226113532p:plain

プロセスユニットであるSlotは以下の要素から構成されています。

  • Slot
    • 商品IDキュー
  • PVMap
    • キーが商品ID、値が閲覧数のマップ
  • Storer
    • Slotから商品IDを取り出してPVMapの各値をインクリメントするワーカー
  • Flusher
    • PVMapの各値を取り出してMySQLに反映するワーカー

Slotの処理フロー

f:id:cubicdaiya:20180222140754p:plain

StorerFlusherの実体はゴルーチンで、PVMap上のデータをやりとりをしながら協調動作します。以下はStorerFlusherの動作イメージです。

// storer behavior image
func (slot *PVCSlot) storer() {
        for {
                itemID := <- slot.Slot
                slot.Lock()
                slot.PVMap[itemID]++
                slot.Unlock()
        }
}

// flusher behavior image
func (slot *PVCSlot) flusher(interval time.Duration) {
        ticker := time.NewTicker(interval)
        for {
              <- ticker.C
               slot.Lock()
               // fetch pv per item and issue SQL to count up
               if err := slot.flush(); err != nil {
                       // error handling
               }
               slot.Unlock()
        }
}

StorerPVMapに商品ID毎の閲覧数を一時的にプールします。こうすることで、商品が閲覧されるたびに都度UPDATE tbl SET pv = pv + 1 ...みたいなクエリを実行するのではなく、UPDATE tbl SET pv = pv + 5 ...といったような形でまとめることでMySQLへの書き込み負荷を和らげることができます。Flusherもまたタイマーを利用してPVMapのデータを一定時間毎に反映することでMySQLへの書き込み負荷を和らげる役割を果たしています。

最後に

メルカリの商品閲覧数カウントアップの裏側とそれを支えるpvpoolについて紹介しました。メルカリではpvpoolをはじめ、プッシュ通知配信サーバやAPIゲートウェイ等といった様々な裏側の仕組みをGoで開発しており、そのためのソフトウェアエンジニアを募集しています。興味がある方は是非ご連絡下さい。