74
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

CSVアップロード機能の処理時間を1時間速くした話

Last updated at Posted at 2023-07-18

はじめに

最近業務で外部プロダクトのパフォーマンス改善を支援する機会があり、結果的に最初は3時間程かかったCSVアップロード機能を40分から1時間(データセットによる)短縮する事ができましたその際に実施した事を書き留めていこうと思います

ボトルネックはどこにあるのか?

Webアプリケーションを作っていてパフォーマンス改善が必要だと判明するのは
「一覧表示がめちゃくちゃ遅い!」などの改善依頼やレイテンシのアラート等、ざっくりしたところからボトルネックがどこにあるのか調査する事が多いかと思います

今回はCSVからユーザー情報をMySQLに作成するのがメインの処理でworkerを別プロセスで動かして非同期処理を実施していました
そのためアプリケーションサーバー(Laravel)またはDBサーバー(MySQL)にボトルネックがありそうだという想定はつきました

そしてさらに絞り込むために下記の2点を実施しました

  • アプリケーションのコードを読んで処理内容を把握して書き出す
  • Auroraの全般ログでどのくらいクエリが発行されているか確認する

結果INSERTをする前に、20個以上SELECTが走るような検索があり、クエリがボトルネックになっている事が分かったので今回クエリを修正する事になりました

実際にはスプレッドシートに遅いクエリを発行している箇所をソースコードベースで書き出し、どの対策を取れるかを⭕️❌で書きました
これで自分以外の人が見たとしても、現在何に取り組んでいるのかすぐに分かると思います

スクリーンショット 2023-07-18 12.34.33.png

※この表が30行くらいに渡って書かれていて、完了したところはグレーアウトさせていく形になります

実施した事

1. 同じクエリ結果が必要な場合はキャッシュを活用する

例えば巨大なCSVをチャンクしていて、それぞれを処理する別プロセスで同じ値を使い回したい場合に RedisMemcachedDynamoなどのキーバリューストアにキャッシュをする事で何度も同じクエリを発行する必要はなくなります
また同じプロセス内ならインスタンス変数に持たせてしまうなどでもよりミニマムに同じ事が実現可能です

同じ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使用率やメモリ使用率を見たり等、色々と実施しましたがひとまずインパクトが大きいクエリチューニングに絞って紹介させて頂きました

自分としても非常に良い経験ができたなと思っていますが、まだまだ改善点はあるので引き続き頑張っていこうと思います

ここまで読んで頂いた皆様、ありがとうございました!

74
28
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
74
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?