コンテンツにスキップ

100万種類以上のランキング集計を支える Sidekiq チューニング

チェック

  • [ ] 本文を確認した
  • [ ] 概要を確認した
  • [ ] タグを確認した
  • [ ] inbox/ 直下へ移行した

概要

Pococha の「ぽこランキング」で、100万種類以上のランキングを30分ごとに集計する Sidekiq 処理をチューニングした記事。 当初は Sidekiq の concurrency を 50 にしていたが、CRuby の GVL により 1 worker process では 1 core 分しか Ruby code を実行できず、CPU 使用率が 50% 付近で頭打ちになり、Active Record 処理のレイテンシも悪化した。 最終的に concurrency を 10 に下げ、8 vCPU の専用 EC2 上で worker process を 8 個に分割することで、CPU をほぼ使い切り、レイテンシ悪化も解消した。

本文

記事の対象は、Pococha に追加された「ぽこランキング」機能の β 版。 ユーザーの応援行動を可視化する機能で、100万種類以上のランキングを 30 分おきに集計する。

要件は大きく2つ。

  • 100万種類以上のランキングデータを加算・保存する。
  • 30分おきの集計をできるだけ速く終わらせる。

検証段階では、Sidekiq のスレッド数を増やせば速くなると考え、concurrency を 50 に設定していた。 しかし、concurrency を 10 に下げた方が性能が改善した。 理由は CRuby の GVL と、CPU バウンド処理におけるスレッドの奪い合いだった。

なぜランキングが100万種類を超えるのか

ぽこランキングでは、配信中のアイテム利用、視聴時間、コアファン獲得数などの応援行動を、ユーザー個別の属性の組み合わせごとに集計する。

たとえば「ライバー」「ハートアイテムの使用個数」というランキングを考える。 ライバーに地方とランクという属性があるとする。 地方が7種類、ランクが18種類あり、それぞれに「全て」を含めると、組み合わせは次のようになる。

(7 + 1) * (18 + 1) = 152

これは単純化した例。 実際には、1時間ランキング、1日ランキング、アイテム種別、視聴時間、コアファン数など、分割軸と対象行動が増える。 属性の組み合わせが増えるほどランキング種類は爆発的に増え、100万種類以上になる。

これらを 30 分に 1 回集計し、画面に表示するランキングを抽出する。

発生した問題

問題は2つあった。

1つ目は、CPU を効率よく使えていないこと。 Sidekiq のスレッド数上限まで job を並行処理しても、CPU 使用率が 50% 付近で頭打ちになった。 集計期間中に CPU が台形状に上がるが、2 core のうち 1 core 分程度しか使えていない。

2つ目は、集計処理とは関係ない別 model の Active Record method のレイテンシが悪化したこと。 これは DB I/O の遅延ではなく、オブジェクト生成など Ruby 処理のレイテンシ。 ランキング集計期間と同じタイミングで大きく悪化していた。

結論として、どちらも CRuby の GVL が原因だった。

GVL の制約

CRuby には GVL、Global VM Lock がある。 同時に Ruby code を実行できる Ruby thread は、1 process あたり基本的に1つ。 I/O 待ちの間は GVL が解放されるため別 thread が進めるが、CPU バウンドな Ruby 処理では thread 数を増やしても同時に CPU を使えるわけではない。

当初構成は次のようなもの。

項目
vCPU 2
worker process 1
Sidekiq concurrency 50

この構成では、1 worker process の中に 50 thread がある。 しかし GVL により、CPU を使って Ruby code を進められるのはそのうち1 thread。 2 vCPU あるのに、実質 1 core 分しか Ruby 処理に使えない。 さらに 50 thread が CPU time を奪い合うため、各 job の CPU 獲得待ちが増え、Active Record method のレイテンシも悪化する。

worker を増やし concurrency を下げる

解決方針は2つ。

1つ目は、同一 queue を処理する worker process を増やすこと。 GVL は process ごとに効くため、worker process を複数立てれば、それぞれが別 core で Ruby code を実行できる。 マルチコア環境では、process 分割により余っている CPU core を使える。

2つ目は、concurrency を下げること。 1 process 内の thread 数を減らすことで、thread 間の CPU 獲得待ちを減らし、1 job あたりの実行時間や Active Record のレイテンシを改善する。

単純化すると、2 core のサーバーに worker 1、concurrency 4 では、1 process 内の thread が GVL を奪い合い、1 core 分しか使えない。 worker 2、concurrency 2 にすると、process が2つになるため、それぞれが別 core を使いやすくなり、各 process 内の thread 競合も減る。

IO バウンドか CPU バウンドかで concurrency は変わる

Sidekiq の concurrency は、多ければ多いほど良いわけではない。 job が IO バウンドか CPU バウンドかで調整方針が変わる。

IO バウンド処理が多い場合、I/O 待ち中に GVL が解放されるため、concurrency を上げることで他 thread が進み、スループットが上がりやすい。

CPU バウンド処理が多い場合、複数 thread が GVL と CPU time を奪い合う。 この場合、concurrency を下げると1 job が CPU を獲得できる頻度が上がり、1 job あたりの処理時間が短くなることがある。

ただし、同じ worker 数のまま concurrency だけ下げると、同時処理数が減る。 全体の処理時間を短縮するには、worker process を増やし、利用できる CPU core を増やす必要がある。

実際のチューニング

調整は、concurrency を少しずつ下げながら Active Record のレイテンシを見る形で進められた。 最初は Sidekiq 公式ドキュメントで推奨上限とされる 50。 そこから 40、30 と下げると、レイテンシ悪化は徐々に解消した。

最終的に安定した concurrency は 10。

その後、インフラチームが専用の 8 vCPU EC2 instance を用意し、同じ queue に対して worker process を 8 個実行する構成にした。 実行方法は bundle exec sidekiq -C sidekiq.yml を独立した daemon process として必要数起動する形。 各 process は同じ queue から job を dequeue する。

「同じ queue を複数 worker が見ると job が重複しないか」という懸念があるが、Sidekiq は Redis の BRPOP を使って job を atomic に取り出すため、重複 dequeue は起きない。 Sidekiq Pro の super_fetch では LMOVE が使われるが、atomic に取り出す点は同じ。

改善前後は次のようになる。

項目 改善前 改善後
vCPU 数 2 8
worker 数 1 8
concurrency 50 10
最大 CPU 使用率 50% 100%

改善後は CPU を 100% 近く使えるようになり、Active Record のレイテンシ悪化もなくなった。

実務での調査観点

記事のまとめでは、Sidekiq 利用時に見るべき観点が整理されている。

  • job の中に IO バウンド処理と CPU バウンド処理がどの程度あるかをコードから推測する。
  • top などで定常的に実行されている thread 数を見る。
  • 監視ツールで Sidekiq busy thread が増えたときのレイテンシを見る。
  • concurrency を実際に調整し、最適値を探る。
  • マルチコア環境では worker process を分割し、CPU core を使えるようにする。

GVL 計測ツールを使うことも有効とされている。

読み替え

この記事の重要点は、Sidekiq の concurrency は「並列度」ではなく「1 process 内の thread 数」であること。 CRuby では process 数を増やさない限り、CPU バウンドな Ruby code は複数 core に自然には広がらない。

concurrency を増やすと、I/O 待ちの多い job では有利になることがある。 しかし CPU バウンドな job では、CPU 獲得待ちと GVL 競合により逆効果になる。 大きな batch aggregation や Active Record object generation が重い処理では、worker process 分割と concurrency 調整をセットで考える。

要点

  • 100万種類以上のランキングを30分ごとに集計する処理で Sidekiq tuning が必要になった。
  • concurrency 50、worker 1、2 vCPU では GVL により 1 core 分しか Ruby code を実行できなかった。
  • thread 数を増やしても CPU バウンド処理はスケールしにくい。
  • concurrency を下げると、1 process 内の CPU 獲得待ちが減り、1 job のレイテンシが改善することがある。
  • worker process を増やすと、process ごとの GVL により複数 core を活用できる。
  • Redis の BRPOP / LMOVE により、複数 worker が同じ queue を見ても job は atomic に取り出される。
  • 最終構成は 8 vCPU、worker 8、concurrency 10。

タグ

ruby #sidekiq #performance #gvl #background-jobs #rails