web-dev-qa-db-ja.com

Contains()演算子がEntity Frameworkのパフォーマンスをそれほど劇的に低下させるのはなぜですか?

更新3: この発表 によると、これはEFチームによりEF6 alpha 2で対処されました。

更新2:この問題を修正するための提案を作成しました。それに投票するには、 ここに行く

1つの非常に単純なテーブルを持つSQLデータベースを考えてみましょう。

CREATE TABLE Main (Id INT PRIMARY KEY)

テーブルに10,000レコードを設定します。

WITH Numbers AS
(
  SELECT 1 AS Id
  UNION ALL
  SELECT Id + 1 AS Id FROM Numbers WHERE Id <= 10000
)
INSERT Main (Id)
SELECT Id FROM Numbers
OPTION (MAXRECURSION 0)

テーブルのEFモデルを作成し、LINQPadで次のクエリを実行します(LINQPadが自動的にダンプを作成しないように「C#ステートメント」モードを使用しています)。

var rows = 
  Main
  .ToArray();

実行時間は〜0.07秒です。次に、Contains演算子を追加して、クエリを再実行します。

var ids = Main.Select(a => a.Id).ToArray();
var rows = 
  Main
  .Where (a => ids.Contains(a.Id))
  .ToArray();

この場合の実行時間は20.14秒(288倍遅い)です!

最初は、クエリに対して発行されたT-SQLの実行に時間がかかっているのではないかと考えたため、LINQPadのSQLペインからSQL Server Management Studioにカットアンドペーストしようとしました。

SET NOCOUNT ON
SET STATISTICS TIME ON
SELECT 
[Extent1].[Id] AS [Id]
FROM [dbo].[Primary] AS [Extent1]
WHERE [Extent1].[Id] IN (1,2,3,4,5,6,7,8,...

そして結果は

SQL Server Execution Times:
  CPU time = 0 ms,  elapsed time = 88 ms.

次に、LINQPadが問題を引き起こしているのではないかと考えましたが、LINQPadで実行してもコンソールアプリケーションで実行してもパフォーマンスは同じです。

したがって、問題はEntity Framework内のどこかにあるようです。

ここで何か間違ったことをしていますか?これは私のコードのタイムクリティカルな部分なので、パフォーマンスを向上させるためにできることはありますか?

Entity Framework 4.1とSql Server 2008 R2を使用しています。

更新1:

以下の議論では、EFが最初のクエリを構築している間、または受け取ったデータを解析している間に遅延が発生したかどうかについていくつかの質問がありました。これをテストするために、次のコードを実行しました。

var ids = Main.Select(a => a.Id).ToArray();
var rows = 
  (ObjectQuery<MainRow>)
  Main
  .Where (a => ids.Contains(a.Id));
var sql = rows.ToTraceString();

これにより、EFはデータベースに対して実行せずにクエリを生成します。その結果、このコードの実行には約20秒が必要であったため、最初のクエリの構築にはほぼすべての時間がかかります。

CompiledQueryが救いに?それほど高速ではありません... CompiledQueryでは、クエリに渡されるパラメーターが基本型(int、string、floatなど)である必要があります。配列またはIEnumerableを受け入れないため、IDのリストに使用できません。

79
Mike

更新:EF6にInExpressionが追加されたことにより、Enumerable.Containsの処理パフォーマンスが劇的に改善されました。この回答で説明されているアプローチは不要になりました。

クエリの翻訳の処理にほとんどの時間が費やされているのは正しいことです。 EFのプロバイダーモデルには現在、IN句を表す式が含まれていないため、ADO.NETプロバイダーはINをネイティブにサポートできません。代わりに、Enumerable.Containsの実装は、それをOR式のツリーに変換します。つまり、C#では次のようになります。

new []{1, 2, 3, 4}.Contains(i)

...次のように表すことができるDbExpressionツリーを生成します。

((1 = @i) OR (2 = @i)) OR ((3 = @i) OR (4 = @i))

(1つの長いスパインですべてのORを使用すると、式ビジターがスタックオーバーフローを起こす可能性が高くなるため、式ツリーのバランスをとる必要があります(はい、テストで実際にヒットしました))

後でこのようなツリーをADO.NETプロバイダーに送信します。ADO.NETプロバイダーは、このパターンを認識し、SQL生成中にIN句に減らすことができます。

EF4にEnumerable.Containsのサポートを追加したとき、プロバイダーモデルにIN式のサポートを導入せずにそれを行うことが望ましいと考えました。正直なところ、10,000は、顧客が期待する要素の数よりもはるかに多いです。 Enumerable.Contains。とはいえ、これは面倒なことであり、式ツリーを操作すると特定のシナリオで物事が高すぎることを理解しています。

私たちの開発者の1人とこれについて話し合いましたが、将来的にはINに対する一流のサポートを追加することで実装を変更できると信じています。これがバックログに追加されることを確認しますが、他の多くの改善が必要であるため、いつ実行できるかを約束することはできません。

スレッドですでに提案されている回避策に、次を追加します。

データベースへのラウンドトリップの回数とContainsに渡す要素の数のバランスをとるメソッドを作成することを検討してください。たとえば、私自身のテストでは、SQL Serverのローカルインスタンスに対して100要素のクエリを計算して実行すると、1/60秒かかります。 100個の異なるIDセットで100個のクエリを実行すると10,000個の要素を持つクエリと同等の結果が得られるようにクエリを作成できる場合、18秒ではなく約1.67秒で結果を取得できます。

クエリとデータベース接続の待機時間に応じて、さまざまなチャンクサイズが適切に機能するはずです。特定のクエリ、つまり、渡されたシーケンスに重複がある場合、またはEnumerable.Containsがネストされた条件で使用されている場合、結果で重複する要素を取得する可能性があります。

ここにコードスニペットがあります(入力をチャンクにスライスするために使用されるコードが少し複雑すぎる場合はごめんなさい。同じことを達成するためのより簡単な方法がありますが、シーケンスのストリーミングを保持するパターンを考え出そうとしていました私はLINQでそのようなものを見つけることができなかったので、おそらくその部分を過剰にした:)):

使用法:

var list = context.GetMainItems(ids).ToList();

コンテキストまたはリポジトリのメソッド:

public partial class ContainsTestEntities
{
    public IEnumerable<Main> GetMainItems(IEnumerable<int> ids, int chunkSize = 100)
    {
        foreach (var chunk in ids.Chunk(chunkSize))
        {
            var q = this.MainItems.Where(a => chunk.Contains(a.Id));
            foreach (var item in q)
            {
                yield return item;
            }
        }
    }
}

列挙可能なシーケンスをスライスするための拡張メソッド:

public static class EnumerableSlicing
{

    private class Status
    {
        public bool EndOfSequence;
    }

    private static IEnumerable<T> TakeOnEnumerator<T>(IEnumerator<T> enumerator, int count, 
        Status status)
    {
        while (--count > 0 && (enumerator.MoveNext() || !(status.EndOfSequence = true)))
        {
            yield return enumerator.Current;
        }
    }

    public static IEnumerable<IEnumerable<T>> Chunk<T>(this IEnumerable<T> items, int chunkSize)
    {
        if (chunkSize < 1)
        {
            throw new ArgumentException("Chunks should not be smaller than 1 element");
        }
        var status = new Status { EndOfSequence = false };
        using (var enumerator = items.GetEnumerator())
        {
            while (!status.EndOfSequence)
            {
                yield return TakeOnEnumerator(enumerator, chunkSize, status);
            }
        }
    }
}

お役に立てれば!

65
divega

あなたがブロックしているパフォーマンスの問題を見つけた場合、あなたはおそらく成功しないので、それを解決するために何年も費やそうとしないでください。年齢。

パフォーマンスの問題とEFが直接SQLを意味する場合、回避策と回避策を使用します。悪いことは何もありません。 EFを使用する= SQLを使用しないというグローバルな考え方は嘘です。 SQL Server 2008 R2があります。

  • IDを渡すためのテーブル値パラメーターを受け入れるストアドプロシージャを作成する
  • ストアドプロシージャが複数の結果セットを返し、Includeロジックを最適な方法でエミュレートするようにします
  • 複雑なクエリの作成が必要な場合は、ストアドプロシージャ内で動的SQLを使用します
  • SqlDataReaderを使用して結果を取得し、エンティティを構築します
  • それらをコンテキストにアタッチし、EFからロードされたかのように操作します

パフォーマンスが重要な場合、より良い解決策は見つかりません。現在のバージョンはテーブル値パラメーターまたは複数の結果セットをサポートしていないため、このプロシージャはEFによってマップおよび実行できません。

24
Ladislav Mrnka

中間テーブルを追加し、Contains句を使用する必要のあるLINQクエリからそのテーブルに参加することで、EF Containsの問題を解決できました。このアプローチで素晴らしい結果を得ることができました。大規模なEFモデルがあり、EFクエリをプリコンパイルするときに "Contains"が許可されないため、 "Contains"句を使用するクエリのパフォーマンスが非常に低下していました。

概要:

  • SQL Serverでテーブルを作成します-たとえば、HelperForContainsOfIntType of HelperIDのデータ型とGuid of ReferenceIDのデータ型列を含むint 。必要に応じて、異なるデータ型のReferenceIDで異な​​るテーブルを作成します。

  • HelperForContainsOfIntTypeおよびその他のEFモデルのテーブル用のEntity/EntitySetを作成します。必要に応じて、異なるデータ型に対して異なるEntity/EntitySetを作成します。

  • IEnumerable<int>の入力を受け取り、Guidを返すヘルパーメソッドを.NETコードで作成します。このメソッドは、新しいGuidを生成し、生成されたHelperForContainsOfIntTypeとともにIEnumerable<int>の値をGuidに挿入します。次に、メソッドはこの新しく生成されたGuidを呼び出し元に返します。 HelperForContainsOfIntTypeテーブルに高速で挿入するには、値のリストを入力して挿入を行うストアドプロシージャを作成します。 SQL Server 2008(ADO.NET)のテーブル値パラメーター を参照してください。さまざまなデータ型に対してさまざまなヘルパーを作成するか、さまざまなデータ型を処理する汎用ヘルパーメソッドを作成します。

  • 次のようなEFコンパイル済みクエリを作成します。

    static Func<MyEntities, Guid, IEnumerable<Customer>> _selectCustomers =
        CompiledQuery.Compile(
            (MyEntities db, Guid containsHelperID) =>
                from cust in db.Customers
                join x in db.HelperForContainsOfIntType on cust.CustomerID equals x.ReferenceID where x.HelperID == containsHelperID
                select cust 
        );
    
  • Contains句で使用する値を指定してヘルパーメソッドを呼び出し、クエリで使用するGuidを取得します。例えば:

    var containsHelperID = dbHelper.InsertIntoHelperForContainsOfIntType(new int[] { 1, 2, 3 });
    var result = _selectCustomers(_dbContext, containsHelperID).ToList();
    
9
Dhwanil Shah

元の回答を編集する-エンティティの複雑さに応じて、可能な回避策があります。 EFがエンティティを生成するために生成するsqlを知っている場合は、 DbContext.Database.SqlQuery を使用して直接実行できます。 EF 4では、 ObjectContext.ExecuteStoreQuery を使用できると思いますが、試しませんでした。

たとえば、以下の元の回答のコードを使用してStringBuilderを使用してsqlステートメントを生成すると、次のことができました

var rows = db.Database.SqlQuery<Main>(sql).ToArray();

合計時間は約26秒から0.5秒になりました。

私はそれがいと言う最初の人になりますが、うまくいけばより良い解決策が現れることを願っています。

update

少し考えた後、結合を使用して結果をフィルター処理する場合、EFはIDの長いリストを作成する必要がないことに気付きました。これは、同時クエリの数によっては複雑になる場合がありますが、ユーザーIDまたはセッションIDを使用してそれらを分離できると思います。

これをテストするために、Targetと同じスキーマを持つMainテーブルを作成しました。その後、StringBuilderを使用してINSERTコマンドを作成し、Targetテーブルに1,000個のバッチでデータを入力します。これは、SQL Serverが単一のINSERT 。 sqlステートメントの直接実行は、EFを実行するよりもはるかに高速で(約0.3秒対2.5秒)、テーブルスキーマを変更すべきではないので問題ないと思います。

最後に、joinを使用して選択すると、クエリがより簡単になり、0.5秒未満で実行されました。

ExecuteStoreCommand("DELETE Target");

var ids = Main.Select(a => a.Id).ToArray();
var sb = new StringBuilder();

for (int i = 0; i < 10; i++)
{
    sb.Append("INSERT INTO Target(Id) VALUES (");
    for (int j = 1; j <= 1000; j++)
    {
        if (j > 1)
        {
            sb.Append(",(");
        }
        sb.Append(i * 1000 + j);
        sb.Append(")");
    }
    ExecuteStoreCommand(sb.ToString());
    sb.Clear();
}

var rows = (from m in Main
            join t in Target on m.Id equals t.Id
            select m).ToArray();

rows.Length.Dump();

そして、結合のためにEFによって生成されたSQL:

SELECT 
[Extent1].[Id] AS [Id]
FROM  [dbo].[Main] AS [Extent1]
INNER JOIN [dbo].[Target] AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id]

(元の回答)

これは答えではありませんが、いくつかの追加情報を共有したかったため、コメントに入れるには長すぎます。結果を再現することができましたが、他にもいくつか追加することがあります。

SQLプロファイラーは、最初のクエリの実行の間に遅延があることを示します(Main.Select)および2番目のMain.Whereクエリなので、問題はそのサイズ(48,980バイト)のクエリの生成と送信にあると思われました。

ただし、T-SQLで同じsqlステートメントを動的に作成するのに1秒未満かかり、idsMain.Selectステートメント、同じsqlステートメントを構築し、SqlCommandを使用して実行すると、0.112秒かかりました。これには、コンテンツをコンソールに書き込む時間が含まれます。

この時点で、EFはクエリを作成するときに10,000個のidsのそれぞれに対して何らかの分析/処理を実行していると思われます。私が決定的な答えと解決策を提供できればいいのに:(。

SSMSとLINQPadで試したコードは次のとおりです(厳しく批判しないでください。急いで仕事を辞めようとしています)。

declare @sql nvarchar(max)

set @sql = 'SELECT 
[Extent1].[Id] AS [Id]
FROM [dbo].[Main] AS [Extent1]
WHERE [Extent1].[Id] IN ('

declare @count int = 0
while @count < 10000
begin
    if @count > 0 set @sql = @sql + ','
    set @count = @count + 1
    set @sql = @sql + cast(@count as nvarchar)
end
set @sql = @sql + ')'

exec(@sql)

var ids = Mains.Select(a => a.Id).ToArray();

var sb = new StringBuilder();
sb.Append("SELECT [Extent1].[Id] AS [Id] FROM [dbo].[Main] AS [Extent1] WHERE [Extent1].[Id] IN (");
for(int i = 0; i < ids.Length; i++)
{
    if (i > 0) 
        sb.Append(",");     
    sb.Append(ids[i].ToString());
}
sb.Append(")");

using (SqlConnection connection = new SqlConnection("server = localhost;database = Test;integrated security = true"))
using (SqlCommand command = connection.CreateCommand())
{
    command.CommandText = sb.ToString();
    connection.Open();
    using(SqlDataReader reader = command.ExecuteReader())
    {
        while(reader.Read())
        {
            Console.WriteLine(reader.GetInt32(0));
        }
    }
}
5
Jeff Ogata

私はEntity Frameworkに精通していませんが、以下を行うとパフォーマンスが向上しますか?

これの代わりに:

var ids = Main.Select(a => a.Id).ToArray();
var rows = Main.Where (a => ids.Contains(a.Id)).ToArray();

これはどうですか(IDがintであると仮定):

var ids = new HashSet<int>(Main.Select(a => a.Id));
var rows = Main.Where (a => ids.Contains(a.Id)).ToArray();
5
Shiv

問題は、Entity FrameworkのSQL生成にあります。パラメータの1つがリストの場合、クエリをキャッシュできません。

EFにクエリをキャッシュさせるには、リストを文字列に変換し、文字列に.Containsを実行します。

そのため、たとえば、EFはクエリをキャッシュできるため、このコードははるかに高速に実行されます。

var ids = Main.Select(a => a.Id).ToArray();
var idsString = "|" + String.Join("|", ids) + "|";
var rows = Main
.Where (a => idsString.Contains("|" + a.Id + "|"))
.ToArray();

このクエリが生成されると、InではなくLikeを使用して生成される可能性が高いため、C#は高速になりますが、SQLが遅くなる可能性があります。私の場合、SQL実行のパフォーマンスの低下に気付かず、C#の実行速度は大幅に向上しました。

2
user2704238

Containsのキャッシュ可能な代替手段?

これは私を少しばかり痛めたので、2つのペンスをEntity Framework Feature Suggestionsリンクに追加しました。

問題は間違いなくSQLを生成するときです。クエリの生成は4秒でしたが、実行は0.1秒でした。

dynamic LINQ and ORsを使用すると、SQL生成は同じくらい時間がかかりましたが、cached 。そのため、再度実行すると0.2秒になりました。

SQL inはまだ生成されていることに注意してください。

最初のヒットに耐えることができるかどうかを検討するために何か他のものがありますが、配列の数はあまり変化せず、クエリを多く実行します。 (LINQパッドでテスト済み)

2
Dave