こんにちは。プロダクトエンジニアリング部の江口です。主に賃貸部門の開発を担当しています。
このたび、LIFULL HOME'Sの賃貸詳細ページにおけるサーバーサイドの処理速度を改善し、99パーセンタイルを60%改善しました。 本記事では、このパフォーマンス改善をどのように実現したのか、具体的な技術的アプローチについて解説します。
背景
サーバーサイドの処理遅延は、ユーザー体験だけでなく収益にも悪影響を及ぼします*1。特に、平均処理時間は良好でも99パーセンタイル(p99)*2が高い場合、一部のユーザーは大きな遅延に遭遇し、不満を感じてサイトから離脱してしまいます。そのため、p99を改善することは離脱率の低下に直結し、長期的には収益にも良い影響をもたらします。
こうした課題は多くのWebサービスで共通していますが、LIFULL HOME'Sの賃貸詳細ページでも例外ではありませんでした。詳細ページのサーバー処理時間のp99は、平均値と比較して大きく遅延している状態でした。

そこで、詳細ページにおけるボトルネックを特定し、p99の改善活動に取り組みました。
分析から見えたパフォーマンスボトルネック
まず、アプリケーションのメトリクスやトレースから、ボトルネックとなっている処理の特定を試みました。分析を進める中で、特に以下の点が明らかになりました。
- 計測不能な処理時間の存在: トレースのスパンとして計測されない、実体の不明な処理に時間がかかっている箇所が見られました。
- イベントループの遅延: 最大1秒にも及ぶイベントループの遅延が判明しました。これは、他のリクエスト処理もブロックし、サービス全体の応答性能を低下させる要因となりえます。


これらのデータから、イベントループのブロッキングがアプリケーションロジックに起因する可能性が高いと判断しました。そこで、CPUの使用状況とメモリ消費を継続的に監視できるPyroscope(継続的プロファイラ)を導入し、詳細な調査を開始しました。Pyroscopeによる継続的プロファイリングの結果、イベントループのブロッキングとCPU占有を引き起こしていた可能性のある処理を見つけることができました。
URLエンコードの同期的な高負荷処理: APIへのリクエスト送信前に、特にセッション情報のような長い文字列をURLエンコードする処理が、同期的に実行されていました。この処理はCPUを長時間占有することで、イベントループをブロックし、アプリケーション全体の応答性を低下させる要因となる可能性があります。
セッション情報のデシリアライズ: バックエンドAPIから取得するセッション情報は、PHPのシリアライズ形式で格納されています。このPHP形式のデータをアプリケーションで利用可能な形式にデシリアライズする処理もまた、CPUを大量に消費する同期処理でした。この処理がイベントループをブロックし、パフォーマンスの低下につながる可能性がありました。
同期的なサーバーサイドレンダリング: 賃貸詳細システムのHTMLレンダリングにはPreactを使用しています。サーバーサイドレンダリング(SSR)の際、tsxデータから仮想DOMノードを生成し、それをHTML文字列に変換する処理が同期的に実行されていました。この一連の処理はCPUを長時間占有することで、他のリクエスト処理の応答性を阻害する要因となりえます。
これらのボトルネックは、いずれも同期的な高負荷処理が原因でイベントループをブロックし、アプリケーション全体のパフォーマンスに影響を与えていたと考えられます。これら以外にも多数のボトルネックが存在しますが、代表的なものは上記の3点でした。
改善のためのアプローチ
イベントループのブロッキングを抑制する一般的なアプローチとしては、CPUバウンドな処理の高速化やWorker Threadsへの処理のオフロードなどがあります。
試行錯誤の結果、URLエンコードの同期的な高負荷処理がイベントループをブロッキングする主要な原因の一つであることが判明しました。この処理を、外部ライブラリのqsからネイティブのURLSearchParamsに置き換えることで、イベントループのブロッキングが大幅に改善されました。特に、大規模なクエリパラメータを処理する場合、URLSearchParamsはqsと比較して最大で4倍以上高速に処理できることが確認できました。
// 変更前(qs) private convertQueryString(param: object) { return qs.stringify(param, { sort: (a: string, b: string) => { return a.localeCompare(b); }, arrayFormat: 'comma', }); } // 変更後(URLSearchParams) private convertQueryString(param: Record<string, unknown>) { const urlSearchParams = new URLSearchParams(); const sortedKeys = Object.keys(param).sort(); for (const key of sortedKeys) { const value = param[key]; if (value == null) continue; if (Array.isArray(value)) { urlSearchParams.append(key, value.join(',')); } else { urlSearchParams.append(key, String(value)); } } return urlSearchParams.toString(); }

この変更によって高速化できた理由は、主に「ネイティブ実装との速度差」と「処理のオーバーヘッド削減」にあります。
まず、URLSearchParamsはNode.jsにC++等で実装されたネイティブAPIであり、最適化されたマシンコードで極めて高速に動作します。対照的にqsはJavaScriptで実装されているため、実行速度に根本的な差が生まれます。さらに、qsはネストされたオブジェクトなど複雑なケースに対応する汎用的なライブラリであり、その分、内部には多くの条件分岐といったオーバーヘッドが含まれます。今回の実装は、必要な処理に特化してネイティブAPIを直接呼び出すため、こうしたオーバーヘッドが一切ありません。
これらの要因が組み合わさり、CPUを占有する同期処理の時間が劇的に短縮され、イベントループのブロッキングが解消されたと考えます。
成果
上記のURLエンコード処理の高速化のリリースによって、p99を60%改善することができました。

終わりに
今回のパフォーマンス改善は、可観測性の向上を目指すところから始まりました。メトリクスや継続的プロファイリングツールの導入だけではなく、必要に応じて手動でスパンやメトリクスを追加することで、p99の高さを生み出す根本原因をデータから特定しました。
また、既存ライブラリが自分たちのユースケースにマッチしているか検証する重要性も再認識しました。汎用的なライブラリがパフォーマンスのボトルネックとなる場合があり、ネイティブAPIのような特化した代替手段を検討することで、劇的な改善につながることがあります。
ウェブアプリケーションの速度改善は、まるで謎解きのようです。メトリクス、トレースデータ、ログといった手がかりを丹念に観察し、パフォーマンスのボトルネックを特定していく作業は、個人的にとても楽しい時間です。そして、改善策を適用した結果、システムのレスポンスタイムが向上したり、プロファイルデータが変化したりするのを直接データで確認できるのは、何よりも魅力的だと感じています。
これからも、ユーザーの皆さんにより快適な体験を提供できるよう、システムのパフォーマンス改善に情熱を持って取り組んでいきたいです。
最後に、LIFULL ではともに成長していける仲間を募集しています。よろしければこちらのページもご覧ください。
*1:https://glinden.blogspot.com/2006/11/marissa-mayer-at-web-20.html
*2:応答時間の分布において、観測された応答時間の99%がこの値以下に収まる点を指します。