一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

一休.comスマホサイトのパフォーマンス改善(サーバサイドとQAとリリース編)

こんにちは。 一休.comの開発基盤を担当しています、akasakasです。

今回は、一休.comスマートフォンホテルページリニューアルをリリースし、パフォーマンスが改善した話を書きます。

概要編はこちらになります。

user-first.ikyu.co.jp

JavaScriptパフォーマンス改善編はこちらになります。

user-first.ikyu.co.jp

CSS・その他パフォーマンスチューニング編はこちらになります。

user-first.ikyu.co.jp

この記事ではスマートフォンホテルページリニューアルで実施したサーバサイドチューニングについて書きます。

ここでお話しする内容

  • サーバサイドチューニング前後のOverview
  • プロジェクトの大まかなタイムライン
  • ボトルネック洗い出し
  • 対策
    • SQL改善
      • 不足インデックスの設定 => 600msの改善
      • 複雑なselect文をシンプルなselect文に分割し、aync/await で非同期化 => 130msの改善
    • Solr高速化
      • SolrへのリクエストをHttpClientを使って可能な限り async/awaitで非同期にする。=> 20ms程度の改善
      • Solrを5系から6系にバージョンアップ => 100msの改善
      • Solrが動いているEC2インスタンスをc4系からc5系にスケールアップ =>30msの改善
    • JavaScriptを使った遅延取得化 => 100msの改善
    • ここまでやって、速くなった、が、、、
    • アプリケーションをASP.NET MVCに移行 => 250msの改善
      • I/O処理を一か所に集めて、async/awaitで非同期
      • 鮮度が求められないデータはElasticacheでキャッシュ
  • QAとリリース
    • QA
      • chromelessを使った自動テスト
      • 手動&実機テスト
    • リリース
  • まとめ

サーバサイドチューニング前後のOverview

スマートフォンホテルページのサーバサイドの改善前の構成は、以下の図の通りです。

  • ASP.NET Web FormsのWebアプリがページを提供している
  • このWebアプリは、検索API経由で対象ホテルの宿泊プランや部屋の情報を取得している
  • このWebアプリは、宿泊プランや部屋の情報以外の情報は、検索APIを経由せずに直接DBアクセスして取得している

改善後は

  • WebアプリからASP.NET Web FormsからMVCにリプレイス
  • Webアプリは、DBを直接見ない
  • データ取得処理は、検索APIの一か所にまとめてasync/awaitで取得
  • 検索APIの呼び出しもasync/await
  • 鮮度を問わないデータはElasticaheでキャッシュ
改善前 改善後
f:id:akasakas:20180910203004p:plain f:id:akasakas:20180910203015p:plain
  • 検索APIが内部通信ではなくFastly経由になっているのは、Fastlyのキャッシュの仕組みを利用しているためです。

プロジェクトの大まかなタイムライン

  • 4月に、速度改善プロジェクトを本格的に始動
    • この時点の構成は↑の改善前の図
  • 4月の時点で、サーバサイドの処理速度は1週間平均で1500msくらい
  • これを、200ms以下にするのが目標
  • まず、比較的簡単な性能課題を解消することで、6月の時点で500msくらいまで改善
  • その後、全面的な作り直しを実施
  • 7月末にほぼ実装完了し、QA開始
  • 8月中旬に先行リリース、8月末に完全リリース
  • 最終的には、9月初頭の時点で、1週間平均で250msくらい
    • 中央値なら200ms前後。ほぼ目標達成
    • 現時点での構成は↑の改善後の図

ボトルネック洗い出し

まず、どこで時間がかかっているかを、洗い出しました。
ツールは、 Newrelic 。それと、アプリケーションのログも参考にしました。

見つかったボトルネックは、

  • 外部へのI/O処理、つまり、検索APIのデータベースアクセスとSolrへのクエリ
  • ユーザーの操作に応じてフロントエンドでJavaScriptを使って遅延取得すればいい情報をサーバーサイドで取得している。

まずは、一番取り組みやすく効果も見込めるselect文の改善に着手しました。
↑の改善前の図の「表示する情報を取得①」と「表示する情報を取得②」のselect文です。
Newrelicなら、ひとつのリクエストで、どのSQLが平均何回呼ばれ、1回あたりどのくらい時間がかかっているか教えてくれます。
有償版なら、対象のSQL文全体を表示してくれます。

対策

SQL改善

不足インデックスの設定 =>600msの改善

性能改善の基本的なアプローチで、一番簡単かつ効果があるのが、不足インデックスの付与です。
SQL Serverなので、SQL Server Management Studioでselect文を実行し、実行計画を確認すると不足インデックスをサジェストしてくれます。↓のように。

f:id:akasakas:20180910203027p:plain

Newrelicで採取したselect文にパラメータを補完して、本番と同等のデータが入っている開発用データベースでSQLを実行し、不足インデックスを洗い出します。その結果、数本のselect文でインデックス不足が見つかりました。
このインデックス付与で大幅に改善しました。

複雑なselect文をシンプルなselect文に分割し、aync/await で非同期化 => 130msの改善

インデックス付与には大きな効果があったのですが、まだまだ目標には達しません。
「表示する情報を取得②」のselect文はかなり複雑になっており、インデックスでの改善は見込めませんでした。
そこで、この複雑なselect文をデータの多重度がそろうように3分割し、async/awaitで非同期で取得して、ソースコード上で、Joinするようにしました。
ソースコード上でJoin、というとコードが複雑になりそうですが、LinqとObject to Object MapperライブラリであるMapster を活用することで、2,3行で実現できました。
.NET で Object to Object MapperというとAutoMapperがメジャーですが、より高速でよりシンプルに扱えるMapsterを選択しました。
検索APIは、ASP.NET Web APIですが、アクションメソッドにasyncキーワードがついていませんでした。そこで、このタイミングで、すべてのアクションメソッドをasyncにして、非同期処理をかけるようにしました。

Solr高速化

SolrへのリクエストをHttpClientを使って可能な限り async/awaitで非同期にする。=> 20ms程度の改善

検索APIのすべてのアクションメソッドをasyncにしたので、HttpClientを使った外部へのHttpリクエストもasync/awaitで非同期にできるようになったため、非同期にしました。
最初のリクエストの結果が次のリクエストの結果に入力になっている場合は、 非同期にできませんが、それ以外は可能な限り非同期化、です。

Solrを5系から6系にバージョンアップ => 100msの改善

もともとSolrの5系を使っていたので、6系にアップしました。
単純にバージョンを上げたら性能が改善するんじゃないか、という仮説でした。
これに伴ってJavaのバージョンも上がりました。

Solrが動いているEC2インスタンスをc4系からc5系にスケールアップ =>30msの改善

一休のサービスはすべてAWS上で運用されており、SolrもEC2で動いています。
c4系のインスタンスを使っていたのですが、昨年発表されたc5インスタンスに移行しました。
単純にCPUのスペックが上がっているので、cpu intensiveなSolrならある程度恩恵をうけるんじゃないか、という仮説でした。

JavaScriptを使った遅延取得化 => 100msの改善

Below the fold のパーツに対して、画面表示のリクエストでサーバサイドでデータを取得している箇所がありました。
これを、ユーザーの操作に応じてフロントエンドから非同期でデータを取得するようにしました

ここまでやって、速くなった、が、、、

ここまでの改善で、1500msかかっていた処理が、500msくらいまで改善しました。
大幅に改善したのですが、目標はサーバサイドの処理で200msを切る、です。
主に検索APIの改善でここまで高速化しました。
逆に言うと検索APIのこれ以上の雑巾絞りできなさそうです。

アプリケーションをASP.NET MVCに移行 => 250msの改善

あとは、Web Formsで作られているアプリケーションの改善のポイントを見出すしかありません。
ここまでの改善で、async/awaitを積極的に使えば、まだまだ改善が見込めることは明らかでした。
そこで、ホテルページだけASP.NET MVCで全面的に作り直すことにしました。以下のような理由です。

  • 既存のWeb Formsのコードがかなり複雑。この複雑さを前提にして速度改善を進めるのは厳しい。
  • フロントエンド側も、大幅な性能改善のためには全面的に書き直しが必要な状態だった。
  • Web Formsでasync/awaitを使うには特殊な書き方をする必要がある。Web Forms固有の概念や書き方に縛られたくない。

事前に @shibayan が横断的な関心事を処理するMVCの基盤を作ってくれていましたので、これをベースに黙々と作り直しをしました。
既存のコードを読み、動作を確認し、Todoを洗い出し、実装する、というのをぐるぐる繰り返しました。

I/O処理を一か所に集めて、async/awaitで非同期

改善前は、画面に表示する情報を、Web FormsのアプリケーションとWeb APIの検索APIの両方で取得していました。
これをWeb APIの一か所にまとめて、可能な限りasync/awaitを使って非同期にすることで、IO待ちをなるべく発生しないようにしました。

鮮度が求められないデータはElasticacheでキャッシュ

画面に表示する情報には、頻繁に変更されないデータがあります。その中で、データ取得のコストが高いデータはElasticacheでキャッシュするようにしました。

QAとリリース

QA

8割できかがったところで、同じく全面的に作り直しているフロントエンドと結合して、試験をします。
試験は、2段階に分けて行いました。

chromelessを使った自動テスト

今回の速度改善は、機能的には改善前と改善後で変わりません。既存のmasterブランチが正解の挙動です。
そこで、以下のようなスクリプトを作り自動テストを行いました。

  1. 本番のfastlyのアクセスログからリクエストパスを抽出する。
  2. 本番相当のデータが入っているデータベースにつないだmasterブランチとテスト対象のブランチに対して、chromelessで1. のリクエストパスでリクエスト。両者のレスポンスから表示情報を抽出。
  3. 2.の表示情報を突合。差異があったらエラーとする。

これを、2週間くらい毎日繰り返しました。リクエストにばらつきがあったほうが、バグの検出が高まるからです。
また、スクリプトをあまり厳密に作りこみすぎないように注意しました。発生条件が複雑なバグは手で触らないと見つからないだろう、と割り切って、あくまで「正常系のユースケースでちょっと触ったらすぐに見つかりそうなバグ」を見つけるためのツールとして作りました。

手動&実機テスト

実装が一通り終わった段階でテストケースを作成し、プロジェクトメンバー3人で実機テストをしました。
テストケースをすべて消化したら、会議室を押さえて、スマートフォンホテルページの仕様に詳しい有識者6人くらいで1時間黙々と実機で触りました。
この最後のQA会でもいくつかの重要な問題が見つかりました。
仕様に詳しい有識者だからこそ発見できる問題があり、ここで大きなBugfixを潰すことができたのは大きかったと思います。

リリース

リリースもテスト同様、QA同様、2段階で行いました。
まず、いくつかのホテル、施設のみを先行リリースします。

  • 表示情報が多く、それほどアクセスが多くない施設を選びました。施設タイプ(ホテル、旅館、ビジネスホテル、貸別荘)もばらつかせました。
  • 先行リリースには、IISのRewrite機能を使いました。

先行リリース後は、DatadogやNewrelic、Google Analyticsなどを用いて、先行リリースした施設に何かおかしなことが起こっていないか、注視しました。
QAフェーズに時間をかけたのが功を奏し、とくに問題は起こりませんでした。 1週間ほど様子を見て、問題なかったため、全面的にリリースしました。
サーバサイドの1週間平均の処理速度は、500msから250msまで下がりました。

まとめ

今回は、一休.comスマートフォンホテルページリニューアルをリリースし、パフォーマンスが改善した話を書きました。 その中でも、サーバサイドチューニングとQA・リリースについて書きました。 このプロジェクトを通じて、感じたことをまとめると

  • async/awaitは積極的に使っていくべき。
    • 複雑なJoinのselect文を一発実行するよりも、複数のシンプルなselect文をasync/awaitで発行して、ソースコード上でJoinするほうが、いろいろな面で良い。
    • 複雑なJoinのselect文はメンテしにくい。かつ、実行計画の変化で突然、遅くなる場合がある。
  • QA大事。
    • 計画に対して1週間ほどスケジュールが遅れましたが、これは、QAに予想以上に時間がかかったため。
    • QAにかかる工数の見積もりは難しい、と感じましたが、時間をかけただけの意味はあったようです。
    • リリース後に見つかったバグはほとんどなかった。
  • Newrelicは非常に価値のあるプロダクトだと改めて感じました。
    • ボトルネックの調査で大活躍。
    • 性能面の調査だけでなくインシデント発生時の調査でも有効。