みんなで100億歩を目指すためのパフォーマンスとの戦い
FiNC Technologies サーバーエンジニアの片桐です。
この度、日本テレビ系と2019年11月初旬に開催した健康キャンペーン「カラダWEEK」とコラボレーションし、FiNCアプリから参加できる「億WALK」を開催しました。「億WALK」は日本中のイベント参加者と日テレ系のアナウンサーが協力して、累計の歩数100億歩を目指す企画を実施いたしました。
本企画において、FiNCアプリ内に用意した「カラダWEEKのコミュニティ」というものがあり、そのコミュニティに参加することで、ユーザーは億WALKに参加できる仕組みになっています。
弊社のランキング機能の1つとして、コミュニティ参加者の総歩数で競い合う、「コミュニティ対抗ランキング」というものがあったため、「参加者のリアルタイムの総歩数」はこの機能を基に実装することにしました。
歩数ランキング周りのサーバー構成
弊社のサービスはマイクロサービスで運用されており、主にRuby on Railsで構築された40〜50ほどのサービスで構成されています。
今回のランキングにも以下のような複数のサービスが関わっています。
- コミュニティを管理するサービス
- ユーザーのライフログデータを管理するサービス
- 歩数ランキングを管理するサービス
これらのサービスが、お互いにinternal API通信や、eventのやりとりを通じて連携して機能を成立させています。
(eventのやり取りは、SNS/SQSを使用した pub/sub になります)
今回のランキング機能で重要になる連携はこちらの2つです
コミュニティへの参加情報の連携
歩数更新情報の連携
リアルタイム更新への対応
もともと「コミュニティ対抗ランキング」はリアルタイム性が要求されるものではなかったため、
歩数の集計はhourlyのバッチで行っていました。
歩数の情報は、lifelogからevent経由で随時連携されていますが、このeventの発行数は非常に多いため、
単純な歩数データのupsertくらいであればともかく、コミュニティ単位での集計のような処理を行うのはパフォーマンス的に問題が出てきます。
ですので、新たにredisを使用して歩数を集計するロジックを追加し、リアルタイム更新を実現する方針となりました。
実装
redisを使用して歩数を集計するロジックは、大まかには以下のような流れとしました。
- 有効なコミュニティ対抗ランキングを取得する
- それらのランキングの対象となっているコミュニティにユーザーが参加しているかチェックする
- 参加しているコミュニティがあれば、そのコミュニティの総歩数データを更新する
機能を完成させ、staging環境でのチェックも無事に終わったので、対応を本番にリリースして、ユーザーには見えない形でランキングを開催させて、動作確認を始めました。
この時点では、歩数データがリアルタイムで更新されており、安定して動作していました。
最初の問題
本番へのリリースを行った日の17時ごろだったかと思います。
SREチームの方から、ランキングサーバーのパフォーマンスが悪くなっているとアラートが入りました。
確認してみるとDBのCPU Utilizationが急激に上昇し、非常に高い状態になっていました。
原因として考えられるのは私の実装した総歩数の機能しかなかったので、一旦リリースした機能の取り下げを行いました。
取り下げによって、無事サーバーのパフォーマンスは改善しましたが、
集計機能でここまでの高負荷になることは想定外出会ったため、急遽原因の調査を始めました。
DBの負荷が急上昇していたことから、原因はSQLにあるとあたりをつけて調査を始めました。
実際ここで問題になっていたのは、「コミュニティにユーザーが参加しているかをチェックする」SQLでした。
そこでまず第一にこのSQLの最適化に着手していたのですが、
SREチームの方から、
「slaveではなくmasterだけ負荷が上がっていた。何かDBに書き込む処理があるのか?」という指摘を受けました。
私の実装は集計してredisに書き込んでいるだけので、DBに対する更新はありません。
そのため、「SELECTのSQLがmasterに行っているのではないか?」という観点での調査に切り替えました。
弊社では、DBはread-replica構成になっており、masterとslaveの切り替えに makara
というgemを用いています。
この makara
というgemには sticky_session
という機能があり、1度接続先がmasterに切り替わると、
その後同じセッション内のSQLは全てmasterに向くようになっていました。
今回実装した総歩数の集計処理は、データの更新処理の後に行う構成になっていたので、
全てのSQLがmasterにいってしまっていました。
修正
SQLについては EXPLAIN
を確認しながらチューニングを行い、
並行して強制的にslaveへ繋ぐようにするやり方を模索しました。
最終的に`without_sticking`というメソッドを用いることでslaveへの接続を強制できることがわかりました。
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true def with_slave_connection(&block)
# 弊社ではtest環境でmakaraを使用していないので分岐を入れています。
if connection.respond_to?(:without_sticking)
connection.without_sticking(&block)
else
yield
end
end
end
加えて、上司からの「一気に適用するのではなく部分適用できるようにしたほうが良い」とのアドバイスを受け、
環境変数で適用割合を変更できるようにして、10%適用から始めるようにしました。
10%適用から開始し、DBのパフォーマンスを見ながら徐々に適用率を上げていきました。
20%、30%、50%…
ここまで大きな問題も出さず、適用率を上げることができました。
50%適用した段階で時刻は夕方となっていたので、この日の適用率はここまでにして続きは翌日に行うことにしました。
問題、再発生
しかし、帰宅後21:00を過ぎたころ、またしてもDB負荷のアラートが発生してしまいました。(平日のこの時間は歩数データのピークタイムに当たります)
今回は環境変数で適用率を変更できるようにしていたので、すぐさま適用率を0に変更しました。
幸い50%適用ということもあり致命的なほどにはならなかったものの、
今回の対応では問題を解消することができなかったことが判明しました。
ここまで来た段階で、簡単なSQLチューニングなどの対策で対処するのは難しいという判断になり、
SREチーム・サーバーチームのメンバーを交えて対応方針を考える会を開催しました。
ここで、各メンバーから意見や指摘をもらい、最終的に以下のような対応をすることになりました。
- ユーザーのコミュニティへの参加状態について、1テーブルだけで参照できるようなキャッシュテーブルを導入する
- さらに、コミュニティの参加状態をredisにもキャッシュし、DBへのアクセス数を削減できるようにする
これらの対応をコードに起こした後、再び10%適用から反映を進めていきました。
10%, 20%, … と50%進めていきましたが、特に負荷に変化はありません。
(前回の時は多少ですが負荷が上がっていました。)
ここまでの推移を見て、大きな問題はないと判断して私たちは、
そこから一気に 80%, 100% と完全適用まで移行させました。
その日の夜は、落ち着かない心境でサーバーの負荷を監視していましたが、
今回は急激な負荷の上昇は起こらず、無事にピークタイムを乗り越えることができました。
企画開始へ
無事にパフォーマンス問題が解決したことを確認した後、
重要な機能について再度QAを行い、無事企画の開始につなげることができました。
実は企画開始後も、ここまで大規模ではないもののパフォーマンスの問題は発生し、逐一対応を行なっていました。
しかしながら、企画の失敗にも繋がりかねない大きな問題をチームの協力のもと、無事に解決することができたのは非常に幸いなことでした
まとめ
私は前職からweb系のサーバーエンジニアとして働いてきていましたが、
今までこのようなパフォーマンス問題が発生するほどの負荷とは無縁な環境で過ごしていました。
知識としては、MySQLのEXPLAINやN+1のなど問題やその対処について把握していましたし、そのようなコードをリファクタしたこともありました。
しかし、実際にパフォーマンスの問題に迫られて対応するという切羽詰まった状況は初めての経験でした。
そのような中で、モニタリングツールを使ってパフォーマンスを追い、
スケジュール・工数を鑑みて取りうる手段を検討するというのは今までにない経験で非常に良い勉強になりました。