背景
とあるWebシステム開発の案件で、パフォーマンスが非機能要件を満たせていないということから、プログラムを大幅に作り直すことになりました。
今後そういうことが起きないよう、パフォーマンスを意識したコーディングを行う際に必要な考え方をまとめておきたいと思ったので、ここにまとめます。
Webに特化した部分もありますが、基本はどのシステムにも適用できる考え方かと思います。
重い処理を知ろう
まずはプログラムの中で実行される処理の中で、一般に重いとされる処理を知っておきましょう。
ここでは処理が開始されてから完了するまでに時間がかかるような処理のことを重い処理と呼ぶことにします。
プログラムの中で重い処理は主に以下の3つがあります。
- DBアクセス
- ファイルのI/O
- ネットワークアクセス
上記の処理には特徴があります。
それは、メモリの外に対してのアクセスが発生するという点です。
通常、プログラムはコンピュータのメモリ上に展開され、メモリ上で動作します。
全ての処理がメモリの中だけで完結しているのであれば処理はそこまで重くはならないですが、メモリの外部に対してアクセスが必要になった場合、処理は重くなります。
上記の3つに当てはまらなくても、メモリの外部にアクセスするような処理の場合、基本的には重い処理になると知っておきましょう。
DBのデータは基本的にHDDやSSDなどのストレージに保存されています。
DBアクセスの処理を実行すると、メモリの外にあるストレージに対しての読み書きが発生するため、処理が重くなります。
またDBアクセスではDBサーバーとAPサーバー間でのネットワークアクセスも発生します。物理的にサーバーが離れている場合は、その間のネットワーク通信も発生するので、メモリ上で完結するプログラムと比較するとどうしても処理は重くなります。
ファイルのI/Oとは、ファイルの読み書き(Input/Output)の処理のことで、例えばログファイルへの出力や、CSVファイルの読み込みなどが該当します。
ファイルもストレージに保存されているので、ストレージに対しての読み書きが発生し、処理は重くなります。
ストレージが物理的に離れていればネットワーク通信が発生します。その点はDBアクセスと同様です。
ネットワークアクセスの処理は、例えばWebサイトから情報を取得するスクレイピングのような処理もあれば、外部のAPIを利用する処理などもあります。
特にAPIを利用する場合は、同期的な処理をしている時にはAPI側での処理が終了するまでプログラムは待機状態になるので、やはり処理は重くなります。
繰り返しになりますが、とにかく、メモリの外にアクセスする処理は基本的に重くなると理解しておいてください。
鉄則1. ループの中で重い処理をしてはいけない
パフォーマンスを意識したコーディングの一番の鉄則は、ループ内で重い処理をしないことです。
DBアクセスも、ファイルIOも、ネットワークを利用するプログラムも、ある程度の規模のシステムを作成する際にはどうしても必要になってくる処理です。
重いからと言ってそれらの処理をせずにやりたいことを実現するのは難しいでしょう。
ただ、それらの重い処理が実行される回数が多くなればなるほど、プログラム全体でパフォーマンスが低下してしまいます。
重い処理の実行回数を減らすためには、ループ処理の中で重い処理を実行しないことが鉄則です。
for, foreach, whileなどのループ文の中に書く処理の中に、重い処理は極力含めないようにしましょう。
ループの回数が最大でも数十回程度と少ない場合は問題はないかもしれませんが、千回以上のような、繰り返し回数が多くなる可能性のあるループ処理の中で、上記に触れた重い処理は実行してはいけません。
その分、プログラム実行にかかる負荷は増え、処理に時間がかかってしまいます。最悪の場合、タイムアウトエラーになったり、メモリが足りなくなってプログラムが終了してしまう可能性もあります。
メモリの外にアクセスするような重い処理を行う場合、ループの中では行わず、ループの外で1回だけ実行するように心がけでください。
特に、DBアクセスの場合、ループの中で実行しなければ実現できないようなことはあまり多くありません。
SQLをうまく活用して、一回の処理で完結できるよう工夫することを心がけましょう。
鉄則2. メモリを意識してプログラムを書く
ループ処理の中で重い処理を実行してはいけないと書きましたが、もう一つ、インスタンスの生成もループの中ではあまりやるべきではありません。
インスタンス生成は基本的にメモリ内で完結する処理なので、そこまで重い処理ではありません。
しかし、インスタンスの生成はメモリ領域にインスタンスを格納するだけの領域を確保するので、プログラムの中で生成するインスタンスが増えると、メモリ領域を圧迫してパフォーマンスに影響が出る場合があります。
特に、ループ処理の中でインスタンスを生成していると、ループの回数分インスタンスが生成されてしまうため、ループ回数が多くなればなるほどパフォーマンスに影響が出る可能性が出てきます。
特に、配列やコレクションなど、要素を複数持つようなインスタンスは、それだけ消費するメモリも多くなるので注意が必要です。
最近のプログラミング言語であれば、参照されないインスタンスは適当なタイミングでガーベジコレクションなどの機能が働いてそのうち破棄されるようになっています。
そのため、あまりメモリを意識せずともプログラミングができるようにはなっていますが、パフォーマンスを意識してコーディングする上では、プログラムがどのような仕組みで動いているのかある程度は知っておいた方が良いでしょう。
ループの中に限りませんが、プログラムを書く時は無駄に変数を定義したり、無駄にインスタンスを生成して必要以上にメモリを圧迫しないように注意しましょう。
鉄則3. データは必要なときに必要な分だけ取得する
DBアクセスで、特にデータの取得を行う場合の話になります。
ループ処理の中でDBアクセスの処理を書いてはいけないと先に述べました。
データを参照するために必要なテーブルが複数にまたがる場合、テーブルの結合などを行って一回のSQL発行でまとめて取得するのが理想です。
だからと言って、たくさんのテーブルをまとめて結合して、一回のDBアクセスで全てのデータを取得する必要はありません。
プログラムの中ではDBアクセスは少ない方が良いですが、逆にDBの中の処理に着目すると、テーブル結合はかなり重い処理になります。
必要のないテーブルも結合して不要なデータまで取得しようとすると、パフォーマンス低下につながる可能性があります。
例えば、1つの画面の中で3つのメニューがあるとします。
メニュー1ではテーブルA, B, Cのデータを表示する。
メニュー2ではテーブルD, E, Fのデータを表示する。
メニュー3ではテーブルG, H, Iのデータを表示する。
とします。
ここで、画面表示時にA~Iまで9つのテーブルのデータを全てまとめて取得するのは多くの場合賢明ではありません。
メニュー1、メニュー2、メニュー3をそれぞれを選択したタイミングで、表示に必要なデータのみを1回のDBアクセスで取得するのが理想です。
もちろん、この辺りはデータ量やテーブルのリレーション、画面の構成などにもよってベストは変わってくると思います。
Webアプリの場合はSPAなのか、そうでないかによっても変わるかと思いますが、
基本的には必要なタイミングで必要なデータをまとめて取得することが鉄則です。
SQLを駆使して少ないDBアクセスを実現する
ここからはDBアクセスの処理に特化して、具体的にループをせずに処理を実行する例を見ていきます。
DBアクセスの処理は、SELECTだろうがINSERTだろうがUPDATEだろうがDELETEだろうが、たいていの場合ループ処理の中で行わなくても実行可能です。
私が関わった案件がLaravelを使用した案件だったため、コードはLaravelのコードをベースとしたサンプルにしましたが、基本的な考え方は言語に依存しないと思います。
//////////////////////////////
// 複数件のデータをINSERTする場合
//////////////////////////////
// ループしながらINSERTする例
// 配列のデータ量が多ければ多いほどDBアクセスが増えてパフォーマンスが悪くなる
foreach($users as $user) {
$model = new User($user);
$model->save();
}
// 一括でINSERTする例
// 一回のDBアクセスで済むので基本はこっちの方が速い
DB::table('users')->insert($users);
//////////////////////////////
// usersをコピーして別のusers_backにINSERTする場合
//////////////////////////////
// ループしながら1件ずつINSERT。基本遅い。
$users = DB::table('users')->get();
foreach($users as $user) {
DB::table('users_back')->insert($user);
}
// まとめてINSERT。DBアクセスは少なく済む
$users = DB::table('users')->get();
DB::table('users_back')->insert($users);
// よりDBアクセスが減る。変数も不要なのでメモリも節約できる
DB::insert("insert into users_back select * from users");
思ったほど良いサンプルが思いつかない。。
思いつき次第追加していきます。。
正しいインデックス設計を
DBアクセスに関しては、プログラム側のアプローチとしては「SQLを駆使してDBアクセス回数を最小限にする」「必要なタイミングで必要なデータのみ取得する」を意識して開発すればよいかと思います。
ただ、プログラムをどれだけ洗練させたとしても、インデックス設計がうまくできていなければ速度が出ない場合も少なくありません。
インデックス設計については書くとそれだけで膨大な内容になるのでここでは割愛しますが、テーブルの検索条件と結合条件についてインデックスが正しく設計されているかはプログラムを書く上で意識しておきましょう。
鉄則4. 通信回数が少なくなるAPI設計をする
昨今のシステムでは外部のAPIを利用したシステムも当たり前となりました。
Webアプリケーションを作成する場合、フロントエンドとバックエンドを明確に切り分けて、バックエンドをAPIとして設計・実装するのも一般的になりました。
APIはネットワーク通信が発生するうえ、同期処理の中で実行しているとAPI側で処理が終わるまで待機状態になるので、APIにリクエストを投げる回数が増えれば増えるほどパフォーマンスには影響が出ます。
ループの中でAPIにリクエストを投げる処理を書かないのはもちろんのこと、システム全体としてリクエストを投げる回数が無駄に多くならないように、フロントエンドとバックエンドをうまく設計する必要があるでしょう。
Webの場合、Vue.jsやReactなどの技術を使ってSPAを実現することでシステム全体のユーザビリティを上げることが可能です。
しかし、中にはVue.jsなどの技術を使っていながら、画面遷移時に毎回画面全ての情報を取得しており、SPAが実現できていないシステムもあります。
SPAが簡単に実現可能な技術を導入しているのであれば、積極的に活用してシステム全体でのAPIによる通信回数を最小限にする設計をしましょう。
処理のジョブ化・スケジュール化を検討する
これまでに述べた鉄則を守ってコーディングをすれば、パフォーマンスにおいて致命的な問題はあまり起きないかと思います。
ただ、システムの都合上、どうしてもループの中で重い処理を繰り返し実行しなければいけない場面もあるかと思います。
例えば、メールを大量に一斉送信したり、プリンターでまとめて大量に印刷する処理など。
そういう場合は、処理を非同期処理にしてバックグラウンドで実行(ジョブ化)したり、リアルタイムで処理する必要のないものであればバッチ処理にしてスケジュール実行するという手段もあります。
これは実際には処理を切り分けて裏で実行されているだけなので、パフォーマンスという意味では改善とは言えないかもしれませんが、単純にユーザーから見た待機時間を短縮してユーザビリティを上げたいという場合には有効です。
今回私が関わった案件はLaravelを使った開発をしていましたが、Laravelにはキューやタスクスケジュールという機能があり、一部の処理ではこれらの機能を使ってユーザビリティの向上を図りました。
データ量の見積もりを忘れずに
今回私が経験したプロジェクトでは、先に述べたようなループ処理の中で重い処理が頻繁に行われていました。
その結果としてパフォーマンスが低下してしまったわけですが、これにはデータ量も大いに関係しています。
そのシステムでは、業務の都合とシステムの都合上、一度の更新処理でデータが数万件単位で出来上がることがあるようなシステムでした。
そのため、ループしながら1件1件更新する処理の場合、非常に時間がかかるようになっていました。
これが例えば、一度に更新されるデータ量が最大でも数百件程度であれば、そのままのロジックでも大きな問題にはならなかったかもしれません。
一度の処理で更新されるデータ量や、想定される最大のレコード数などを考慮した上で設計・実装を行うことが重要だと実感しました。
まとめ
パフォーマンスの問題を起こさないためには
- メモリ外にアクセスが必要な重い処理をループの中に書かない
- メモリ容量を圧迫しないプログラムにする
- DBアクセスを最小限に抑える
- インデックスを正しく設計する
- APIとの通信回数を最小限にする
- データ量を見積もったうえで設計・実装する
この辺りを意識しておきましょう。