N+1問題の回避
N+1問題の回避方法は簡単で、遅延ローディングを使わず、使用するテーブルをあらかじめ宣言することです。LINQではIncludeというメソッドで、まとめて読み込むテーブルを指定できます(リスト7)。
using System.Data.Entity; //ラムダ式版Includeメソッド用 ・・・ //あらかじめ読み込むテーブルを指定する products = context.Products.Where(x => x.Price < 300) .Include(x => x.Employee.Department).Include(x => x.Maker);
ここでは、「Productsテーブルから関連するEmployeesテーブル(からさらにDepartmentsテーブル)とMakersテーブルまで読み込む」と指定しています。(厳密には、「テーブル名」ではなく、読み込む「プロパティ名」をラムダ式で指定しています)。
実行結果はリスト8のように、Productsテーブルから必要な3テーブルにJOINをかけたSQLとなります。少し複雑なSQLとなり、実行時間も長くなりますが、このSQL1回だけで必要な情報を取得できますので、N+1問題は発生せず、総合的なパフォーマンスは向上することでしょう。
SELECT [Extent1].[Id] AS [Id], [Extent1].[Name] AS [Name], [Extent1].[Price] AS [Price], [Extent2].[Id] AS [Id1], [Extent2].[Name] AS [Name1], [Extent2].[Birthday] AS [Birthday], [Extent3].[Id] AS [Id2], [Extent3].[Name] AS [Name2], [Extent4].[Id] AS [Id3], [Extent4].[Name] AS [Name3] FROM [dbo].[Products] AS [Extent1] LEFT OUTER JOIN [dbo].[Employees] AS [Extent2] ON [Extent1].[Employee_Id] = [Extent2].[Id] LEFT OUTER JOIN [dbo].[Departments] AS [Extent3] ON [Extent2].[Department_Id] = [Extent3].[Id] LEFT OUTER JOIN [dbo].[Makers] AS [Extent4] ON [Extent1].[Maker_Id] = [Extent4].[Id] WHERE [Extent1].[Price] < 300 -- 2014/10/26 1:55:45 +09:00 で実行しています -- 42 ミリ秒で完了しました。結果: SqlDataReader
なお、Includeメソッドは複数の定義があり、読み込む対象を文字列で指定することも可能です(リスト9)。
//読み込み対象を文字列で指定するInclude。これは正直イケてない…… products = context.Products.Where(x => x.Price < 300) .Include("Employee.Department").Include("Maker");
しかし、関連するプロパティ名を文字列で持ってしまっては、統合言語クエリの旨味が薄れてしまいます(文字列になってしまってはIntelliSenseも効かないのです!)。ですので、前述のラムダ式で読み込むプロパティを指定するIncludeメソッドの使用を推奨します。なお、このラムダ式版Includeメソッドを使用するには、「using System.Data.Entity;」の記述が必要になりますのでご注意ください。
遅延ローディングの無効化
このように、N+1問題が起きる箇所では遅延ローディングを使用せず、Includeメソッドで関連するテーブルを読み込んでいく、というのが基本的な対策となります。しかし「Includeメソッドを書き忘れた時に自動的に遅延ローディングが起きてしまうのはイヤだ。N+1問題を起こすぐらいなら、そもそも遅延ローディングを切っちゃいたい」という要望もあるでしょう。デフォルトでは遅延ローディングは有効ですが、リスト10のように、コンテキストクラスのConfigurationプロパティの、LazyLoadingEnabledプロパティにfalseを設定することで、遅延ローディングを無効化できます。
//遅延ローディングを無効化 context.Configuration.LazyLoadingEnabled = false; //遅延ローディングを無効化した状態で、うっかりIncludeを忘れた products = context.Products.Where(x => x.Price < 300); //DumpProductsで別テーブルのデータにアクセスする時にNullPointerException発生…… DumpProducts(products);
当然ながら、遅延ローディングを無効化した状態で、必要なIncludeを忘れると、別テーブルのプロパティにアクセスしようとした時点でNullPointerExceptionが発生します。この辺りは諸刃の剣ですね。遅延ローディングの有効/無効化はコンテキストクラスのインスタンスごとに個別に指定できますので、必要に応じて使い分けが可能です。個人的には、遅延ローディングの恩恵は捨てがたいため、パフォーマンスが必要な箇所では遅延ローディングを無効化してIncludeで関連するテーブルを明示的に指定し、特に問題とならない箇所では遅延ローディングにお任せするようなコーディングを実践しています。遅延ローディングにはメリット・デメリットの双方がありますので、用法・容量を守ってお使いください。
なお、遅延ローディングの無効化はコンテキストクラスを生成してすぐに(最初のSQLを発行する前に)指定しないと無視されるようです。無効化する位置にご注意ください。
まとめ
本記事では、Entity Frameworkが発行するSQLを確認する方法と、遅延ローディングとN+1問題の微妙な関係について解説しました。幾らかLINQが脇役にそれた感はありますが、データベースプログラミングでLINQを活用する上では外せないポイントですので、しっかり抑えておきましょう。次回は「SQLに変換されるモノと変換されないモノ」と題して、どんなLINQがSQLに変換されるのか、もう少し解説します。