こんにちは。iOS、Androidアプリエンジニアの
id:tokizuoh です。『Inside GigaViewer for Apps』連載13回目は、Webアプリケーションエンジニアの
id:magaming と一緒に出版社向けマンガビューワのアプリ版である「GigaViewer for Apps」(以下 GigaApps)における API での負荷対策についてお話しします。今回は、少年ジャンプ+アプリ版のリプレイスにおける負荷対策の例をご紹介します。
背景
少年ジャンプ+アプリ版は、元々他社様が開発・運用されていたサービスでした。GigaApps へリプレイスを行うにあたり、「リプレイス前と同等のアプリのリクエストをさばけること」は必須要件でした。
GigaApps のバックエンドは、これまでの記事 でもご紹介した通り、ほぼすべての API で GraphQL を採用しています。そのため、負荷対策としては GraphQL のパフォーマンス改善がメインとなりました。
計測
負荷試験の目標設定とシナリオの定義
リプレイス前のパフォーマンスを維持できるかを確認するため、本番環境相当の負荷試験環境を構築し、負荷試験ツール k6 を利用して計測を行いました。
まず、リプレイス前のアプリのピーク時の秒間最大リクエスト数 の情報を頂き、それに基づき以下の目標を設定しました。
各クエリが、リプレイス前の最大リクエスト数と同じ頻度で発行された際に、エラーなく応答できること
例えば、秒間最大リクエスト数が1000回/秒であった場合、以下のような試験のシナリオを設定しました。
トップ情報を取得するGraphQLクエリAを毎秒1000回発行
作品情報を取得するGraphQLクエリBを毎秒1000回発行
これらのシナリオを、全100以上のクエリ全てに対して実施しました。
試験結果と課題
試験の結果、キャッシュ可能 なクエリに関しては概ね目標を達成できることが判明しました。しかし、キャッシュ不可能 なクエリに関しては、目標値を満たせない部分が多くあることが明らかになりました。
GigaViewer のバックエンドでは ElastiCache に GraphQL のレスポンスをキャッシュしていますが、キャッシュ可否はクエリ単位で制御しており、ユーザー固有の情報を取得する場合はキャッシュ不可能とする実装となっています。
# context->visitor を参照するとキャッシュ不可能のフラグがセットされる sub visitor { my ($self) = @_; $self->disable_cache; return $self->{visitor}; }
例えばエピソードビューワ画面のクエリはユーザーの閲覧権限などを取得するためキャッシュ不可能となりますが、当初は要求値をまったく満たせない状態でした。
改善
負荷試験によって特定された、要求を満たせていないクエリ。この課題に対し、データベースとサーバー、双方の視点から対策を講じました。また、モバイルアプリでも対策を講じることで、改善を進めました。
データベース側の対策
Amazon Aurora の Performance Insights の トップ SQL を参照し、負荷が高いクエリを優先的に改善していきました。対策の一例を紹介します。
大量のIN句の分割
例えば、1000作品を取得し、その後各作品の最初のエピソードを取得するようなケースで、以下のようにIN句が大量になるクエリが発行されていました。
SELECT * FROM episode WHERE series_id IN (1, 2, 3, ....,1000)
IN句が多すぎる場合は、クエリを複数に分割するアプローチを取りました。
SELECT * FROM episode WHERE series_id IN (1, 2, 3, ....,100) SELECT * FROM episode WHERE series_id IN (101, 102, 103, ....,200) ...
取得するレコード数を絞る
大量のデータを取得してからアプリケーション側でフィルタリングするような処理を、WINDOW 関数などを使い最初からデータベース側で絞り込むように変更することで、データ転送量と処理時間を削減しました。
ElastiCache へのキャッシュ
上記のアプローチでも性能が改善しきれない重いクエリに対しては、SQLの実行結果を ElastiCache にキャッシュすることで、データベースへのクエリ発行頻度を大幅に減らしました。
サーバー側の対策
サーバー側のボトルネック特定には、Perlのプロファイラである Devel::NYTProf を活用しました。こちらも対策の一例を紹介します。
DataLoaderの最適化
不自然に関数の呼び出し回数が多い部分があったため調査すると、一部フィールドで DataLoader 実装が漏れており、N+1 問題が発生していることがわかりました。これには DataLoader の実装を追加して問題の解決を図りました。
一方で、呼び出し回数が極端に多いフィールドではDataLoader のインスタンス生成自体がオーバーヘッドとなり遅くなるケースも確認されたため、あえて DataLoader を使わない対策も取りました。
例えば、以下のような雑誌のページ画像を取得するクエリでは、ページ画像が 1000 枚以上になるため、DataLoader のインスタンス生成も 1000 回以上行われ遅くなっていました。DataLoader を使わない場合、複数の雑誌のページ画像を取得する場合 N+1 となりますが、そのようなクエリはアプリから発行されないため、DataLoader を使わない選択肢を取ることとしました。
query Magazine { title pageImage { # このフィールドで DataLoader を実装していたが、オーバーヘッドが多いので使わなくした url } }
無料話をキャッシュ可能にする
エピソードビューワのクエリはユーザー固有の情報を返すためキャッシュ不可能でしたが、無料のエピソードに関しては必ず読めるため、ユーザーの状態に関わらず読める、という情報を一律で返すことにしました。これによりユーザ固有の情報を返す必要がなくなり、キャッシュ可能となりました。
これらの対策は一定の効果は得られ、特に無料話をキャッシュ可能にする対応によって無料のエピソードの閲覧に関してはほぼほぼ要求値を満たせる状態となりました。しかし、依然としてユーザー固有の情報を返すクエリに関しては要求値を満たすことが難しい状況でした。そこで、モバイルアプリ側でも負荷対策の対応を行いました。
モバイルアプリ側の対策
今回は GraphQL のクエリ分割と重複クエリの調査という二つのアプローチについて紹介します。
クエリ分割
GraphQL は必要なデータだけを柔軟に取得できる強力な仕組みですが、使い方によってはサーバーに負荷をかけてしまう側面もあります。 当初、一つの画面に必要なデータを一つの大きなクエリでまとめて取得していました。しかし、この方法ではユーザーごとに変わる動的なデータと、変わらない静的なデータが混在してしまいます。その結果、静的なデータまで毎回取得しに行くことになり、サーバー側でのキャッシュが有効に機能せず、不必要なリクエストが増加していました。
そこで、クエリをキャッシュの可否によって分割するアプローチを取りました。
- キャッシュ可能: 作品タイトルなどの、ユーザーごとに変わらない静的なデータ
- キャッシュ不可能: 作品のお気に入り状態などの、ユーザーによって変わる動的なデータ

お気に入りの状態やコミックスの購入状態はユーザーごとに異なります。加えて、最新の状態を即時に反映したいため、キャッシュ不可能なデータです。
今回は作品詳細画面を例にクエリ分割のアプローチを紹介します。簡単のために作品タイトルとお気に入り状態に焦点を当てます。
以下の GraphQL スキーマからクエリを考えてみます。
type Query { series(id: ID!): Series } "作品" type Series { "タイトル" title: String! "お気に入り済みかどうか" mylisted: Boolean! }
title と mylisted を一度にクエリしてしまうと、キャッシュ可能のデータとキャッシュ不可能のデータが混ざるためキャッシュが効きません。
query SeriesDetail($id: ID!) { series(id: $id) { title mylisted } }
そこで、キャッシュを効かせるために以下のようにクエリを分割しました。
query SeriesDetailCachable($id: ID!) { series(id: $id) { title } } query SeriesDetailUncachable($id: ID!) { series(id: $id) { mylisted } }
クエリを分割することでサーバーへのリクエスト数は実質的に2倍になりましたが、リクエスト増というオーバーヘッドと、クエリを分割しない場合のキャッシュ効率を比較した結果、前者を許容することのメリットの方が大きいと判断しました。結果として、このアプローチによって要求値を満たすことができました。
重複クエリの調査
モバイルアプリはAPIクライアントとして、Apollo iOS と Apollo Kotlin を採用しています。モバイルアプリ側のキャッシュについては画面ごとにキャッシュポリシーやフェッチポリシーを設定して具体的な処理はライブラリ側に委ねていますが、アプリの複雑化に伴い、意図しないAPIリクエストが重複して送信され、サーバー負荷を増大させる問題がありました。
そこで、通信デバッグツール Proxyman を用いてアプリの通信内容を詳細に調査しました。その結果、起動時の処理において、同じリクエストが複数回送信されているケースを特定しました。
この問題への対策として、API リクエストの管理方法を見直して不要なリクエストが実行されないように変更しました。この改善により、サーバー負荷の軽減を実現しました。
リリース後の対応
負荷試験と対策の結果、リリースに向けて一定の目処が立ったため、リリース判断を可としました。ここからは、リリース後に見つかったいくつかの課題と、それらに対する具体的な取り組みについてご紹介します。
雑誌発売日のスパイク対応
いざリリースしたところ、負荷試験の甲斐あり数日間は大きな問題なく運用を行うことができました。しかし、少年ジャンプ+における雑誌発売日の日付が変わった直後、リクエストが大幅に増加し、数分間一部リクエストにエラーを返す状況が発生しました。負荷試験の目標はエピソード公開時のリクエスト想定で、雑誌発売日のスパイクが想定できていなかったためでした。
Performance Insights を確認すると、データベースの負荷が支配的であったため、以下のような対策を講じました。
Reader インスタンスへの負荷分散
SELECT クエリを Reader インスタンスに逃がし、Writer インスタンスへの負荷を軽減しました。
追加で ElastiCache にキャッシュ
クエリの結果を ElastiCache にキャッシュする数を増やして、重いクエリの発行数をさらに減らしました。
データベースの最大コネクション数増加
スパイク時に最大コネクション数が Aurora の max_connections の既定値に達していたデータベースがあったため、同時接続数を増やし、接続待ちによる遅延を解消しました。
これらの対策により、データベースの負荷は40%程度まで下がったものの、引き続きリクエストを取りこぼす事象は改善されませんでした。
ALBの暖機申請
引き続き調査を進めると、ある一定以上のリクエスト数になると必ずエラーを返し始める、ALB (Application Load Balancer) のスケールアウトが日付変更から数分後に行われているという傾向を見つけました。そこから、ボトルネックは ALB にあると推定し、暖機申請(Pre-warming) を行うことにしました。
ALBの暖機申請とは、大量のアクセスが集中する前に、事前にALBをスケールアウトさせておくことで、急激なトラフィック増加に対応できるようにする仕組みです。この暖機申請を行った結果、雑誌発売日のスパイク時にもリクエストを取りこぼすことがなくなり、やはりボトルネックが ALB であることが特定できました。その後雑誌発売日には暖機を行う仕組みを導入し、安定的にサービスを運用できるようになりました。
余談ですが、この当時は手動での暖機申請が必要でしたが、現在はAWSの LCU(Load Balancer Capacity Unit)予約機能によって機械的に暖機を設定できるようになっています。
Scissorsのキャッシュ対応
GigaApps では画像変換プロキシ「Scissors」を活用しています。Scissors は以下で言及されている社内サービスです。
Scissors ではクエリパラメータで1px単位のリサイズや画質調整が可能ですが、その細かな指定によりキャッシュが細分化してヒット率が低下し、サーバー負荷が増大していました。対応として、リサイズ指定を一定間隔に丸めるルールを導入したところ、見た目をほぼ維持しつつキャッシュ効率が向上し、リクエストのバリエーションが約20%減少しました。
今後の展望
今回の負荷対策の甲斐もあり、大きな問題なく少年ジャンプ+アプリ版のリプレイスを成功させることができました。しかし、まだいくつか課題はある状況です。今後の展望として、以下の取り組みを進めていきたいと考えています。
Persisted Queryの導入
現在、GraphQLのレスポンスキャッシュは ElastiCache から返していますが、キャッシュロジックを時前で実装しているため、サーバー側の実装が複雑化する要因になっています。Persisted Query を導入してGraphQL のレスポンスを HTTP キャッシュに乗せられるようにすることで、キャッシュの責務を CDN に移譲し、アプリケーション実装のシンプル化とキャッシュ効率の向上を目指したいと考えています。
キャッシュ用の CustomDirective の導入
現在のユーザー固有の情報を返す場合キャッシュ不可能という仕様は直感的ではなく、意図せずキャッシュ可能だったクエリがキャッシュ不可となり、予期せぬ負荷増加を招く可能性もあります。現在はモバイルアプリ側のクエリ変更をサーバー側のエンジニアがレビューすることで防いでいますが、今後はキャッシュ不可なフィールドには CustomDirective を付与するなどして、GraphQLスキーマ上でキャッシュの挙動を明確に表現できるようにしたいと考えています。
Mackerel APMの活用
負荷試験時にはパフォーマンスを詳細に見ていましたが、これまでアプリケーションのパフォーマンスを継続的に監視する機会は限られていました。Mackerel APM を活用して継続的にアプリケーションのパフォーマンスを監視し、さらなる最適化に繋げていきたいと考えています。
終わりに
この記事では GigaApps の API 負荷対策について紹介しました。計測から始め、データベース側とサーバー側、モバイルアプリ側のアプローチについて触れました。
連載企画『Inside GigaViewer for Apps』では、これまで様々な技術的チャレンジを紹介してきました。残りの連載でも、開発の裏側にある、さらなる挑戦をお届けする予定です。
id:tokizuoh
2022年入社。マンガアプリチーム所属のエンジニア。
blog: カルボナーラ街道
id:magaming
2020年2月入社。マンガメディア開発チーム所属Webアプリケーションエンジニア。