はじめに
最近業務で外部プロダクトのパフォーマンス改善を支援する機会があり、結果的に最初は3時間程かかったCSVアップロード機能を40分から1時間(データセットによる)短縮する事ができましたその際に実施した事を書き留めていこうと思います
ボトルネックはどこにあるのか?
Webアプリケーションを作っていてパフォーマンス改善が必要だと判明するのは
「一覧表示がめちゃくちゃ遅い!」などの改善依頼やレイテンシのアラート等、ざっくりしたところからボトルネックがどこにあるのか調査する事が多いかと思います
今回はCSVからユーザー情報をMySQLに作成するのがメインの処理でworkerを別プロセスで動かして非同期処理を実施していました
そのためアプリケーションサーバー(Laravel)またはDBサーバー(MySQL)にボトルネックがありそうだという想定はつきました
そしてさらに絞り込むために下記の2点を実施しました
- アプリケーションのコードを読んで処理内容を把握して書き出す
-
Aurora
の全般ログでどのくらいクエリが発行されているか確認する
結果INSERT
をする前に、20個以上SELECT
が走るような検索があり、クエリがボトルネックになっている事が分かったので今回クエリを修正する事になりました
実際にはスプレッドシートに遅いクエリを発行している箇所をソースコードベースで書き出し、どの対策を取れるかを⭕️❌で書きました
これで自分以外の人が見たとしても、現在何に取り組んでいるのかすぐに分かると思います
※この表が30行くらいに渡って書かれていて、完了したところはグレーアウトさせていく形になります
実施した事
1. 同じクエリ結果が必要な場合はキャッシュを活用する
例えば巨大なCSVをチャンクしていて、それぞれを処理する別プロセスで同じ値を使い回したい場合に Redis
、Memcached
、Dynamo
などのキーバリューストアにキャッシュをする事で何度も同じクエリを発行する必要はなくなります
また同じプロセス内ならインスタンス変数に持たせてしまうなどでもよりミニマムに同じ事が実現可能です
同じOrderのidで1時間以内ならキャッシュから使用する例
$order = Cache::remember($cacheKey, 3600, function () use ($id) {
return Order::find($id);
});
2. N+1問題を発生させない
N+1問題が発生している場合、DBアクセスが大量に発生するのでパフォーマンスが劣化しやすいです
N+1が発生する例
$orders = Order::all();
foreach ($orders as $order) {
$user = $order->user;
}
この場合、$orders
の数だけDBアクセスが発生するので下記のようにEagerLoading
をして、最初に必要な関連先情報をメモリにロードしておく事で大量のDBアクセスが発生する事は回避できます
$orders = Order::with('user')->get();
foreach ($orders as $order) {
$user = $order->user;
}
ただし、この場合関連先が膨大なデータ量を持っている場合、メモリ上に大量のデータをロードしないといけなくなります
この状態で大量のデータをEagerLoading
した場合、swapを起こしてパフォーマンスが劣化するか最悪OOM Killerなどでプロセス自体を強制的に殺されてしまう可能性もあります
そのため件数が増えてきた場合は
Order::with('user')->lazy(1000)->each(function ($order) {
$user = $order->user;
});
など1000件ずつのLazyLoading
などもバランスを見ながら検討するのが良いと思います
3. BulkInsertを行う
今回はINSERT
が個別に発行されていたので、それをBulkInsert
に置き換える事で毎回のトランザクション処理のオーバヘッドを削減できて、速度が向上しました
1件ずつINSERT
する例
foreach ($ordersData as $orderData) {
Order::create($orderData);
}
BulkInsert
する例
DB::table('orders')->insert($ordersData);
4. indexを適切に貼る
今回はexplain
を実際にテストした環境で実施しましたが、これを修正するよりはクエリ結果をキャッシュするなど、クエリの総量を減らす方が効果が高かったのでそこまでindexの調整は実施していません
ただ、データセットが大きくなるほどindexは効果が高まるので非常に重要な要素の一つだと思っています
かなり長くなってしまいそうなので今回は詳しく説明できないんですが、MySQLで使用されるB+treeインデックスの特徴を簡単に記載させていただきます
B+treeインデックスの特徴
- カーディナリティが高いデータの方が高速に検索できる(ユニークな値が多い)
- リーフノード同士がつながっているので範囲検索も効率良く実施できる
- insert,updateなどの際は再構築が行われるのでオーバヘッドが増える
- ソートされた状態で格納されるので、並び替え(order by)なども効率的に行える
- その他(まだまだあるかと思いますので)
終わりに
上記の事を実施して、最終的には処理時間の削減を実施する事ができました
今回実際に適応してみて効果が大きかったのは、キャッシュの利用とBulkInsertだったかなと思います
ただこれに関しては、どの様な検索を実施しているかなど実装にもかなり依存するかと思うのであまり参考にならないので実際に適応して頂いてどの様な結果が出るかを実施して頂くのが1番良いかと思ってます
またここに書いていない部分も結構あり、リファクタリングを同時に進めることによって実は不要だったクエリを見つける事ができたり、インフラ面ではCPU使用率やメモリ使用率を見たり等、色々と実施しましたがひとまずインパクトが大きいクエリチューニングに絞って紹介させて頂きました
自分としても非常に良い経験ができたなと思っていますが、まだまだ改善点はあるので引き続き頑張っていこうと思います
ここまで読んで頂いた皆様、ありがとうございました!