業務でサーバサイドやバッチ系アプリを作成する際に重要なのがパフォーマンス。最初はテストを通すために3~5件程度をテストデータで準備するので留意しないのですが、結合テスト以降に本番データを投入する時に言われてしまう「遅い」「1日流し続けても終わらない」
今回サーバサイドを作る上で実際に動作の遅さを目の当たりにしてしまったので、どう対応したかをメモの意味も含めて残しておきたいと思います。
目次
- 1 ToList ではなくToArrayを使う
- 2 AutoDetectChangeEnabledをOFFにする
- 3 1対多のテーブルで多のデータをコピーや移行する時はループで回したりNew List<xxxxxx>をするよりSelect+Fromでコピーさせる
- 4 入力ファイルが大量件数の時は Chunkを使って分割する
- 5 リードするDBの大量件数処理はPagning+Chunkを使って分割する
- 6 Parallel.For/ Parallel.Foreachを使い、ループ処理そのものを並列化する
- 7 EntityFrameworkに付随されているSQLを叩く機能を使う
- 8 何件か処理した後DBを接続しなおしてメモリを解放する
- 9 EntityFrameWork拡張ツールをインストールしてBulkInsertを用いる
- 10 まとめ
ToList ではなくToArrayを使う
これは劇的な改善にはなりませんでしたが、ToListよりもToArrayを使った方が性能が良いです。
注意点としてはDBの結果をループで回す時に終了条件を件数で回す時はListと配列では定義がちょっと違うのですがそこだけ気をつければどちらでも今後の処理に対応できたりします。
AutoDetectChangeEnabledをOFFにする
EntityFrameworkにはSQL文を直接打たなくても、DeleteもInsertもUpdateもやってくれる便利機能ですが、これらを一度に大量に行うと、かなりパフォーマンスが落ちます。操作は楽ですがその分、裏で複雑なものが動いているようです。
このフラグをOFFにするとUpdateをするときはDB毎に細かい設定をしなくてはいけなくなりますがパフォーマンスがよくなります。
LINQ の INSERT が遅いときは AutoDetectChangesEnabled を False にする
1対多のテーブルで多のデータをコピーや移行する時はループで回したりNew List<xxxxxx>をするよりSelect+Fromでコピーさせる
こちらはDB間でデータの移行したり、複製したりする時にパフォーマンスを改善する手段です。通常複数のデータを複製したりするときは下記のようにnewし直したり中のFK等のデータだけ変えたい時にループで回して設定したりするのが通常ですが、Select+Fromを使って使う側はコードも実際の処理時間も若干短縮させることができます。
下記ソースは実際の環境で打ったものではなく、説明のためにイメージで打っていますのであらかじめご了承ください。(構文ミス・スペルミスありそう)
修正前
IQueryable copymoto = dbcontext.entity().AsNoTracking; 1. var copysaki = new List<Entity>(copymoto); 2. List<Entity> copysaki = new List<Entity>(); foreach(var item in copymoto) { copy = new Entity() { id = guid(), Attribute = item.Attribute, 省略 }; copysaki.Add(copy); }
修正後
IQueryable copymoto = dbcontext.entity().AsNoTracking; var copysaki = copymoto.select( x => From(copymoto)); 省略 } // 別のメソッド private Entity From(Entitycopymoto) { id = guid(), Attribute = copymoto.Attribute, 省略 }
入力ファイルが大量件数の時は Chunkを使って分割する
入力ファイルが大量にある場合、単純にその件数をループさせようとするとメモリを大量に消費してしまいます。それを軽減するために例えば10万件のデータを3万件ずつ処理をさせてパフォーマンスを向上させる手段があります。それがChunk処理と呼ばれるものです。
このChunk処理はC#じゃなくともLaravel等、別のフレームワークでも応用されている技術ですので使用用途は広いでしょう。
リードするDBの大量件数処理はPagning+Chunkを使って分割する
一個前のChunk処理は主に入力ファイルを制御する時に用いる分割方法です。では、DBから大量データを取ってきた時はどうすればよいのでしょうか?通常にSELECTしてしまうと全データを取得してしまうため、工夫する必要があります。
この時に用いられるのがPaginatedListを使ったページングです。
パート 3、ASP.NET Core の Razor ページと EF Core – 並べ替え、フィルター、ページング
上の例の PaginatedList.csの記述を見ればよいのですが、下のGitHubを見るとコードがそのまま載っているので流用することができます。
どういった処理かと言いますと全体の中から{pageSize}件をpageIndex毎に取得する。といった処理になります。
例えばトータルが10万件でChunk(pagesizeの事)が25000件だとすれば最初は1件目~25000件目。次は25001件目~50000件目といったようにリード結果を分割して返してくれるものとなっています。
これをまとめて10万件、処理し続けるよりもメモリ消費が抑えられてパフォーマンスが向上するというわけです。もちろん例にもあるようにASP.NETで検索結果を表示している処理のページングにも応用できますね。
Parallel.For/ Parallel.Foreachを使い、ループ処理そのものを並列化する
こちらは例えばリード後の大量データをファイルに出力したりするときに使います。とはいえ、並列処理ということでソートをきちんとして出したい時にはもしかしたら使用できないかもしれません。こちらについては以下のリンクにとても詳しく書かれているので参照してみてください。
(C#)Parallel.For, Parallel.ForEach並列処理の挙動確認
このParallelを使用したループできるのはあくまで出力/表示時に有効でDB書き込みに使おうとするとエラーになってしまいましたので用途限定なのが残念です。
EntityFrameworkに付随されているSQLを叩く機能を使う
大量の子テーブルのデータも同時に取りたい場合は個々にEntityFrameworkを使った取得をしているとループも必要になってとても処理が重くなってしまいます。こういう時はEFCore搭載のFromSqlRawを使う事で直接SQLを打ってデータを取ってくることができます。
何件か処理した後DBを接続しなおしてメモリを解放する
こちらは大量データでDB書き込み続けるとメモリを解放してくれず、どんどん重くなっていく現象です。なのでChunkと組み合わせて区切り毎にDispose→DB再接続をしてあげるとメモリを占有しすぎず、パフォーマンスが向上します。
EntityFrameWork拡張ツールをインストールしてBulkInsertを用いる
この EntityFrameWork Coreの拡張ツールとしてZ.EntityFramework.Extensions.EFCoreがありますが、これを使用すればDBの保存形式にBulkInsertが使用できます。参考にしたのは下記の記事です。→ 2022/2/10確認したらリンク先が消滅していました
SQL Bulk Insertオペレーション – Entity FrameworkのBulkInsertよりずっと速くする方法
これも試したのですが、単体のテーブルの登録には良さそうですが、SaveChanges()で子テーブルの挿入も実現したい場合は子テーブルの登録ができませんでした。
リンク先のサイトはEntityFrameworkやZ.EntityFramework.Extensions.EFCore を使うよりもSqlBulkCopyを使った方が早かったですよ。という内容でした。
まとめ
SqlBulkCopyはEFCoreではないので今回は検証対象外にしましたが、Insertするなら一番早いそうです。
後はDB側の基本としてFK等、PK以外のデータをWhere句で指定して大量のデータを取得したい時はFKにインデックスを貼る等の考慮をしましょう。
これらを留意すれば1000万を超える子データが複数存在するという状況でない限りはかなりのパフォーマンス改善の期待ができるのではと思います。
環境的には親データ10万→子データ100万→孫データ100万で行った結果が2日以上かかって終わらなかったものが上記対策を入れたことによって3時間20分で終了することができたという結果です。今回はあくまで実務に基づいた自分用のメモレベルで残す記事なのでもっとうまい方法があったりはあるかもしれません。