パフォーマンスチューニング 初めの一歩
初めに
興味を持って開いてくださった方々、ありがとうございます。
この記事ではパフォーマンスに関して興味がある方々の最初の一歩になれば幸いに思います。
主にバックエンド、インフラのパフォーマンス向上の手法などに関して言及します。フロントエンドのブラウザの表示最適化などの話には触れないのでご容赦ください。
私自身、過去の長期インターン先でバックエンドのパフォーマンスチューニングのプロジェクトに興味があって話を聞いていました。しかし、エンジニアリングの実力的にも非常に未熟で全く話についていけませんでした。
当時から多少はバックエンドのエンジニアリングの知識などもついてきたと思い、達人が教えるWebパフォーマンスチューニング (通称ISUCON本) を用いて勉強してみました。また、サマーインターンでは就業型のインターンで大規模なプロジェクトに参加させていただき、その際に得た知見なども交えて書いていければと思います。
ISUCON本、わかりやすくボトルネックが発生する原因と、その解消方法を解説してくれているので非常におすすめです!
そもそもパフォーマンスチューニングとは?
パフォーマンスチューニングと聞いて何を想像しますか?
パフォーマンスをチューニング(調整)するみたいな...
ソフトウェアにおけるパフォーマンスは一般的にユーザーのアクションに対する反応速度のことを示します。特にバックエンドの場合にはリクエストが来てからレスポンスを返却するまでの時間のことを示します。
つまり、パフォーマンスチューニングを行うことでサクサクしたアプリケーションを構築できるというわけです。これは最終的にユーザー体験(UX)の向上に大きく起因しているというものになります。
パフォーマンスが悪くページの表示速度が遅い(APIからのレスポンスの返却に時間がかってしまう)と多くのユーザーがページから離脱してしまい、利益を生み出す可能性があったユーザーの機会が失われてしまうことの原因になります。
googleの調査によるとページの読み込みに1secから3sec以上かかってしまうと30%以上ものユーザーがサイトから離脱してしまうという調査結果も出ています。
think with googleより
パフォーマンスを向上させることのメリット
パフォーマンスを向上させることのメリットとしては以下のようなものが挙げられます。
- SEO(Search Engine Optimization)で上位に表示されやすいものになる
- インフラのコスト削減が期待できる
- UXが良くなる
つまり、プロダクトを成長させたり、事業の利益を最大化するためにはパフォーマンスチューニングは欠かせない要素になってくるのです。
パフォーマンスの指標の単位
パフォーマンスの性能を考える上で二つの指標を軸に考えることになります。
- レイテンシー
- スループット
それぞれの用語について書いていきます。
レイテンシーとは、リクエストを受け取ってからレスポンスを返却するまでの時間を示しています。つまり、単位は 秒/リクエスト (s/req) です。
レイテンシーが高いなどの文脈で使用されており、これはサーバー側の応答速度が遅くなっているということを示しています。
スループットとは、1秒あたりにサーバーが返却できるレスポンスの数です。つまり、単位は リクエスト/秒 (req/s) です。
パフォーマンスチューニングでは、これらの指標の数値を改善することを目標として様々な観点からチューニングを行います。
パフォーマンスチューニングの時に大事にしたいコンセプト
ISUCON本を読んで常に意識しておきたいと思ったいくつかのコンセプトを紹介します。
-
推測するな、計測せよ
- 考えて手を動かすのではなく、常にデータに忠実に手を動かすことが重要。
- 計測の際には条件を揃えるなど、公平な測定を行うこと。
-
ボトルネックになっている部分にだけアプローチする
- ボトルネックにより、システム全体が遅くなっているように見える。しかし、存在しているボトルネックを取り除くことで他の部分が本来の100%の性能を発揮でき、全体としてパフォーマンスの向上が見込める
- 上記のようにボトルネックを取り除いたのちに再度計測してみて他にボトルネックはないのかを確認してみること
- このアプローチを繰り返すことで全体のパフォーマンスの向上が見込める
具体的なボトルネックの例とその対策
ここでは具体的に発生しがちなボトルネックの内容とそれを対策するための方法について紹介します。
特に有名キーワードとして以下のようなものがあります。
- データベース
- N+1問題
- データベースインデックス
- 取得データを制限する
- プロキシ
- リバースプロキシを用いたキャッシュ
- キャッシュ
- インメモリキャッシュを使用する
- マイクロサービス
- バルクゲットなインターフェースを用意する
それぞれ細かくみていきたいと思います。
データベース
N+1問題
この問題は特に有名なパフォーマンス上の問題だと思います。
N+1問題と検索をした方が細かく解説が載っている記事がたくさんあるかと思いますが、ここでも一応どんなものなのかを書いておきます。
端的にいうと1つのクエリに対してN回のクエリが実行されてしまうということを示しています。
以下のコードを見てみましょう。
// ユーザーのリストを取得(1回目のクエリ実行)
users, err := db.Query("SELECT * FROM users;")
if err != nil {
return nil, err
}
// 取得したユーザーに対してループを回して投稿を取得(N回のクエリ実行)
for _, user := range users {
posts, err := db.Query("SELECT * FROM posts WHERE user_id = ?;", user.ID)
if err != nil {
return nil, err
}
user.Posts = posts
}
このコードではコメントに書いてある通り、ユーザー取得(1回のクエリ)をした後に各ユーザーに対して投稿を取得(N回のクエリ)しています。
この問題を解消するために以下のようなクエリを実装するようにします
// ユーザーの情報と投稿の内容をleft joinして取得する
userAndPost, err := db.Query("SELECT * FROM users LEFT JOIN posts ON posts.user_id = users.id;")
if err != nil {
return nil, err
}
このようなクエリを発行することで1回のクエリでユーザーに紐づく投稿の内容を取得できます。
GORMなどのORMを使用する際には内部的にN+1問題が発生していないか、注意しながら実装する必要があります。
データベースインデックス
こちらも言わずと知れたパフォーマンスチューニングのための手法になります。
個人開発のアプリのパフォーマンスチューニングを行った際にとりあえず考えるのがインデックスになるかと思います。これは簡単なSQLコマンドで実装でき、特に大規模なデータベースには莫大な効果を与えてくれるものになります。
CREATE UNIQUE INDEX idx_users_username ON users(username);
このコマンドでは、usernameというusersテーブルに存在しているカラムに対してインデックスが作成されています。
インデックス(索引)が作成されている裏ではどんなことが起きているのでしょうか?
インデックスを貼るということの言葉の裏には、特定のカラムに対してB+tree(またはB-tree)が作成されるという操作が行われています。
この操作により、テーブルではSELECTのクエリが計算量 CREATE, UPDATE, DELETEの操作にはインデックスの更新を伴うため、基本的には
テキストデータに対して、検索を行う場合があると思います。
-- 1. 前後の一致を検索するクエリ username が (任意の文字列)jon(任意の文字列) というカラムを取得
SELECT * FROM users WHERE username LIKE '%jon%'
-- 2. 後方の一致を検索するクエリ username が (任意の文字列)jon というカラムを取得
SELECT * FROM users WHERE username LIKE '%jon'
-- 3. 前方の一致を検索するクエリ username が jon(任意の文字列) というカラムを取得
SELECT * FROM users WHERE username LIKE 'jon%'
ここで注意しないといけないのは3番目のクエリにしかインデックスは適用されないということです。B+treeなどの構造を考えると当たり前のことなのですが、木構造は最初の文字列で検索をかけて枝分かれしていきます。つまり最初の文字列が確定していない(1, 2の例)ではインデックスがどの文字列から検索すればいいのかわからない状態になってしまい、結果的にインデックスを貼ったのにパフォーマンスが向上しないということがあります。
またデータベースのインデックスに関しては複合インデックスなどの概念もあり、複数カラムに対して検索を行うときなどはパフォーマンスの向上が見込めます。
以下のzennに公開されている記事などを確認してみるとより理解が深まるかと思います。
取得データを制限する
ここまでのサンプルのクエリでは全て SELECT * FROM users ... というように全てのカラムからデータを取得していましたが、必要なカラムだけを読み込む形式に変更することもパフォーマンスの改善が期待できます。
必要な列のみの取得を行うと、以下のような結果が期待できます。
また、行の数の制限を行うことも重要な要素です。
SELECT * FROM users LIMIT 10 OFFSET 10;
これにより、11件目のレコードから20件目のレコードを取得するようなクエリが実装できます。
特に大規模なデータベースでは毎回全件取得を行っているとネットワークの転送データ量が増加するなどの問題が顕著になり、パフォーマンスが低下するので注意が必要です。
ここまで示してきた取得データ量を制限することで以下のようなことが期待でき、パフォーマンスが向上することが期待できます。
- ディスクI/Oが減少する
- ネットワークの送信データサイズが減少
- キャッシュの効率が上昇
プロキシ
リバースプロキシ
リバースプロキシとはバックエンド側に配置されているプロキシで、主に負荷分散、キャッシュ、接続情報の使い回し、セキュリティなどを目的として配置されています。
負荷分散
リバースプロキシには負荷分散の目的があります。
この負荷分散のことを Load Balancing といい、著名なサービスとしてはAWSの Application Load Balancer などのサービスがあります。
AWSのドキュメントには以下のような記述があります。
ロードバランサーは、クライアントにとって単一の通信先として機能します。クライアントはロードバランサーにリクエストを送信し、ロードバランサーはターゲット (EC2 インスタンスなど) にそれらのリクエストを送信します。
つまり、一つのサーバーにリクエストを集中させるのではなく、いい感じにリクエストを分散してくれるという機能を提供してくれます。
これによりアプリケーション全体のパフォーマンスの向上が期待できます。
キャッシュ
リバースプロキシはキャッシュの機能も提供します。
ここでのキャッシュはサーバーに対して同じリクエストが来た時にそのレスポンスをキャッシュしてバックエンドを再び呼び出すことなくデータを返却する仕組みのことを指します。
このキャッシュはアプリケーションサーバーへのリクエストにも有効であり、CloudFrontなどのCDNを使用すれば静的なファイルをキャッシュしておき、ユーザーに直接返却する仕組みも提供してくれます。
つまりアプリケーションサーバーで処理を行わなくてもレスポンスを返却できるためサーバーの負荷軽減とレイテンシーの低下を期待できます。
ただし、適切なTTL(Time To Live)を設定して古すぎるデータを返却することがないようにしておく必要があります。
接続情報の使い回し
リバースプロキシはクライアントとサーバー間のTCP通信を再利用する機構を提供します。
これにより、毎回のTCPのハンドシェイクなどの通信のための接続確立のオーバーヘッドを削減して遅延を減らすことができます。
キャッシュ
先述のリバースプロキシの文脈でも出てきましたが、キャッシュすることもパフォーマンスを向上させるために重要な手法です。
ここでのキャッシュとはミドルウェアを使用したキャッシュを示します。具体的なものとして Memcached と Redis などのものがあります。これらは一般的にKVS(Key-Value-Store)と呼ばれることも多いです。
それぞれに共通する特徴に関して、述べていきたいと思います。
- Key-Valueペアを保持しており、高速なデータアクセスが可能になる
- ディスクではなく、メモリ上で動作するためディスクI/Oが不要であり、高速に動作する
- 永続化を前提としないことが多く、揮発性が高い(データが永続的に残ることがあまりない)
- 頻繁にアクセスされるデータを一時的に保存しておく
- DBやAPIのレスポンスをキャッシュ
- 分散環境(マイクロサービス)などで共有メモリ的な動作をさせる
ここまでの特徴からキャッシュのメリットとして以下のことが挙げられます。
- 計算が複雑で処理に時間がかかる処理など返り値をキャッシュすることで、実行回数を減少させることが期待でき、CPUの負荷低下やインフラのコスト低下を期待できる。
- マイクロサービス上からキャッシュのサーバーを使用してユーザーのレートリミットなどを管理することで比較的簡単にサービスを超えたデータ管理ができる。
しかし、問題点も存在します。
- 古いデータが表示されることもあり、データベース上のデータとキャッシュ上から返しているデータの不整合が発生することもあり得る
- 本来キャッシュするべきでないデータをキャッシュしてしまい、データが流出してしまう
- データの不整合を起こさないようなロジックの実装は非常に複雑性が増すため、エンジニアの能力が求められる。
ここまでの考察からリバースプロキシと同様に適切なTTLを設定するべきだということがわかります。また、ここでのTTLはデータの更新頻度なども加味する必要がありますが、十分に短時間にしておく必要があります。
ここでキャッシュがヒットした場合としていない場合にそれぞれ分けて処理の流れを確認してみます。
上から下に処理が流れていくイメージです。
このようなフローを得て、アプリケーションの高速化を行うことができるようになります。
マイクロサービス
バルクゲットなインターフェースを実装する
いよいよ最後のセクションになりました。
最後はマイクロサービスのパフォーマンスチューニングの話です。
マイクロサービスとは
まずは簡単にマイクロサービスについて説明します。
- ドメインなどの境界ごとに小さなサービスに切り出し、それぞれが独立してデプロイ、独立のスケールが可能になる
- 論理的な単位での分割がなされているし、物理的(サーバー)単位でも分割している
- それぞれのサーバーがインフラの観点から独立しているのでスケーラビリティに優れている
- ネットワーク越しの呼び出しが増加することなどが問題
ここではマイクロサービスに関する説明はこの程度にしておきます。
興味がある人は別途調べてもらえるとたくさんの情報が出てくるかと思います。
マイクロサービスのパフォーマンス問題
ここではマイクロサービス間において発生する1つのデータの問い合わせにN回の通信を行う現象に関して注目します。
先ほどのUsersとそれに紐づくPostsが存在しているアプリケーションを元に考えてみましょう。
現在UsersサービスとPostsサービスが存在していると仮定します。
ここでUsersサービスでユーザーのリストを一覧で取得し、そのリストを元にループを回してPostsサービスにリクエストを送っている実装があったとします。これでは、1回のUsersサービスへのリクエストとN回のPostsサービスへのリクエストが行われている状態です。この処理ではN+1回のネットワーク通信が発生しています。この状態ではネットワークのオーバーヘッドなどが非常に大きくなってしまい、パフォーマンスの観点からもよくない実装になります。
そこでバルクゲットのインターフェースを実装することでこの問題を解消できます。
バルクゲットなインターフェースとは
バルクゲットなインターフェースとは同じ種類のリソースを複数IDで一括取得するAPIのことです。
具体的には/users?ids=1,2,3のようなクエリを実行して該当する全てのユーザーの投稿を取得する実装を行います。
このような形でバルクゲットなものを用意することでパフォーマンスの向上が期待できます。
まとめ
ここまで様々なボトルネックの発生原因とその解消方法などに関してみてきました。
あくまでここでみてきたものはほんの一例に過ぎないと思います。
検索系の速度改善であればElastic Searchを導入する、そのためにアプリケーションデータベースとElastic Serachの同期方法を考えるなどいろんなことができると思います。
ここまでみてきたものは全て一般的なものだと思います。開発しているプロダクトに特有な制約条件の中で、正解のない中から最善の選択ができるようなエンジニアになることが求められているのかなと感じました。
最近挑戦している開発ではパフォーマンスを考えて実装することはないので将来的に挑戦できる環境に行ったら活躍できるようにもっと知見を増やしていきたいと思います。そのためには今は全力でいろんなことを勉強します!!
あと、ISUCONの開催が待ち遠しいです笑
今年中は開催の予定がないらしく、残念です😢
次回の開催があればぜひエントリーしてみたいと思います。
これからもバックエンドエンジニアとして全力で頑張っていきます。
ここまで読んでくださった方々、ありがとうございました。
Discussion