
Ruby on Rails
イベント
マガジン
技術ブログ
トリビューでWebエンジニアとして、コーポレートエンジニアを兼務しているHirokazu (@hirokazu_narrgi)です。 これまで主にバックエンドを触ってきましたが、徐々に領域を広げ、1人でサービス全てを扱えるエンジニアになるため勉強中です。まずはインフラ分野を触りたいと思っていましたが、個人でやるには初期環境から作るのが大変でどうしようか悩んでいました。ちょうどその時、今回のテーマとなる課題が社内で議論されていたので、挑戦したいと手を上げて担当しました。こうして、はじめてのインフラ構築に取り組むことになりました。 トリビューには、自社サービスの運用業務のために共有アカウント
こんにちは、タイミーでバックエンドエンジニアをしている 福井 (bary822) です。 タイミーのバックエンドは巨大な Rails のモノリスアプリケーションです。以前から「アクセスが集中する特定のテーブル(以下、人気テーブル)への DB マイグレーションが日中に通らない」という問題を抱えており、看過できないレベルになってきたため、本格的に対処に乗り出しました。 この記事では、原因となっていたロングトランザクションに対し、Datadog と Devin を組み合わせた自動修正フローで対処した話と、その設計の裏側を紹介します。 DBマイグレーション失敗のメカニズム 日常的に発生していたのは、人気テーブルへの ALTER TABLE が日中はほぼ通らない、という状況でした。原因は メタデータロック (MDL) です。 Aurora MySQL(8.0) では、SELECT / INSERT / UPDATE / DELETE などの DML が対象テーブルの MDL(共有ロック)を取得する MDL はテーブルなどのメタデータに対して取得されるロックであり、共有 MDL が保持されている間は ALTER TABLE に必要な排他 MDL を取得できずロック待ちになる MDL が解放されるまで ALTER TABLE はブロックされるため、1 本でも長い時間走るトランザクション(以下、ロングトランザクション)があると、その裏で ALTER TABLE がタイムアウトしてしまう つまり、クエリ実行頻度の高い人気テーブルほど日中は触れなくなり、「カラムを別テーブルに切り出す」「カラム、インデックスの削除を諦める」といった、技術的制約が設計を歪める方向に力学が働き始めていました。 このマイグレーション失敗そのものに対しては、これまで strong_migrations gem のロック取得リトライ機能( lock_timeout_retries など)で何とか対策してきました。しかし、これらはあくまで成功確率を上げる ための投機的なアプローチにとどまり、根本原因であるロングトランザクションそのものには手を入れられていませんでした。 ロングトランザクション修正の方針 これまで見てきた通り、根本原因はロングトランザクションそのものです。そこで、リトライで凌ぐ運用から一歩踏み込んで、いよいよロングトランザクション自体を減らしていく方向に舵を切ることにしました。 とはいえ、現時点において目立ったロングトランザクションを頑張って解消したとしても、今後開発者が意図せず新たなロングトランザクションを生み出してしまう可能性は大いにあります。 かといってマージ前にロングトランザクションを検出するのも現実的ではありませんでした。トランザクションの長さは、多くの場合そのレコード(スキャン)量に依存しており、本番で実行してみるまで検知しにくいからです。 そこで本番リリース前の検知は諦めて、リリース後にできるだけ早く検知する方針にしました。また、検知から修正、レビューまでをできるだけ自動化し、人間は最終判断要員として介入するだけで済む状態にすることで持続可能な運用を目指すことにしました。 仕組みの全体像 上記方針をもとにいくつかのプランを検討した結果、タイミーで既に導入されていた Datadog、Devin などを組み合わせ、以下の 5 フェーズからなる自動化フローを構築しました。 準備: ActiveRecord Query Logs を有効化し、クエリの発行元がSQLコメントとして埋め込まれるようにしておく 観測: Datadog Agent から本番 DB に対して定期クエリを実行し、 performance_schema と information_schema の情報をもとに、テーブルごとにMDLを取得するロングトランザクション時間をカスタムメトリクスとして Datadog に送信する テーブルごとにMDLを保持しているトランザクションのうち、計測時点で最も時間が長い秒数を記録する 検知: Datadog Monitor にてテーブルごとに一定のしきい値を超えるロングトランザクションを検知する 修正 : Datadog Monitor で発火されたアラートをトリガーとして、Datadog Workflow Automation を起動。コンテキストを整理して GitHub Actions 経由で Devin Session を起動し、修正 PR を作成 レビュー : 「修正対象のコードに詳しい人」を自動的に判定してアサイン + AI による事前レビュー ロングトランザクション修正フローの構成図 以下、それぞれのフェーズで工夫したポイントを紹介します。 準備: クエリの発行元を明らかにする Rails 7 から標準提供されている ActiveRecord Query Logs には豊富なオプションが用意されており、クエリの発行元をコメントとして付与する対象を限定することができます。 https://railsguides.jp/v8.1/configuring.html#config-active-record-query-log-tags タイミーでは次の設定を入れています。 config.active_record.query_log_tags_enabled = true config.active_record.query_log_tags = %i[namespaced_controller action sidekiq_worker rake_task] 観測: ロングトランザクション発生状況を可視化する MySQL では performance_schema と information_schema の情報を組み合わせることで「テーブルごとのその時点で実行されている最も長いMDLを取得するトランザクション」を特定することができます。 さらにクエリコメントとして付与された発行元の情報を組み合わせることで「どこから実行されたトランザクションが何秒実行されているか」が特定可能になります。 次の例では、テーブル名を table_name 、クエリの発行元を query_source として取得しています( query_source は、実際の出力を見ながら扱いやすいように加工している)。 計測クエリ例 SELECT table_name, CASE WHEN raw_sql LIKE '%namespaced_controller:%' THEN CONCAT( TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'namespaced_controller:', -1), '*/', 1), ',', 1)), '#', TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'action:', -1), '*/', 1), ',', 1)) ) WHEN raw_sql LIKE '%sidekiq_worker:%' THEN TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'sidekiq_worker:', -1), '*/', 1), ',', 1)) WHEN raw_sql LIKE '%rake_task:%' THEN CONCAT('rake:', TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'rake_task:', -1), '*/', 1), ',', 1))) ELSE 'unknown' END AS query_source, tx_duration_seconds AS max_tx_duration_seconds FROM ( SELECT CASE WHEN ml.OBJECT_NAME LIKE '#sql-%' THEN 'DDL_IN_PROGRESS' ELSE ml.OBJECT_NAME END AS table_name, COALESCE(esc.SQL_TEXT, it.trx_query, '') AS raw_sql, TIMESTAMPDIFF(SECOND, it.trx_started, NOW()) AS tx_duration_seconds, ROW_NUMBER() OVER ( PARTITION BY CASE WHEN ml.OBJECT_NAME LIKE '#sql-%' THEN 'DDL_IN_PROGRESS' ELSE ml.OBJECT_NAME END ORDER BY TIMESTAMPDIFF(SECOND, it.trx_started, NOW()) DESC ) AS rn FROM performance_schema.metadata_locks ml JOIN performance_schema.threads th ON ml.OWNER_THREAD_ID = th.THREAD_ID JOIN information_schema.innodb_trx it ON th.PROCESSLIST_ID = it.trx_mysql_thread_id LEFT JOIN performance_schema.events_statements_current esc ON th.THREAD_ID = esc.THREAD_ID WHERE ml.OBJECT_TYPE = 'TABLE' AND ml.OBJECT_SCHEMA NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys') AND TIMESTAMPDIFF(SECOND, it.trx_started, NOW()) >= 5 ) ranked WHERE rn = 1 このクエリを何かしらの方法で本番DBに対して定期的に実行し、その結果をどこかに貯めておけばロングトランザクション発生状況を可視化できます。 Datadog ではこれを簡単に行うことができました。アプリケーションが実行されているものとは別のサービスとして ECS 上で常時稼働している Datadog Agent にて定期的にクエリを実行し、その結果をカスタムメトリクスとして Datadog に送信しています。 Aurora MySQL での設定方法: https://docs.datadoghq.com/ja/database_monitoring/setup_mysql/aurora 検知: 修正対象のロングトランザクションを絞り込む カスタムメトリクスとして1度 Datadog に取り込んでしまえば、それを使ってアラート(Datadog Monitor)を仕込むことは簡単です。 メトリクスはクエリ発行元( query_source )でグルーピングして監視するようにしました。こうすることで後続のフローに「どのクラス(ファイル)でロングトランザクションが発生したか」を渡せるようになります。 また、発行元が特定できなかったものや定期実行しないバッチなどは対象外としました。 以下が Datadog Monitor のクエリです。( !query_source:rake:tmp:* は定期実行しないバッチを取り除くためのものです) default_zero(avg:custom.mysql.mdl_holder.max_tx_duration_by_table{account:timee-jp-prod,replication_role:writer, !query_source:unknown, !query_source:rake:tmp:*} by {query_source}) しきい値はまずはアラートがノイズにならない程度(後続の修正フローによって作成されるPRのレビューが負担にならない程度)から始めることをおすすめします。 タイミーの場合は当初数百 sec を超えるロングトランザクションが発生していたため、まずは 100 秒をしきい値として設定しました。 この時点でロングトランザクションの発生元が限られている場合は、後続の自動修正フローを構築する前に、まずはそれらだけを対象にいったん修正してみるのも効果的かもしれません。 修正: パターン集で修正アプローチを制御する Datadog Monitor のしきい値超過をトリガーに、Datadog Workflow Automation を起動します。ここでは、Monitor から渡されたロングトランザクションに関する情報(クエリ実行元、発生時間など)を取りまとめ、GitHub Action 経由で Devin Session を起動して、詳細な原因調査と修正PRの作成を行います。 また、数百秒にわたるロングトランザクションでは、Monitor が重複してトリガーされる可能性があります。そのため、同一クエリ発行元に対して Devin Session が重複実行されないようにする必要がありました。具体的には、Session 起動時のタグに query_source を設定し、新しい Session を起動する前に既存の起動有無をチェックして、利用料金の無駄を防いでいます(初期段階ではこのチェックがなく、一夜にして数百ドルかかったことがありました)。 Devin による修正では Datadog MCP 経由で APM などの情報を分析させることで詳細な原因調査を行っていますが、しばらく運用しているうちにロングトランザクションの発生とその修正方法には一定のパターンがあることを発見しました。そこであらかじめ修正パターンをドキュメント化してレポジトリに置いておき、それを Devin に参照させるようにしました。こうすることで調査のアタリをつけやすくなりコンテキストの節約に寄与したり、実行時間を短縮することができました。 修正パターンドキュメント例 # トランザクション内の外部APIコールを排除する ## 概要 トランザクション(`with_lock` / `transaction do`)の内側で外部APIコール(HTTP リクエスト、LLM API、外部 SDK 呼び出しなど)を実行している場合、通信時間の間ずっとMDL(Metadata Lock)が保持され続けます。外部呼び出しの所要時間は秒〜分単位に及ぶことがあり、これがロングトランザクションの**最も典型的な原因**です。 改善の基本方針は、外部呼び出しをトランザクション外に出して **MDL保持時間を最小化** することです。完全な除去ではなく **トランザクションスコープの最小化** を第一選択とし、ロックが守ろうとしていたデータ整合性は別の手段(ステータス管理・楽観的整合性チェックなど)で維持します。 ## 問題のシグネチャ - **コード上の特徴**: - `with_lock do ... end` または `transaction do ... end` の内部に、HTTP クライアント呼び出し(Net::HTTP, Faraday, RestClient など)、AWS SDK 呼び出し、LLM API 呼び出し、メール送信、Slack 通知などが含まれている - 外部呼び出しが完了してから `save!` / `update!` が呼ばれる流れになっている - **APMトレース上の特徴**: - トランザクション開始から終了までのスパン内に、`http.client` / `aws.s3` / `openai.api` 等の子スパンがある - DB クエリの所要時間より外部呼び出しスパンの所要時間のほうが長い - 「DB時間 << 全体時間」のトレースが頻発している ## Before / After ```ruby # Before(外部APIコールがトランザクション内 → MDLを長時間保持) def process with_lock do reload return false unless entered? result = call_external_api! # 外部APIコール → 最大120秒のMDL保持 save_result!(result) end end # After(トランザクションを分離してMDL保持時間を最小化) def process # 短いトランザクション: ステータス確認のみ with_lock do reload return false unless entered? end # 外部APIコールはトランザクション外で実行(MDLを保持しない) result = call_external_api! save_result!(result) end ``` ### 楽観的整合性チェックの追加(再enqueueパターンがある場合) 対象の処理が「データ変更時に再enqueueされる」設計の場合、以下のリスクが生まれます: - Worker A がデータ読み込み後にトランザクションを終了 - レコードが更新され Worker B が enqueue - Worker A が古いデータで重い処理を続行 - Worker B が新しいデータで上書き(結果整合性は保たれるが Worker A の処理は無駄になる) このリスクを緩和するため、トランザクション終了後に再enqueueトリガーと同じ変化検知ロジックでデータの鮮度を確認し、変化があれば中断する楽観的チェックを追加します。 ```ruby # トランザクション内でスナップショット取得 before_checker = SomeChecker.new(record) data = load_data_in_transaction # トランザクション外で鮮度確認(重い処理の前) current_record = Record.includes(...).find(id) return if before_checker.changed?(current_record) # Worker Bに任せる # 重い処理を実行 process(data) ``` ## 効果 - MDL保持時間が **秒〜分単位** で短縮される(外部呼び出しの所要時間ぶん) - ロングトランザクション(長時間 MDL 保持)アラートの発火回数が大幅に減少することが期待される - 同テーブルへの他アクセス(マイグレーション・更新クエリ)の待ち時間も短縮される ## 注意点・トレードオフ - **排他制御が弱まる可能性**: トランザクション外に出すことで排他制御が弱まる場合があります。 ` retry: false ` の Sidekiq Worker など、同一レコードが同時処理されるリスクが低い場合は許容できます - **堅牢化の選択肢**: より堅牢にするには、トランザクション内でステータスを ` processing ` に変更してから外部呼び出しを行うパターンが有効です(スキーマ変更が必要な場合は別PRで対応) - **楽観的整合性チェックの適用条件**: 対象レコードの更新が同一Workerの再enqueueをトリガーする設計になっている場合のみ必要。再enqueueしない設計では不要です - **完全除去は最終手段**: ロックの完全除去は、保護が不要であることを論理的に説明できる場合にのみ行ってください。経緯( ` git log ` / ` git blame ` )を確認せずに削除すると、過去に修正済みのバグを再発させるリスクがあります ``` # Before(外部APIコールがトランザクション内 → MDLを長時間保持) def process with_lock do reload return false unless entered? result = call_external_api! # 外部APIコール → 最大120秒のMDL保持 save_result!(result) end end # After(トランザクションを分離してMDL保持時間を最小化) def process # 短いトランザクション: ステータス確認のみ with_lock do reload return false unless entered? end # 外部APIコールはトランザクション外で実行(MDLを保持しない) result = call_external_api! save_result!(result) end ``` ### 楽観的整合性チェックの追加(再enqueueパターンがある場合) 対象の処理が「データ変更時に再enqueueされる」設計の場合、以下のリスクが生まれます: - Worker A がデータ読み込み後にトランザクションを終了 - レコードが更新され Worker B が enqueue - Worker A が古いデータで重い処理を続行 - Worker B が新しいデータで上書き(結果整合性は保たれるが Worker A の処理は無駄になる) このリスクを緩和するため、トランザクション終了後に再enqueueトリガーと同じ変化検知ロジックでデータの鮮度を確認し、変化があれば中断する楽観的チェックを追加します。 ``` # トランザクション内でスナップショット取得 before_checker = SomeChecker.new(record) data = load_data_in_transaction # トランザクション外で鮮度確認(重い処理の前) current_record = Record.includes(...).find(id) return if before_checker.changed?(current_record) # Worker Bに任せる # 重い処理を実行 process(data) ``` ## 効果 - MDL保持時間が **秒〜分単位** で短縮される(外部呼び出しの所要時間ぶん) - ロングトランザクション(長時間 MDL 保持)アラートの発火回数が大幅に減少することが期待される - 同テーブルへの他アクセス(マイグレーション・更新クエリ)の待ち時間も短縮される ## 注意点・トレードオフ - **排他制御が弱まる可能性**: トランザクション外に出すことで排他制御が弱まる場合があります。 ` retry: false ` の Sidekiq Worker など、同一レコードが同時処理されるリスクが低い場合は許容できます - **堅牢化の選択肢**: より堅牢にするには、トランザクション内でステータスを ` processing ` に変更してから外部呼び出しを行うパターンが有効です(スキーマ変更が必要な場合は別PRで対応) - **楽観的整合性チェックの適用条件**: 対象レコードの更新が同一Workerの再enqueueをトリガーする設計になっている場合のみ必要。再enqueueしない設計では不要です - **完全除去は最終手段**: ロックの完全除去は、保護が不要であることを論理的に説明できる場合にのみ行ってください。経緯( ` git log ` / ` git blame ` )を確認せずに削除すると、過去に修正済みのバグを再発させるリスクがあります Devin は与えられたコンテキストとパターン集を照らし合わせ、当てはまるパターンがあればこれを参考に修正。なければ新規パターンとしてドキュメントを追加します。 つまり、Devin が直せば直すほど、次の Devin が使えるドキュメントが増えていくループを、リポジトリ内で完結する形で作っています。プロンプトの調整も普通の PR ベースで行えるので、レビュアーからのフィードバックが自然と AI 側の挙動改善に還元されていきます。 レビュー: 「そのコードに詳しい人」を特定する ロングトランザクション修正は、コードの表面的な変更だけでは判断できないケースが多く、実装の意図やドメイン背景を知っている人のレビューが不可欠です。 そこで、次の手順でレビュアーを決めています。 コードオーナーが設定されていれば、その人(チーム)をレビュアーとする なければ、直近 1 年間で最も多くそのファイルに commit したユーザーとその時点での所属チーム 1 年以内に commit がなければ、特定チーム(私が所属するチーム) これはプロンプトベースだと間違ったアサインを行うことがあったため、スクリプト化しました。 さらに、作成された PR に対して AI レビューを実行しています。Devin はレビューに対して自動で対応を行うため、人間レビュアーの目に届く時点で、AI 同士の一次すり合わせは終わっている状態になっています。 運用上のポイント 昨今、コーディングエージェントの性能向上やその周辺ツールの充実により、このような自動修正フローを簡単に構築することができるようになりました。 一方で「作った仕組みを普段の開発フローの中で無理なく運用する方法」をセットで実装することは以前に増して重要になってきたように思います。 今回のケースでは下記3点を特に意識して実装に落とし込みました。 人間の目に触れる前までに無駄を削ること 人間が対応する場合の工数を可能な限り小さくすること 無理なく運用できるペースで継続できること AI による相互レビューで無駄を削る 前述の AI 相互レビューでは次の観点でPRの妥当性を判断しています。 この変更は本当に長時間MDLを生み出すボトルネックにアプローチしているか? この変更が長時間MDLを解消するための必要最小限の変更か? 長時間MDLを解消しつつ、元の振る舞いを極力維持できているか? たとえ修正によってあるトランザクションがMDLを取得する時間が短くなったとしても、それが検出されたロングトランザクションを十分に解消する(アラートが鳴らなくなるレベル)でなければ修正する価値はありません。 また、修正できたとしてもその変更範囲が膨大になってしまえばレビュアーの負荷が高くなり、いつまでもマージできないことで運用が回らなくなってしまいます。 AI レビューでこれらの観点を満たさない場合は PR を クローズする運用を行っています。 「対応しない」ことも選択肢におく 継続的な運用で意外と重要なのが、「対応しない」判断を尊重することです。 Devin が作った PR が、レビュアーの目から見て対応しないと判断されることは普通にあります。多くの場合トランザクションの範囲を小さくしたりトランザクション自体を無くすことはデータの整合性とトレードオフの関係にあるからです。 このとき単にクローズして終わりだと、次に同じクエリ発行元( query_source )でトリガーされたときにまた同じ PR が生成されてしまいます。 これを避けるために、「対応しない」ことがあるという前提で運用を考えました。また、対応しない場合の工数もできる限り小さくなるようにしています。 対応しないものは query_source 単位で Ignore List として管理し、リポジトリに含めておく Ignore List の実体はただの query_source のリスト(フォーマットは JSON、YAML など何でもいい) レビュアーが PR に long-transaction-wontfix ラベルを付けるとGitHub Actions が起動し、それまでの commit を破棄して Ignore List に追加する ⚠️ Ignore List は query_source 単位なので、同じ query_source の別箇所で新たにロングトランザクションが発生しても検知されなくなります。厳密な検知性より運用のシンプルさを優先した割り切りで、必要があれば粒度を後から変えられるようにしています。 しきい値を下げて対象を広げていく ここまでの仕組みは、Datadog Monitor のしきい値(初期構築時は 100 s)を超えたロングトランザクションを対象にしています。運用初期はやや保守的な値に置き、専用のダッシュボードにまとめたロングトランザクション発生状況や作成された修正 PR 数やマージ数、レビュアーの偏りを見ながら、段階的に下げていく運用を行っています。 現在では無理なく運用しながらしきい値を 50s まで引き下げられており、人気テーブルによっては MDL 保持時間が以前の半分以下になりました。 定期観測しているダッシュボード。画面上部のメトリクス(MDL保持時間)が時間が進むにつれて改善されている(短くなっている)ことがわかる おわりに 以前投稿した Flaky Test 自動修正の取り組みとテーマは違いますが、同じようなパターンでロングトランザクションを改善する仕組みの実装と運用ノウハウを紹介しました。 tech.timee.co.jp 今回のケースでは変更によるトレードオフが発生する特性があるため、「対応しない」という選択も同じように尊重する必要がありました。そこでロングトランザクションを駆逐するのではなく、あくまでも現状を緩和することをターゲットに置いたことで現実的に持続可能な運用に落とし込むことができました。 問題の発生を検知し、自動で原因分析から修正 PR の作成まで行うパターンは、他の問題にも適用できる汎用性があります。そのため、ついつい多用したくなってしまいます。しかし、開発サイクルのどこかに人間が介在する限り持続可能な運用に落とし込むことが重要になっていることをあらためて実感しています。 最後までお読みいただき、ありがとうございました!
こんにちは。BUYMA TRAVELのWebエンジニアの赤間です! BUYMA TRAVELは BUYMA を運営する株式会社エニグモのグループサービスです。 1. はじめに 弊社では日本語対応のオプショナルツアー・貸切ガイド予約サイト’’BUYMA TRAVEL’’を開発・運営しています。 その中でも「エリアページ」と「スポットページ」は検索エンジンから流入したユーザが最初に訪れることの多い主要なページです。 ここ数ヶ月でユーザにとってより使いやすいページを目標に、クチコミやおすすめ商品の掲載など様々な要素・機能の追加を行ってきました。しかしその一方で表示速度が低下し、商品数の多いページによっては表示完了まで最大3秒かかってしまう状況でした。 そこでこれらのページを対象に、Railsアプリケーションのパフォーマンス改善を実施しました。本記事では調査の流れから改善内容、その改善を通して得られた知見をご紹介します。 2. エリアページ・スポットページとは ここで簡単にサービス紹介もかねて、この2つのページのご紹介です。 エリアページ https://travel.buyma.com/service/a040118/ その名の通り、エリア(地域)単位で商品をまとめている特設ページです。 スポットページ https://travel.buyma.com/service/a040118/s0000001/ こちらは、観光スポット単位で商品をまとめている特設ページです。 どちらのページも人気商品やプライベート商品、クチコミ、おすすめスポットなどのコンテンツを掲載しています。 一見すると単純な一覧ページですが、実際にはユーザに新鮮な情報を提供するための複数の検索処理や集計処理、共通コンポーネントによって構成されており、システム的に比較的処理の多い重いページとなっています。 3. なぜ改善が必要だったのか これらのページは検索エンジンから’’BUYMA TRAVEL’’に流入したユーザが最初に目にするページです。 そのため表示速度の低下は単なる技術的な課題ではなく、サービス全体の利用率や売り上げにも直結する可能性があります。 また、エリアページとスポットページはサービス内でもアクセス数が多く、改善効果が大きいページでもありました。 実際に計測したところ、改善前ではページ表示までに最大3秒程度かかってしまうケースも確認できました。 ページ表示速度と離脱率には相関があると言われており、ユーザがページの表示を待っている間に離脱してしまえば、どんなに魅力のある商品を掲載していても見てもらうことはできません。 そこで、本格的なパフォーマンス改善に取り組むこととなりました。 4. AIを活用したボトルネック調査 エリアページは長年運用されてきたページであり、コードベースも大規模化していました。 そのため単純に「とりあえずSQLを削減する」といった場当たり的な改善ではなく、本当にボトルネックとなっている処理を特定する必要がありました。 Railsアプリケーションではコントローラ・モデル・ビュー(・ヘルパー)に処理が分散しているため、どこで処理時間を消費しているかを把握するだけでも手間がかかります。 今回はDatadogで遅いトランザクションを特定し、Cursorを利用してコードベースの調査を行いました。 AIに「TTFBを悪化させている箇所を探して欲しい」 という依頼を行い、懸念箇所や修正候補をリストアップしてもらいました。 5. 調査して見つかった問題箇所 分析の結果、大きく以下の3つの課題が見つかりました。 SQLの発行数が多い 同一リクエスト内で重複した計算が発生している キャッシュが十分に活用できていない 特にSQL関連の問題が深刻で、全体処理時間の約60%を占めている有り様でした。 6. 実施した改善 SQL発行数の削減 調査の結果、レスポンス時間の大部分をデータベースアクセスが占めていることがわかりました。 特に商品一覧の表示処理では、関連データを取得する際にN+1問題が発生している箇所が複数存在していました。 N+1問題とは、一覧取得後に各レコードごとに追加のSQLが発行されてしまう問題です。 たとえば商品を10件まとめて表示する場合、本来1回で済むはずが11回以上のDBアクセスが必要になることがあります。 さらにデータ量が増えるほどSQL実行回数も増加するため、パフォーマンスへ大きな影響を与えてしまうのです。 今回の改善では includes を利用したEager Loadingを積極的に導入し、必要な関連データを事前にまとめて取得するように変更しました。 また、同じ情報を異なるコンポーネントで個別に取得している箇所も存在していたため、取得処理を集約し不要なクエリ発行を削減しました。 結果として、SQL発行数を大幅に削減でき、レスポンス改善に最も大きく貢献した施策となりました。 同一リクエストで同じ計算をしていた 調査を進める中で、同一リクエスト内で何度も同じ計算を行なっている箇所も見つかりました。これは本当にもったい無いですね、、、 単体で見ると小さな処理ですが、ページ全体では何度も呼び出されるため無視できないコストになります。 そこでメモ化を導入し、一度計算した結果を再利用するように変更しました。 また、一部の集計処理にはキャッシュを活用することで再計算そのものを削減しています。 大きな改善ではありませんが、細かな最適化の積み重ねが最終的なレスポンス改善につながりました。 お気に入り機能とキャッシュの見直し 今回の改善で特に興味深い問題が、お気に入り機能とキャッシュの関係でした。 商品カードはページ内で大量に表示されるため、本来であればカード単位でキャッシュしたいコンテンツです。商品名や価格、評価、パートナー情報などは頻繁には変化しないため、一度生成したHTMLを再利用できることで大きな効果が期待できます。 しかし、お気に入りの状態はユーザごとに異なる情報です。 同じ商品であってもあるユーザにとっては「お気に入り登録済」、別ユーザにとっては「未登録」という状態があります。 そのため商品カード全体をキャッシュしてしまうと、 ユーザごとに異なるキャッシュを生成する必要がある キャッシュキーが複雑になる キャッシュヒット率が低下する という問題が発生します。 実際に、改善前はこの問題を回避するために、お気に入りボタンを除いた「パートナー・評価」「商品情報」の2つの領域に分けて個別にキャッシュしていました。 この構成でもキャッシュがない状態より圧倒的に効率的ですが、キャッシュの管理が複雑であったり、テンプレートも読みづらい実装になっていました。 そこで今回の修正では、商品カードとお気に入りの状態を分離し、商品カード全体を共通キャッシュできる構成へ変更しました。 商品カード本体は全ユーザ共通のHTMLとしてキャッシュし、商品IDごとのお気に入りの状態は別で取得する形です。 これにより商品カード自体は1つのキャッシュとして扱えるようになり、キャッシュ構成をシンプルにしつつ、高いキャッシュ効率を実現しています。 結果としてレスポンス速度だけでなく、今後の保守性や機能追加のしやすさという点でも大きな効果を得ることができました。 7. 改善結果 改善前ではページ表示までに最大3秒程度かかる状態でした。 改善後は1秒に満たない時間で表示できるケースが大半となり、体感速度も大きく向上しました。 今回の改善では特定の1箇所を直したのではなく、 SQLの最適化 重複処理の削減 キャッシュの見直し といった複数の小さな改善を積み重ねたことが、この結果につながりました。 8. まとめ 当サイトの主要ページである、エリアページ・スポットページの速度改善についてご紹介しました。 パフォーマンス改善というと大規模なアーキテクチャ変更をイメージするかと思います。私自身はそのような印象を持っていました。 しかし実際には特別な技術ではなく、N+1の解消やメモ化、キャッシュ設計の見直しといった、比較的小さな改善の積み重ねでも、十分な効果が出せると実感しました。 Railsには優秀なキャッシュ機構や様々な最適化手法がありますが、まずは基本的なSQLの見直しが重要です。 普段のレビューでもN+1の有無や効率的なデータの取得については確認していますが、改めて見直してみると、どうしても漏れは出てくるものだと感じました。機能追加を重ねてきたページほど、定期的にパフォーマンスの棚卸しは必要だと思います。 今回で大きくレスポンスを改善できましたが、まだ課題は残っています。 特にレビュー集計処理にはサマリーテーブル導入の余地がありますし、ページビューカウントの非同期化など、更なる高速化が可能です。 今後もユーザ体験向上のため、継続的な改善を続けていきます。
動画
該当するコンテンツが見つかりませんでした
















