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種類あり、それぞれに「全て」を含めると、組み合わせは次のようになる。
これは単純化した例。 実際には、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。