BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル ORMのパフォーマンス最適化

ORMのパフォーマンス最適化

ブックマーク

原文(投稿日:2011/03/07)へのリンク

パフォーマンスで苦い経験をしたせいで、ORM (オブジェクト-リレーショナルマッパー)を使うのを嫌っている開発者もいる。抽象化と同様、ORMを利用すると何かしらのオーバーヘッドはあるのだが、うまくやれば手書きのデータアクセスコードと張り合うことも可能だ。その上、パフォーマンスの最適化や微調整も簡単になる。手書きのアクセスコードではこうはいかず、もっと労力がかかるだろう。

この記事では、Mindscape LightSpeed ORMを使って、よく見かける問題とその解決策について見ていく。単純な販売および配送のためのWebアプリを題材として、そのパフォーマンスをどうやって改善していくか見ていく。

N+1問題

遅延注文のリストについて見ていこう。これは簡単だが、各注文の顧客についても調べたい。これも一見すると簡単なように見える。

var overdues = unitOfWork.Orders.Where(o => o.DueDate < today);
foreach (var o in overdues) // 1
{
  var customer = o.Customer; // 2
  DisplayOverdueOrderInfo(o.Reference, customer.Name); 
}

このコードにはN+1問題として知られる、よく見かける問題が隠れている。ここでは遅延注文を取得するために(コードの1の部分)、データベースをクエリしている。しかし、各注文の顧客を取得するところでも(コードの2の部分)、さらにデータベースのクエリが発生しているのだ! その結果、遅延注文が100件あると、101回のデータベースクエリを実行することになる。最初の1回は遅延注文の集合を取得するため、続く100回はそれぞれの顧客を取得するためだ。N件の遅延注文に対して、N+1回のデータベースクエリを実行することになる。これがその名の由来だ。

これは明らかに遅く非効率だ。この問題はEager Loadingというテクニックを使うことで解決できる。もし最初の注文クエリの一部として後で必要となる顧客とのアソシエーションをすべてロードしておけば、Customerに対するアクセスはただのプロパティへのアクセスになる。こうすれば後のデータベースクエリは不要になり、N+1問題も起こらない。

LightSpeedでは、そのEager Load設定をTrueに変更することで(もしくは手書きコードのエンティティにEagerLoadAttributeを適用することで)、アソシエーションのEager Loadを可能にする。Eager Loadされるアソシエーションをもつエンティティをクエリすると、LightSpeedは追加のSQLを生成して、‘プライマリ’エンティティと同じように関連したエンティティを取得する。

Order.CustomerアソシエーションにEager Loadを適用すると、LightSpeedはOrderエンティティにクエリを発行するとき、OrderエンティティとCustomerエンティティの両方を取得するSQLを構築し、同じバッチで二つのSQL SELECT文を送る。スイッチをオンにするだけで、データベースとのN+1回のやり取りが1回に削減できる。

ORMのリフレクションと手書きのデータアクセス

これはなぜORMにパフォーマンス上のメリットがあるかを説明している。遅延注文のページが手書きのSQLで書かれており、データ層からオブジェクトへ手動で値をコピーしているとしよう。N+1問題が現れると、あなたは自作のSQLをアップデートするだけでなく、複数の結果セットを処理してアソシエーションを管理するマッピングコードもアップデートする必要がある。単純な例であれば、たいしたことはないが、もしページが連鎖したテーブルに存在するデータを使っているとどうだろう? 単にオプションを切り替えたり属性を適用したりするよりも、もっとたくさんの作業が必要になる! ORMを使うということは、最小限の労力で最適化できるということだ。

Lazy Loading

遅延注文ページは潜在的な問題をもう1つ抱えている。Customerエンティティに大きな画像ファイルを含むPhotoプロパティがあるとしよう(販売部に機能要求させると、これが必要になるだろう)。遅延注文ページにはCustomer.Photoプロパティは必要ないのだが、とにかく他のCustomerエンティティといっしょにロードされる。写真が大きければ、たくさんのメモリを費やすし、データベースから取得するのにも時間がかかるだろう。これは無駄だ。

これに対する解決策はPhotoプロパティを遅延ロード(lazy loaded)することだ。つまり、Customerエンティティの一部としてロードするのではなく、アクセスされたときにロードするのだ。遅延注文ページではPhotoプロパティにアクセスしないので、そのプロパティがあってもコストにはならない。写真を必要とする顧客プロファイルなどのページでは、そのままプロパティにアクセスできる。

プロパティをLazy Loadするための単純なフラグはない。その代わりに、名前をプロパティのAggregates設定に入れることで、そのプロパティをNamed Aggregatesの一部としてマークすることができる。これが何を意味するかについては次のセクションで説明する。Named Aggregatesの一部であるプロパティは、デフォルトで遅延ロードされる。

したがって、PhotoプロパティのAggregatesに“WithPhoto”が設定されていれば、遅延注文ページではロードされず、無駄なメモリを回避し、データ量を削減し、速度を改善できる。

Named Aggregate (Include)

N+1問題と巨大なプロパティの問題に対する解決策により、遅延注文ページはかなり軽快になった。しかし、これは他のページに影響を及ぼすおそれがある。たとえば、Orderをロードして関連する情報を表示する注文詳細ページについて考えてみよう。このページには必要のないOrder.CustomerまでEager Loadされてしまう。CustomerをEager Loadすることで非効率になるページがあるようだ。

理想的には、Order.Customerアソシエーションは遅延注文ページではEager Loadされるべきだが、注文詳細ページではLazy Loadされるべきだ。これはNamed Aggregateの一部にすることで実現できる。

Named Aggregateでは異なるページには異なるオブジェクトグラフが必要であることを知っている。Named Aggregateとは条件付きでEager Loadするアソシエーションとプロパティの集合のことだ。任意のクエリにおいて、それを要求したクエリではEager Loadされ、そうでないクエリではLazy Loadされる。(‘Named Aggregate’というのはLightSpeedの用語だ。‘Includes’という似たような機能を提供しているORMもある)

Order.CustomerをNamed Aggregateの一部にするために、まずそのEager Load設定をオフに戻す。これで注文詳細ページは幸せになる。次に遅延注文ページの効率を上げるために、Order.CustomeのAggregatesボックスにアグリゲート“WithCustomer”を追加する。

それから遅延注文ページにおけるOrdersクエリにWithCustomerを指定するよう修正する。LINQクエリにWithAggregateプロパティを追加するのは簡単なことだ。

var overdues = unitOfWork.Orders
                         .Where(o => o.DueDate < today)
                         .WithAggregate("WithCustomer");

同様のことは個々のプロパティにも当てはまる。Customer.Photoプロパティのロードを遅延するために、それを“WithPhoto”をアグリゲートの一部にしたことを思い出そう。しかし、これは必ず写真を要求する顧客プロファイルページでは非効率だ。WithAggregate("WithPhoto")を顧客プロファイルページのCustomerのクエリに追加することで、再びその効率を上げることができる。

Named Aggregateはあなたに大きな力を与えてれる。潜在的に複雑なEager Loadグラフの詳細を単純な文字列に隠蔽してくれる。パフォーマンス要求に応じて、非常にコストのかかる、もしくは頻繁に使われるページでアグリゲートを調整することで、かなりチューニング可能だ。

バッチ

注文入力ページについて考えてみよう。注文にはリファレンス番号などOrderレベルのプロパティだけでなく、OrderLineの集合も含まれる。ユーザが注文入力ページに入力すると、アプリケーションはOrderエンティティといくつかのOrderLineエンティティを作って、これらをすべてデータベースに挿入する必要がある。

これはN+1問題と少し似ている。注文に100行あると、101回の挿入を実行する必要がある。しかし、データベースと101回のやり取りを実行するのは我々の望むことではない!

LightSpeedではこの問題を、バッチで解決する。バッチとは、それぞれのINSERT(あるいはUPDATEやDELETE)を別々のコマンドとして送る代わりに、LightSpeedが10個までを1つのバッチにまとめて、それを1つのコマンドとして送ることだ。この結果、巨大なアップデートが必要なときも、ばか正直にやるのと比べて10分の1のやり取りで済む。

これのよいところは、バッチを活用するのに何も必要ないことだ。LightSpeedは自動的にCUD操作をバッチにしてくれるので、注文入力ページは自動的に高速な永続化の恩恵を得ることができる。

ファーストレベル・キャッシュ

さて、このアプリでセキュリティをどう実装するか見ていこう。Userエンティティがあるとする。このUserエンティティはパーミッションを管理しており、ユーザ名や各種ウィジェットのデータ表示に影響を与えるプリファレンスのような属性もある。この場合、ページのさまざまな場所から現在のUserをロードする可能性がある。パーミッションをチェックするためのコントローラ、名前を表示するバナー、表示プリファレンスを決定するデータウィジェットなどだ。しかし、これは非効率だ。もしUserエンティティがすでにメモリ上にあれば、データベースに再クエリするよりも既存のエントリーを再利用した方が高速だろう。

MVCアーキテクチャのような設計改善により、この問題を軽減できる場面もあるが、より一般的な解決策はファーストレベル点キャッシュを実装することだ。LightSpeedは作業ユニットパターンを中心に構築されており、UnitOfWorkクラスがファーストレベル・キャッシュを提供する。したがって、推奨される「リクエスト毎の作業ユニット」パターンに従ったアプリケーションは、自動的にページリクエストをスコープとするファーストレベル・キャッシュが得られる。つまり、ページリクエストの間、もしIDでクエリしたときに – 遅延ロードされた関連のトラバースのような暗黙のクエリを含む – 作業ユニットがすでにそのIDのエンティティをもっていれば、LightSpeedはデータベースクエリをせずに既存のエンティティを返す。これ以上に高速なものはない!

多くのフルスペックのORMでは似たような機能を導入している。たとえば、NHibernateセッションオブジェクトにはファーストレベル・キャッシュが含まれている。しかしながら、これは多くのマイクロORMが失敗している領域だ。それらは効率的なエンティティのマテリアライゼーションにのみフォーカスしているためだ。フルスペックのORMでは、クエリ時にできる限り効率であろうとしながらも、必ずしも最初の場所でクエリしないようスマートであろうとする。

ファーストレベル・キャッシュは自動で行われる。Userエンティティは作業ユニット(すなわちページリクエスト)の間、自分でアクションすることなく再利用できる。

セカンドレベル・キャッシュ

この注文管理システムでは複数の通貨を扱う必要があるとしよう。注文はドルやユーロ、円で行われる。通貨をわかりやすく表示するために、各通貨に関する情報を少しストアしておく必要があるだろう。たとえば、その名前(米ドル)やコード(USD)、シンボル($)などだ。特に問題はない。Currencyエンティティを定義していこう!

Eager Loadとファーストレベル・キャッシュによって、すでにかなり効率はよいのだが、それでもまだ少し無駄がある。ページリクエスト毎に作業ユニットがあってファーストレベル・キャッシュはその作業ユニットを対象としているため、アプリケーションは通貨にかかわるリクエスト毎にCurrencyテーブルをクエリすることになる。しかし、通貨定義はリファレンスデータだ。それはほとんど変わることはない。実際のところ、ページリクエスト毎に最新の詳細についてデータベースをクエリする必要はない。一度クエリしたら、そのリファレンスデータをどこかにキャッシュして、ページリクエスト間で共有できた方がずっと効率がよいだろう。

ここでセカンドレベル・キャッシュが登場する。LightSpeedのセカンドレベル・キャッシュは単一のUnitOfWorkよりも長期間存続することができ、ここにストアされたエンティティは好きなだけ保持しておけるのだ。(実際にどれくらい保持しておくかは、expiry設定を使うことで設定できる)。LightSpeedにはASP.NETキャッシュや、オープンソースのmemcachedライブラリを使った複数のサーバにまたがって使える強力なキャッシュの実装が含まれている。セカンドレベル・キャッシュを提供しているORMはほかにもあるが、多くのORMは提供していない。

LightSpeedにCurrencyエンティティをセカンドレベル・キャッシュとしてキャッシュさせることで、データベースにクエリすることなく、いつでもそれらを利用できる。そのためには、まずキャッシュ実装を選択して設定に指定する必要がある。そのあとは簡単で、Currencyエンティティをキャッシュするには、Currencyエンティティを選択してCachedオプションをTrueにすればよい。

コンバイル済みクエリ

具体的な利用パターンを紹介しながら、パフォーマンス改善のためのいろいろなテクニックを見てきたが、まだ取り上げていないのが、C# LINQ表現とLightSpeedが最終的にデータベースに送信するSQLとの間の変換オーバーヘッドだ。このオーバーヘッドはコード上のクエリ毎に影響を与える。一般的にデータベースクエリの実行コストと比べればわずかではあるが、パフォーマンスの最後の一滴を絞り出す必要が本当にあるのなら、そのコストをなくすために取り組む価値があるかもしれない。この点では手書きのSQLに傾きたくなるかもしれないが、LightSpeedを含む最近のORMを使うと、変換オーバーヘッドなしにLINQの便利さを享受できる。

変換オーバーヘッドを取り除くためにLightSpeedが採用している方法は、コンパイル済みクエリを使うことだ。コンパイル済みクエリは通常のLINQクエリから構築される。これはLINQクエリを実行可能形式に変換して、その実行可能形式を保存する。こうすることで、毎回再変換する必要はなくなり、何度もクエリを再実行することができる。自分でSQLスクリプトを書いたりメンテする必要なしに、素のSQLのパフォーマンスが得られるのだ。

実際のところ、直感に反して、コンパイル済みクエリは手書きのSQLよりも高速になり可能性がある。手書きのSQLを実行した場合、LightSpeedは結果セットの形式について想定できないためだ。LightSpeedがコンパイル済みクエリを実行した場合には、結果セットの形式について想定可能だ。というのも、LightSpeed自体がSQLを構築したためだ。これはコンパイル済みクエリからエンティティをマテリアライズするときの最適化を可能にする。

コンパイル済みクエリというのは、これまで見てきたテクニックよりも多少わずらわしい。(これをわずらわしくなくすテクニックを研究しているORMベンダーもある。)なぜなら、コンパイル済みクエリをどこかにストアしておく必要があるためだ。また、コンパイル中に変数のままだったパラメータしか変えられないため、コンパイル済みクエリの実行には別のAPIも必要になる。

顧客の注文履歴ページに必要になるような、顧客注文のクエリについて見ていこう。

int id = /* どこかから顧客IDを取得 */;
var customerOrders = unitOfWork.Orders.Where(o => o.CustomerId == id);

このクエリが何度も実行されることが想定でき、そこから最大限のパフォーマンスを絞り出す必要があるなら、あなたはCompile()拡張メソッドを使ってそれをコンパイルすることができる。このときには、ローカル変数idをクエリの実行時に埋められるパラメータに置き換える必要がある。コンパイルは次のようになる。

var customerOrdersQuery = unitOfWork.Orders.Where(o => o.CustomerId == CompiledQuery.Parameter("id")).Compile();

見るとわかるように、ローカル変数idをCompiledQuery.Parameter(“id”) で置き換え、Compile()拡張メソッドを呼び出している。resultsはCompiledQueryオブジェクトになる。通常はこれを長期生存するオブジェクトやスタティッククラスのメンバーとしてストアしておく。そうすれば、次のようにしてCompiledQueryを実行できる。

int id = /* どこかから顧客IDを取得 */
var results = customerOrdersQuery.Execute(unitOfWork, CompiledQuery.Parameters(new { id }));

(もし本当に決意が固いなら、この記事に言及されているように、最高のパフォーマンスが得られるようパラメータ値の解像度を調整できる)

まとめ

多くの開発者はオブジェクト-リレーショナルマッパーをパフォーマンスと引き換えに便利さを得るものだと見なしている。しかし最近のORMには、手書きのデータアクセス層で実装するのが難しいEager Loadやバッチのようなテクニックを組み込んだものもある。こうしたテクニックとともにORMを使うことで、手書きのDALと同等のパフォーマンスを、複雑なSQLや手書きのマッピングコードを習得したりメンテしなくても実現できるということだ。複数の結果セットを処理するようSQLをネストした自作のSELECTとマッピングコードに変更するよりも、スイッチを切り替えたりマッピングを調整することでN+1問題を修正する方が簡単だ。

すべてのORMがこの記事で説明した機能を提供しているわけではないが、最近のORMは少なくともそのいくつかを提供している。重要なことは、アプリケーションのどこにデータベース関連のボトルネックがあるのかを理解することだ。この記事にあるテクニックとそれらをサポートしたORMを使うことで、こうしたボトルネックの多くを解消でき、最小限のコストとリスクで最大限のパフォーマンスが得られるのだ。

著者について

John-Daniel Trask氏は全世界で何千もの顧客をかかえる開発者主導のコンポーネントベンダー、Mindscapeの共同創業者。John-Daniel氏はASP.NETのMicrosoft MVPであり、20年のソフトウェア開発経験がある。

 

 

この記事に星をつける

おすすめ度
スタイル

BT