弊社サービス「aumo.jp」を支えるRuby on Railsのサーバーレスポンス速度向上によってLCPの「不良(4秒越)」を大幅に減少させることを目指します。
これからコアウェブバイタル対策に取り組まれる方や興味がある方のお役に立てる記事になれば幸いです。
目次
目次
はじめに
初めまして。2021年4月にグリー株式会社にエンジニアとして新卒入社し、aumoに配属されたネゴロと申します。
大学時代は個人事業主としてウェブ開発をはじめとして様々なサービスの開発に携わっていました。aumoではサーバーサイド、フロントエンド、インフラ幅広く経験させていただいております。
今回の記事の主題であるコアウェブバイタルですが、なぜ弊社がコアウェブバイタル対策に取り組んでいるのかと言いますと、2021年6月からコアウェブバイタルのスコアがモバイルの検索順位に影響を与えていくと言われているからです。
我々記事メディアにとって検索順位は非常に重要で、検索上位に表示されるためには様々な要素で競合よりも優位に立たなくてはなりません。コアウェブバイタルもその要素の一つで、競合よりも優れたスコアを出すことが重要になってきます。
そこで本記事では、コアウェブバイタルのスコアが検索順位に影響を与え始める前に、どのようにスコア改善に取り組んだかをお話ししていきたいと思います。
コアウェブバイタルとは
コアウェブバイタル(Core Web Vitals)とは、2020年にGoogleが発表したUXの重要指標であり、2021年6月から徐々に検索順位(モバイルのみ)に影響を与えていくと言われています。
コアウェブバイタルにはLCP,FID,CLSという3つの指標があります。
1. LCP(Largest Contentful Paint ):読み込みパフォーマンス
LCPは「読み込みが進んでいるか?」を表す指標です。
具体的には、ページのメインと判断されたコンテンツ要素が読み込まれるまでの時間を測定しています。
2. FID(First Input Delay ):インタラクティブ性
FIDは「反応が早いか?」を表す指標です。
具体的には、ユーザーが最初に操作(クリックなど)した時から、ブラウザがその操作に応答するまでの時間を測定しています。
3. CLS(Cumulative Layout Shift ):ページコンテンツの視覚的な安定性
CLSは「見た目に快適か?」を表す指標です。
具体的には、一部の要素が遅延して読み込まれることなどにより、他の要素が当初の位置からどのくらいずれたか(レイアウトシフト)を測定しています。
これらの指標に関して、自分のサイトがどういう状況になっているか知りたい場合は 「Google Search Console > ウェブに関する主な指標 > モバイル」 から確認することができます。
コアウェブバイタルの指標を改善するには、ここで表示されている「不良」「改善が必要」と診断されたページ(URL)を「良好」にしていく必要があります。
今回取り組んだのは「LCP」改善
今回私はLCPの改善に取り組みました。理由は2つです。
- 取り組み始めた当初、コアウェブバイタルの3つの指標の中でLCPが最も「不良」と診断されたURLが多かった
- LCPが不良と診断されているURLは、アクセス頻度が高いにもかかわらず、体感速度的にも非常に遅く感じるため、改善した際のインパクトが大きそう
上の画像からも、10秒近くも読み込みに時間がかかっているページがあることがわかります。これではページ読み込み中にユーザーがサイトを離脱してしまう可能性が高くなります。
そこでコアウェブバイタルの改善施策として、まずは読み込み速度を改善し、LCPの「不良」を大幅に減らすことを目標としていきます。
aumo.jpのLCPの課題と原因分析
LCPが長い原因
LCPが「不良」と診断されているページは以下の3つです。
- /regions/:id
- /prefectures/:id
- /areas/:id
これらのページでは、それぞれ地域(e.g. 関東), 都道府県(e.g. 東京), エリア(e.g. 浅草)別に飲食店や観光地など様々なスポットを一覧で表示しています。
これらのページでLCPが長くなってしまっている原因を探していきたいと思います。
まず、一般的にLCPが長くなる原因として以下が挙げられます。
- サーバーの応答に時間がかかっている
- JavascriptとCSSによりレンダリングが妨げられている
- リソースの読み込みに時間がかかっている
- クライアント側でレンダリングが行われている
上記のURLがこれらの原因のどれに該当するのかを調査するため、Google Chromeデベロッパーツールを活用しました。具体的には「Network」を見て「Time」順にソートし、どの処理に時間がかかっているのかを確認しました。
<Google Chromeデベロッパーツール>
画像から「28?page=2」というサーバーへのリクエストが7.44sと最も時間がかかっていることがわかります。このことから、サーバーの応答速度がLCPが長くなっている主要原因ではないかという仮説を立てました。
そこで次は、上記のURLにおけるサーバーのレスポンス時間を調べます。
aumo.jpではBigQueryのテーブル上でAWSのロードバランサーのリクエストURL別レスポンス時間を記録しています。以下の表は上記のURLで2021年5月1日から5月3日の期間で集計した結果になります。
averageがレスポンス時間(秒)の平均値となっています。
集計結果より
パス | レスポンス時間 |
/areas/:id | 約 4.3 秒 |
/prefectures/:id | 約 7.6 秒 |
/regions/:id | 約 9.7 秒 |
となっており、サーバーのレスポンス速度が遅いことがわかります。このことからLCPが長くなっている主要原因は、サーバーのレスポンス速度が遅いためという仮説は正しそうです。
サーバーレスポンス速度が遅い原因
それでは次に、サーバーのレスポンス速度が遅い原因の詳細を深ぼっていきます。
調査の方法はシンプルで、binding.pryなどを駆使して、Rails標準出力とにらめっこし、速度を遅くしているボトルネックを探します。
Railsの標準出力は便利で、リクエストの過程で発行したクエリ内容や実行時間も出力してくれます。また部分レンダリングを行っている場合は、パーシャルごとにレンダリングにかかった時間もわかります。
このような標準出力調査とコードリーディングによって、以下の4つがサーバーレスポンス速度を遅くしているボトルネックになっていることがわかりました。
- スポット(Spot)一覧を取得するクエリ時間
- N+1問題
- 重い集計処理をキャッシュするロジックがリクエスト毎に走っている
- DynamoDBへのリクエストのオーバーヘッド
1.に関して、/areas/:id, /prefectures/:id, /regions/:id のページで表示するスポットの一覧を取得しているクエリに非常に時間がかかっていました。
2.に関して、多くの箇所でN+1問題が発生していました。N+1問題とは、ループ処理の中で逐一クエリが実行されることにより、大量のクエリが発行されてしまう問題です。これはDBのパフォーマンス低下を招くため、アクセスが多いページほど早急な対応が必要になります。
3.に関して、記事数やスポット数など対象件数が多い集計処理に時間がかかるロジックがユーザーがページをロードする度に行われていました。既に、キャッシュするようにはなっていましたが、有効期間が短めに設定されているためほとんどのページではキャッシュが効いていない状態でした。そのため、キャッシュが効いていないページにユーザーに訪問した際や、GoogleのクローラーがLCPを測定しに訪問した際には、再度集計処理が走り時間がかかっていました。
4.に関して、aumoでは一部DynamoDBを利用していますが、DynamoDBへのリクエストのオーバーヘッドによって、時間がかかっていました。
これら4つの課題を解決することによって、サーバーレスポンス速度を向上しLCPの大幅改善を目指します。
解決のための施策
前項で以下の4つの課題を解決する必要があることを示しました。
- スポット(Spot)Spot一覧を取得するクエリ時間
- N+1問題
- 重い集計処理をキャッシュするロジックがリクエスト毎に走っている
- DynamoDBへのリクエストのオーバーヘッド
ここからは、これらの課題の解決のためにどのような施策を行ったか示します。
1. スポット(Spot)一覧を取得するクエリ時間
Spot一覧を取得するクエリをEXPLAINコマンドで調べ、インデックスが適切に効いているかを見ていきました。クエリとEXPLAINの結果は以下のとおりです。
<改善前のクエリとEXPAINの実行計画>
SELECT `spots`.* FROM spots WHERE `spots`.`deleted_at` IS NULL AND (`spots`.`status` = 1 OR `spots`.`status` = 4) AND `spots`.`prefecture_id` = 28 ORDER BY `spots`.`rate_star` DESC LIMIT 30 OFFSET 30;
=> 30 rows in set (1.85 sec)
key | key_len | ref | rows |
index_spots_on_prefecture_id, index_spots_on_deleted_at | 5,6 | NULL | 118540 |
まずクエリに関して、実行時間を遅くしているのはspotsテーブルのrate_starカラムをORDER BYで並び替えている箇所だと推察できます。
次にEXPLAINの結果から、本来インデックスが効いて欲しいはずのrate_starではなくprefecture_idにインデックスが効いていることがわかります。また削除管理のためのdeleted_atカラムのインデックスが意図せず効いてしまっていることもわかります。
そこで、deleted_atカラムのインデックスを明示的に外す IGNORE INDEX を付与しました。結果rate_starのインデックスが効くようになり、速度も 1.85 sec から 0.02 sec まで短縮しました。
<改善後のクエリとEXPAINの実行計画>
SELECT `spots`.* FROM spots IGNORE INDEX(index_spots_on_deleted_at) WHERE `spots`.`deleted_at` IS NULL AND (`spots`.`status` = 1 OR `spots`.`status` = 4) AND `spots`.`prefecture_id` = 28 ORDER BY `spots`.`rate_star` DESC LIMIT 30 OFFSET 30;
=> 30 rows in set (0.02 sec)
key | key_len | ref | rows |
index_spots_on_rate_star | 5 | NULL | 664 |
2. N+1問題
Railsの標準出力で吐き出されるクエリを参考に、N+1が発生している箇所を洗い出しました。
N+1を解消する基本的な方法としては、ActiveRecoreのincludesメソッドで関連するテーブルをまとめて取得します。そのためには関連するテーブルを洗い出す必要があるのですが、ここが一番大変で泥臭い作業でした。
Google Documentでメモを取りながら関連するテーブルを漏れなく洗い出し、適切にincludesすることによって、全てのN+1を解消できました。
(本来ならテーブル設計から見直す必要がありそうですが、変更範囲が大きいかつ時間的制約からincludesによる改善方法を採用しました。)
<Google Documentで関連テーブルを洗い出した際のメモ>
with_meta_search
affiliate_genre
SpotAffiliate(has_many)
meta_search_groumet
Gourmet
meta_search_hotel
Hotel
HotelFacility(has_one)
meta_search_leisure
Leisure
ArticleBuilder
articles(has_many)
with_main_photos
PhotoRelation
Photo(has_one)
Photo
location_sp_spot_category_tags_elements
Hotel(has_one)
Leisure(has_one)
Gourmet(has_one)
travel_categories
Hotel(has_one)
Leisure(has_one)
LeisureSubCategory
Gourmet(has_one)
SmallCategoryGourmet(has_many)
location_get_spot_to_station_distance_str
Geometry(has_many)
SpotGeometryRelation(has_many)
Photo
User(belongs_to)
active_affiliate
SpotAffiliate(has_many)
get_first_affiliate_appeal
Affiliate
<新たに追加したincludesメソッドの引数>
.includes(
:active_affiliate,
:affiliate,
:geometries,
:spot_geometry_relations,
:articles,
:shops,
snaps: [:child_photos, :user],
current_published_articles: :main_photo_relations,
hotel: :facility,
leisure: {sub_categories: :leisure_main_category},
gourmet: {small_category_gourmets: [:gourmet_small_category, :gourmet_medium_category, :gourmet_large_category]}
)
3. 重い集計処理をキャッシュするロジックがリクエスト毎に走っている
そもそも重い集計処理をControllerで実行するのではなく、バッチ処理で定期実行するようにコードを書き換えました。
バッチ処理で集計処理を実行し、結果をRedis::Objectsのcounterで定義したkeyに格納します。
Controller側ではRedis::Objectsのkeyを指定して値を取得することで、リクエスト毎に走っていた重い集計処理の時間を大幅に削減しました。
4. DynamoDBへのリクエストのオーバーヘッド
不必要にDynamoDBにリクエストしている箇所を洗い出して修正していきました。具体的には、DynamoDBと同じ結果を得られるModelのカラムがある場合は、そちらを利用するようにコードを修正しました。
結果
解決のための施策を実行した結果、2つの有益な結果を得ることができました。
- サーバーレスポンス速度向上によるLCP「不良」の大幅減少
- クエリ改善によるDB固定費の削減
1. サーバーレスポンス速度向上によるLCP「不良」の大幅減少
まずサーバーレスポンス速度に関して、以下のような改善が見られました。
パス | レスポンス時間(改善前) | レスポンス時間(改善後) | 減少率 |
/areas/:id | 約 4.3 秒 | 約 1.2 秒 | 72 % |
/prefectures/:id | 約 7.6 秒 | 約 2.0 秒 | 74 % |
/regions/:id | 約 9.7 秒 | 約 2.4 秒 | 75 % |
これに伴い、LCPの「不良」も大幅に減少させることができました。
2. クエリ改善によるDB固定費の削減
今回実行した施策のうち、クエリ改善(N+1解消等)の効果によってDB負荷が減少し、AWSのRead DBを1台減らすことができました。結果、10万円弱/月のDB固定費が削減されました。
最後に
今回の取り組みから、コアウェブバイタル対策だからといって特別なことをするのではなく、様々なツールを活用して問題を分析し、愚直にボトルネックを解消していくことの大切さを学びました。
今後もコアウェブバイタルのさらなる改善に取り組むとともに、引き続きエンジニアリングから事業の成果を出せるように頑張ります。