キャッシュクエリガイドライン
RailsにはSQLクエリキャッシュが用意されており、リクエストの間、データベースのクエリの結果をキャッシュするのに使われます。Railsは同じリクエスト内で同じクエリに再び遭遇したとき、データベースに対してクエリを再度実行する代わりに、キャッシュされた結果セットを使用します。
クエリの結果はそのリクエストの間だけキャッシュされ、複数のリクエストにまたがって持続することはありません。
キャッシュされたクエリが良くないとされる理由
キャッシュされたクエリはデータベースの負荷を軽減するのに役立ちますが、それでもまだ残っています:
- メモリを消費します。
- Rails が
ActiveRecord
オブジェクトをインスタンス化し直す必要があります。 - オブジェクトの各関係を再定義するようRailsに要求します。
- キャッシュされたクエリのリストを調べるために、さらにCPUサイクルを費やすことになります。
キャッシュクエリはデータベースの観点からは安価ですが、メモリの観点からは高価になる可能性があります。そのため、通常のN+1クエリと同じように扱う必要があります。
キャッシュクエリによってN+1クエリが隠蔽される場合、同じクエリがN回実行されます。データベースにN回アクセスするのではなく、キャッシュされた結果をN回返します。毎回オブジェクトを再初期化する必要があるため、CPUとメモリリソースのコストが高くなります。それよりも、可能な限り同じメモリ内オブジェクトを使用すべきです。
新しい機能を導入するときは、次のようにします:
- N+1クエリは避けましょう。
- クエリ数を最小にします。
- キャッシュされたクエリがN+1の問題を隠していないか、特に注意してください。
キャッシュされたクエリを検出する方法
Kibanaを使用した潜在的な犯罪者の検出
GitLab.com では、pubsub-redis-inf-gprd*
インデックスにdb_cached_count
として、実行されたキャッシュクエリの数がエントリとして記録されます。実行されたキャッシュクエリの数が多いエンドポイントでフィルタリングできます。例えば、db_cached_count
が100を超えるエンドポイントは、キャッシュ・クエリによって隠されているN+1問題を示している可能性があります。このエンドポイントが本当に重複したキャッシュ・クエリを実行しているかどうか、さらに調査する必要があります。
キャッシュクエリに関連する Kibana の可視化については、イシュー#259007 「潜在的な N+1 CACHED SQL 呼び出しを検出するのに役立つメトリクスの提供」を参照してください。
パフォーマンスバーを使用した疑わしいエンドポイントの検査
機能を構築する際、パフォーマンス・バーを使用して、キャッシュ・クエリを含むデータベース・クエリのリストを表示します。実行されたクエリとキャッシュされたクエリの合計数が 100 を超えると、パフォーマンス・バーに警告が表示されます。
利用可能な統計情報の詳細については、パフォーマンス・バーを参照してください。
見るべきもの
Kibana を使用すると、実行されたキャッシュクエリの数が多いかどうかを調べることができます。db_cached_count
が大きいエンドポイントは、キャッシュされたクエリの重複数が多いことを示唆している可能性があり、多くの場合、N+1 問題が隠蔽されていることを示します。
特定のエンドポイントを調査する場合、パフォーマンスバーを使用して類似したクエリやキャッシュされたクエリを特定し、N+1 クエリの問題(または同様の種類のクエリバッチングの問題)を示す可能性もあります。
例
例えば、”Group Members” ページをデバッグしてみましょう。パフォーマンスバーの左隅のデータベースクエリには、データベースクエリの総数と実行されたキャッシュクエリの数が表示されます:
ページには55のキャッシュクエリが含まれています。数を選択すると、クエリの詳細を表示するモーダルウィンドウが表示されます。キャッシュされたクエリには、クエリの下にcached
のラベルが付きます。このモーダルウィンドウでは、複数の重複キャッシュクエリを確認できます:
実際のスタック・トレースを表示するには、...を選択します:
[
"app/models/group.rb:305:in `has_owner?'",
"ee/app/views/shared/members/ee/_license_badge.html.haml:1",
"app/helpers/application_helper.rb:19:in `render_if_exists'",
"app/views/shared/members/_member.html.haml:31",
"app/views/groups/group_members/index.html.haml:75",
"app/controllers/application_controller.rb:134:in `render'",
"ee/lib/gitlab/ip_address_state.rb:10:in `with'",
"ee/app/controllers/ee/application_controller.rb:44:in `set_current_ip_address'",
"app/controllers/application_controller.rb:493:in `set_current_admin'",
"lib/gitlab/session.rb:11:in `with_session'",
"app/controllers/application_controller.rb:484:in `set_session_storage'",
"app/controllers/application_controller.rb:478:in `set_locale'",
"lib/gitlab/error_tracking.rb:52:in `with_context'",
"app/controllers/application_controller.rb:543:in `sentry_context'",
"app/controllers/application_controller.rb:471:in `block in set_current_context'",
"lib/gitlab/application_context.rb:54:in `block in use'",
"lib/gitlab/application_context.rb:54:in `use'",
"lib/gitlab/application_context.rb:21:in `with_context'",
"app/controllers/application_controller.rb:463:in `set_current_context'",
"lib/gitlab/jira/middleware.rb:19:in `call'"
]
スタック・トレースには N+1 の問題があります。これは、コードがグループ・メンバーごとにgroup.has_owner?(current_user)
を繰り返し実行しているためです。このイシューを解決するには、繰り返されるコード行をループの外に移動し、代わりに結果をレンダリングされた各メンバーに渡します:
- current_user_is_group_owner = @group && @group.has_owner?(current_user)
= render partial: 'shared/members/member',
collection: @members, as: :member,
locals: { membership_source: @group,
group: @group,
current_user_is_group_owner: current_user_is_group_owner }
キャッシュ・クエリを修正した後、パフォーマンス・バーには6つのキャッシュ・クエリしか表示されなくなりました:
変更の影響を測定する方法
メモリ・プロファイラを使ってコードをプロファイリングします。この例では、Groups::GroupMembersController#index
アクションをプロファイラでラップします。修正前、アプリケーションの統計は次のようになっていました:
- 割り当てられた合計7133601 バイト (84858 オブジェクト)
- 保持合計757595バイト(6070オブジェクト)
-
db_count
: 144 -
db_cached_count
: 55 -
db_duration
:303 ms
この修正により、割り当てメモリとキャッシュクエリの数が減少しました。これらの要因により、全体的な実行時間が改善されました:
- 割り当てられた合計5313899バイト(65290オブジェクト)、1810KB(25%)減少。
- 総保持量685593バイト(5278オブジェクト)、72KB(9%)減
-
db_count
95(34%減) -
db_cached_count
6 (89%減) -
db_duration
:162 ms (87%高速化)