ビジネスマッチングアプリ 「Yenta」事業でサーバサイドエンジニアをしている遠矢です。
アプリケーションを開発する際に切り離せない問題として、パフォーマンスチューニングがあります。
今回は、主にAPI開発の現場において最低限必要なパフォーマンスチューニングの基本についてお話ししたいと思います。
パフォーマンスチューニングとは
パフォーマンスチューニングとは、アプリケーションやシステムの信頼性や処理性能を高めるために、システムの最適化を継続的に行い続けることです。 昨今はアプリケーションの構成要素が複雑かしており、単にパフォーマンスチューニングと言っても膨大な知識や経験が必要になります。
例えばWebのフロントエンドアプリケーションで取れる対策をざっくり見てみても
リソースサイズ
- ファイルの圧縮、形式の最適化、サイズの縮小
- 不要コードの削除
- ライブラリサイズの削減 / ライブラリの変更
- トランスパイルの実行有無判断
- ...
通信の効率化
- プロトコルの最適化
- レスポンスヘッダやボディサイズの縮小
- 通信回数の削減
- ESの依存関係最適化 / バンドル化
- ...
CDN利用
- プレフェッチ、プレコネクトなどの利用
- ブラウザキャッシュ
- レンダリングパスの最適化
- ダウンロードの優先度判断
- ...
など方法は多岐にわたっており、またありがたいことに技術の進歩がめざましいため、どんどん新しい手法が提案、実装されています。
今回は、シンプルなWeb/APIサーバを例として、基本となる考え方とAPIのパフォーマンス改善の際によくやる手法をまとめさせていただきます。 構成は、Railsで構築されたアプリケーションサーバとDBを想定しています。
基本となる考え方と実際の例
パフォーマンスチューニングするために押さえておきたい基本は下記のフローをしっかりと愚直に回すことです。 これはフロントエンド、バックエンド、インフラ、DB、非同期処理、バッチ処理...どの様なケースでも適用できると思います。
- 計測する
- 原因特定する
- 改善する
計測する
計測することの目的は、現在の状況を正確に把握することです。
YentaでAPIのパフォーマンスを改善する際はDatadogを利用することが多いです。 確認する際はAPMで各エンドポイントのパフォーマンスを確認したり、
エンドポイント毎の詳細や集計結果を見て重たい処理の特定進めていくなどの使い方をしています。
また、ログを追う、ベンチマークツールを利用する、開発環境で特定の処理をコメントアウトして計測する... など詳細を特定するために様々な手法を用いています。
「推測するな計測せよ」という格言がある様に、パフォーマンスに悪影響を与えている要因をファクトとして特定する必要があります。
原因特定する
計測により、状況・悪影響を与えている要因を把握できたら「なぜその様になっているのか?」という原因を特定するフェーズに移行します。 こちらも原因は多岐に渡りますが、私の場合は下記の様な項目についてそれぞれ確認を行なっています。
- クエリ
- サーバー処理
また今回は割愛させていただきますが、
- DBやサーバのメモリ、CPU、コネクションなど設定/インフラ関連の問題
についても精査します。
改善する
原因が特定できたら後は改善するのみです。 こちらは原因特定と合わせて簡単な例をいくつか紹介させていただきます。
具体例
[原因特定] スロークエリを特定する
YentaではDatadogを利用しているため、 エンドポイント内で発生しているクエリを確認して、スロークエリを特定します。
原因特定の例
実行計画を確認する
特定したスロークエリの先頭に EXPLAIN
をつけることで実行計画を確認することができます。
EXPLAIN for: SELECT `hoges`.* FROM `hoges` WHERE `hoges`.`fuga_id` = 1
Railsだと下記の様な書き方でも同様の内容が確認できます。
Hoge.where(fuga_id: 1).explain
実行すると下記の様な結果を得ることができます。
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+------+---------------+------+---------+------+------+-------------+ | 1 | SIMPLE | hoges | ALL | NULL | NULL | NULL | NULL | 511 | Using where | +----+-------------+-------+------+---------------+------+---------+------+------+-------------+
このクエリの場合は、indexが効いてないことがわかります。
実際に処理を実行してみる
また、実際に処理を実行してログを見てみるのも良いかもしれません。
下記の様な処理があった場合にログを見てみると
10.times { |i| Hoge.create! fuga_id: i }
Hoge Create (8.8ms) INSERT INTO `hoges` (`fuga_id`) VALUES (1) Hoge Create (8.8ms) INSERT INTO `hoges` (`fuga_id`) VALUES (2) Hoge Create (8.8ms) INSERT INTO `hoges` (`fuga_id`) VALUES (3) Hoge Create (8.8ms) INSERT INTO `hoges` (`fuga_id`) VALUES (4) Hoge Create (8.8ms) INSERT INTO `hoges` (`fuga_id`) VALUES (5) ...
この様に多量のインサートクエリが発行されていることがわかります。
改善の例
上述の例だとindexを貼る、発行されるクエリを減らす ことで対応できそうです。
他にもよくある例として、
- 効率の様クエリを発行する様に処理を書き換える / 不要クエリを削除する
- ループ処理 / N+1を削減する
- ...
など色々なケースがあります。
indexを追加/変更/削除する
例であげた様にindexを追加/変更/削除する場合は、indexの詳細を確認します。 下記の様なコマンドを実行することで、
SHOW INDEX FROM hoges;
下記の様な結果を得ることができます。
+-------+------------+------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+ | Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | +-------+------------+------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+ | hoges | 1 | fuga_index | 1 | fuga | A | 501 | NULL | NULL | YES | BTREE | | | +-------+------------+------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
この時に利用したいindexがない場合は新たに追加すると良いでしょう。
indexを貼る場合はカーディナリを確認しておくと良いかと思います。
カーディナリとはindex上のユニークな値の多さを表す数値で、この値が小さいほどindexがユニークではないため検索効率が低くなってしまいます。
また大量にindexが貼られている場合は、更新性能が悪化してしまいます。
テーブルの特性を理解して、スロークエリに対応できる適切なindexを貼る様にしましょう。
バルクインサートを利用する
例であげた様にINSERTクエリが多量に発行されるケースはバルクインサートを利用したら良いかもしれません。
処理を下記の様に修正したとします。
hoges = [] 10.times { |i| hoges << Hoge.new(fuga_id: i) } Hoge.import hoges
こちらを実行すると、下記の様なクエリが発行されます。 クエリの発行数が激減するため、データの書き込みが多ければ多いほどパフォーマンスに好影響を与える可能性があります。
INSERT INTO `hoges` (`id`,`fuga_id`) VALUES (NULL,1),(NULL,2),...
効率の様クエリを発行する様に処理を書き換える / 不要クエリを削除する
例えば、下記の様な処理があった場合、
Hoge.all.map(&:fuga_id) Hoge Load (86.8ms) SELECT `hoges`.* FROM `hoges` => [...]
この様に修正することでパフォーマンスが削減されます。
Hoge.pluck(:fuga_id) (44.7ms) SELECT `hoges`.`fuga_id` FROM `hoges` => [...]
クエリで取得されるデータが処理内で利用されていない場合は、 クエリの最適化 もしくは 不要クエリをそもそも削除すると良いかもしれません。
これらは開発してる言語によってメソッドが提供されている場合もあります。
N+1の修正 / ループの削減
逆にクエリで取得していないデータを処理内で利用すると、場合によってはN+1を発生させてしまいます。
Hoge.all.map(&:fuga).map(&:id)
上記を実行すると、hogeを全件取得した後に、 fugas
のテーブルを参照するクエリが多量に発行されてしまいます。
さらにループ処理(map
)が2回実行されるため、その観点からもパフォーマンスに悪影響と言えます。
こちらは
Hoge.preload(:fuga).map{|hoge| hoge.fuga.id }
などの様にN+1を潰す、ループを減らす様に書き換えることで改善が見込めます。
肌感ですが、新人エンジニアが開発する場合にパフォーマンスに悪影響を与える処理として N+1 が一番多い気がしています。
開発時に検知できるライブラリも色々あるので、それらを利用すると良いかと思います。
Railsの場合、prosopiteやbulletなどがあります。
その他
簡単な例をいくつか紹介しましたが、
- サブクエリをJOINに書き換える
- キャッシュを利用する
- 非同期処理化する
- データ型を最適化する
- ...
などケースに応じて対応する方法は多岐に渡ります。 こうすれば完全に対応できるというものはないので、上記の具体例はよく見かける原因ぐらいに捉えておいてください。
終わりに
長々と書きましたが、パフォーマンスチューニングはこれをやっとけば正解と言えるものはありません。 重要なのは、計測して原因を特定し、改善すること と これを継続すること だと思います。
また、処理によってはあえてN+1を許容してる場合などもあるかもしれません(私は聞いたことないですが...w
ちゃんと処理の目的を理解して、計測して、確認して、修正するを地道に続けていきましょう。
最後に、パフォーマンスチューニング好きやで!って方は弊社で一緒に改善してみませんか?