¿Por qué el operador Contains () degrada el rendimiento de Entity Framework tan drásticamente?

79

ACTUALIZACIÓN 3: De acuerdo con este anuncio , esto ha sido abordado por el equipo EF en EF6 alpha 2.

ACTUALIZACIÓN 2: He creado una sugerencia para solucionar este problema. Para votar por él, vaya aquí .

Considere una base de datos SQL con una tabla muy simple.

CREATE TABLE Main (Id INT PRIMARY KEY)

Completo la tabla con 10,000 registros.

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)

Construyo un modelo EF para la tabla y ejecuto la siguiente consulta en LINQPad (estoy usando el modo "C # Statements" para que LINQPad no cree un volcado automáticamente).

var rows = 
  Main
  .ToArray();

El tiempo de ejecución es de ~ 0,07 segundos. Ahora agrego el operador Contiene y vuelvo a ejecutar la consulta.

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

¡El tiempo de ejecución para este caso es 20,14 segundos (288 veces más lento)!

Al principio sospeché que el T-SQL emitido para la consulta tardaba más en ejecutarse, así que intenté cortarlo y pegarlo desde el panel SQL de LINQPad en 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,...

Y el resultado fue

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

Luego sospeché que LINQPad estaba causando el problema, pero el rendimiento es el mismo si lo ejecuto en LINQPad o en una aplicación de consola.

Entonces, parece que el problema está en algún lugar dentro de Entity Framework.

¿Estoy haciendo algo mal aquí? Esta es una parte de mi código en la que el tiempo es crítico, entonces, ¿hay algo que pueda hacer para acelerar el rendimiento?

Estoy usando Entity Framework 4.1 y Sql Server 2008 R2.

ACTUALIZACIÓN 1:

En la discusión a continuación, hubo algunas preguntas sobre si el retraso ocurrió mientras EF estaba construyendo la consulta inicial o mientras analizaba los datos que recibió. Para probar esto, ejecuté el siguiente código,

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

lo que obliga a EF a generar la consulta sin ejecutarla en la base de datos. El resultado fue que este código requirió ~ 20 segundos para ejecutarse, por lo que parece que se toma casi todo el tiempo para construir la consulta inicial.

CompiledQuery al rescate entonces? No tan rápido ... CompiledQuery requiere que los parámetros pasados ​​a la consulta sean tipos fundamentales (int, string, float, etc.). No acepta matrices o IEnumerable, por lo que no puedo usarlo para una lista de ID.

Miguel
fuente
1
¿Ha intentado var qry = Main.Where (a => ids.Contains(a.Id)); var rows = qry.ToArray();ver qué parte de la consulta se está demorando?
Andrew Cooper
no es el EF lo que degrada su consulta, es la consulta real que está intentando ejecutar; ¿Podría explicar lo que está tratando de hacer? tal vez haya un mejor enfoque para sus necesidades
Kris Ivanov
@AndrewCooper Acabo de probarlo y, debido a la ejecución diferida, la primera declaración (sin ToArray) se ejecuta casi instantáneamente. La consulta, incluido el filtrado Contiene, en realidad no se ejecuta hasta que ejecuta ToArray ().
Mike
5
Solo y actualice sobre esto: EF6 alpha 2 incluye una mejora que acelera la traducción de Enumerable.Contains. Vea el anuncio aquí: blogs.msdn.com/b/adonet/archive/2012/12/10/… . Mis propias pruebas muestran que traducir list.Contains (x) para una lista con 100,000 elementos int ahora toma menos de un segundo, y el tiempo crece aproximadamente de forma lineal con el número de elementos en la lista. ¡Gracias por tus comentarios y por ayudarnos a mejorar EF!
divega
1
Tenga cuidado con esto ... las consultas con cualquier parámetro IEnumerable no se pueden almacenar en caché, lo que puede causar efectos secundarios bastante graves cuando sus planes de consulta son complicados. Si tiene que ejecutar las operaciones muchas veces (por ejemplo, si usa Contiene para obtener fragmentos de datos), es posible que tenga tiempos de recompilación de consultas bastante desagradables. Verifique la fuente por sí mismo y podrá ver que parent._recompileRequired = () => true;sucede con todas las consultas que contienen un parámetro IEnumerable <T>. ¡Abucheo!
jocull

Respuestas:

66

ACTUALIZACIÓN: Con la incorporación de InExpression en EF6, el rendimiento del procesamiento de Enumerable.Contains mejoró drásticamente. El enfoque descrito en esta respuesta ya no es necesario.

Tiene razón en que la mayor parte del tiempo se dedica a procesar la traducción de la consulta. El modelo de proveedor de EF no incluye actualmente una expresión que represente una cláusula IN, por lo que los proveedores de ADO.NET no pueden admitir IN de forma nativa. En cambio, la implementación de Enumerable.Contains lo traduce a un árbol de expresiones OR, es decir, para algo que en C # se ve así:

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

... generaremos un árbol DbExpression que podría representarse así:

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

(Los árboles de expresión deben estar equilibrados porque si tuviéramos todos los OR en una sola columna larga, habría más posibilidades de que el visitante de la expresión golpeara un desbordamiento de pila (sí, en realidad lo hicimos en nuestras pruebas))

Luego enviamos un árbol como este al proveedor de ADO.NET, que puede tener la capacidad de reconocer este patrón y reducirlo a la cláusula IN durante la generación de SQL.

Cuando agregamos soporte para Enumerable.Contains en EF4, pensamos que era deseable hacerlo sin tener que introducir soporte para expresiones IN en el modelo de proveedor, y sinceramente, 10,000 es mucho más que la cantidad de elementos que anticipamos que los clientes pasarían. Enumerable Contiene. Dicho esto, entiendo que esto es una molestia y que la manipulación de árboles de expresiones encarece demasiado las cosas en su escenario particular.

Hablé de esto con uno de nuestros desarrolladores y creemos que en el futuro podríamos cambiar la implementación agregando soporte de primera clase para IN. Me aseguraré de que esto se agregue a nuestro trabajo pendiente, pero no puedo prometer cuándo lo hará, dado que hay muchas otras mejoras que nos gustaría hacer.

A las soluciones ya sugeridas en el hilo, agregaría lo siguiente:

Considere la posibilidad de crear un método que equilibre el número de viajes de ida y vuelta a la base de datos con el número de elementos que pasa a Contiene. Por ejemplo, en mis propias pruebas observé que calcular y ejecutar contra una instancia local de SQL Server la consulta con 100 elementos toma 1/60 de segundo. Si puede escribir su consulta de tal manera que la ejecución de 100 consultas con 100 conjuntos diferentes de ID le dé un resultado equivalente a la consulta con 10,000 elementos, entonces puede obtener los resultados en aproximadamente 1,67 segundos en lugar de 18 segundos.

Los diferentes tamaños de fragmentos deberían funcionar mejor según la consulta y la latencia de la conexión de la base de datos. Para ciertas consultas, es decir, si la secuencia pasada tiene duplicados o si Enumerable.Contains se usa en una condición anidada, puede obtener elementos duplicados en los resultados.

Aquí hay un fragmento de código (lo siento si el código utilizado para dividir la entrada en trozos parece demasiado complejo. Hay formas más simples de lograr lo mismo, pero estaba tratando de encontrar un patrón que preserva la transmisión de la secuencia y No pude encontrar nada parecido en LINQ, así que probablemente exagere esa parte :)):

Uso:

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

Método para contexto o repositorio:

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;
            }
        }
    }
}

Métodos de extensión para cortar secuencias enumerables:

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);
            }
        }
    }
}

¡Espero que esto ayude!

divega
fuente
Para explicar !(status.EndOfSequence = true)en el método TakeOnEnumerator <T>: Por lo tanto, el efecto secundario de esta asignación de expresión siempre será! Verdadero, por lo que no afectará a la expresión general. Básicamente, marca stats.EndOfSequencecomo truesolo cuando quedan elementos por recuperar, pero ha llegado al final de la enumeración.
arviman
Quizás el rendimiento del procesamiento Enumerable.Containsmejoró dramáticamente en EF 6 en comparación con las versiones anteriores de EF. Pero, desafortunadamente, todavía está lejos de ser satisfactorio / listo para producción en nuestros casos de uso.
Nik
24

Si encuentra un problema de rendimiento que le está bloqueando, no intente pasar mucho tiempo resolviéndolo porque lo más probable es que no tenga éxito y tendrá que comunicarlo directamente con MS (si tiene soporte premium) y se necesita siglos.

Utilice soluciones alternativas en caso de problemas de rendimiento y EF significa SQL directo. No tiene nada de malo. La idea global de que usar EF = dejar de usar SQL es una mentira. Tiene SQL Server 2008 R2, entonces:

  • Cree un procedimiento almacenado que acepte el parámetro con valores de tabla para pasar sus ID
  • Deje que su procedimiento almacenado devuelva múltiples conjuntos de resultados para emular la Includelógica de manera óptima
  • Si necesita una construcción de consultas compleja, use SQL dinámico dentro del procedimiento almacenado
  • Úselo SqlDataReaderpara obtener resultados y construir sus entidades
  • Adjúntelos al contexto y trabaje con ellos como si estuvieran cargados desde EF

Si el rendimiento es crítico para usted, no encontrará una mejor solución. EF no puede mapear y ejecutar este procedimiento porque la versión actual no admite parámetros con valores de tabla ni conjuntos de resultados múltiples.

Ladislav Mrnka
fuente
@Laddislav Mrnka Encontramos un problema de rendimiento similar debido a list.Contains (). Intentaremos crear procedimientos pasando identificadores. ¿Deberíamos experimentar algún impacto en el rendimiento si ejecutamos este procedimiento a través de EF?
Kurubaran
9

Pudimos resolver el problema de EF Contiene agregando una tabla intermedia y uniéndonos a esa tabla desde la consulta LINQ que necesitaba usar la cláusula Contains. Pudimos obtener resultados asombrosos con este enfoque. Tenemos un modelo EF grande y como "Contiene" no está permitido al precompilar consultas EF, obtuvimos un rendimiento muy bajo para las consultas que usan la cláusula "Contiene".

Un resumen:

  • Crear una tabla en SQL Server - por ejemplo, HelperForContainsOfIntTypecon el HelperIDde Guidtipo de datos y ReferenceIDde intcolumnas de tipo de datos. Cree diferentes tablas con ReferenceID de diferentes tipos de datos según sea necesario.

  • Cree un Entity / EntitySet para HelperForContainsOfIntTypey otras tablas similares en el modelo EF. Cree diferentes Entity / EntitySet para diferentes tipos de datos según sea necesario.

  • Cree un método auxiliar en código .NET que tome la entrada de an IEnumerable<int>y devuelva un Guid. Este método genera una nueva Guide inserta los valores de IEnumerable<int>a HelperForContainsOfIntTypelo largo con el generado Guid. A continuación, el método devuelve este recién generado Guida la persona que llama. Para una inserción rápida en la HelperForContainsOfIntTypetabla, cree un procedimiento almacenado que tome la entrada de una lista de valores y realice la inserción. Consulte Parámetros con valores de tabla en SQL Server 2008 (ADO.NET) . Cree diferentes ayudantes para diferentes tipos de datos o cree un método de ayuda genérico para manejar diferentes tipos de datos.

  • Cree una consulta compilada de EF que sea similar a algo como a continuación:

    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 
        );
    
  • Llame al método auxiliar con los valores que se usarán en la Containscláusula y obtenga el Guiduso en la consulta. Por ejemplo:

    var containsHelperID = dbHelper.InsertIntoHelperForContainsOfIntType(new int[] { 1, 2, 3 });
    var result = _selectCustomers(_dbContext, containsHelperID).ToList();
    
Dhwanil Shah
fuente
¡Gracias por esto! Usé una variación de su solución para resolver mi problema.
Mike
5

Editando mi respuesta original: existe una posible solución alternativa, según la complejidad de sus entidades. Si conoce el sql que EF genera para poblar sus entidades, puede ejecutarlo directamente usando DbContext.Database.SqlQuery . En EF 4, creo que podrías usar ObjectContext.ExecuteStoreQuery , pero no lo probé.

Por ejemplo, usando el código de mi respuesta original a continuación para generar la declaración SQL usando a StringBuilder, pude hacer lo siguiente

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

y el tiempo total pasó de aproximadamente 26 segundos a 0,5 segundos.

Seré el primero en decir que es feo y, con suerte, se presentará una mejor solución.

actualizar

Después de pensar un poco más, me di cuenta de que si usa una combinación para filtrar sus resultados, EF no tiene que crear esa larga lista de identificadores. Esto podría ser complejo según la cantidad de consultas simultáneas, pero creo que podría usar identificadores de usuario o identificadores de sesión para aislarlos.

Para probar esto, creé una Targettabla con el mismo esquema que Main. Luego usé StringBuilderpara crear INSERTcomandos para completar la Targettabla en lotes de 1,000, ya que esa es la mayor cantidad que SQL Server aceptará en una sola INSERT. La ejecución directa de las declaraciones sql fue mucho más rápido que pasar por EF (aproximadamente 0,3 segundos frente a 2,5 segundos), y creo que estaría bien ya que el esquema de la tabla no debería cambiar.

Finalmente, seleccionar usando un joinresultó en una consulta mucho más simple y se ejecutó en menos de 0.5 segundos.

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();

Y el sql generado por EF para la unión:

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

(respuesta original)

Esta no es una respuesta, pero quería compartir información adicional y es demasiado larga para caber en un comentario. Pude reproducir sus resultados y tengo algunas otras cosas que agregar:

SQL Profiler muestra que el retraso se produce entre la ejecución de la primera consulta ( Main.Select) y la segunda Main.Whereconsulta, por lo que sospeché que el problema estaba en generar y enviar una consulta de ese tamaño (48,980 bytes).

Sin embargo, construir la misma declaración sql en T-SQL dinámicamente toma menos de 1 segundo, y tomar idsde su Main.Selectdeclaración, construir la misma declaración sql y ejecutarla usando SqlCommand0.112 segundos, y eso incluye el tiempo para escribir el contenido en la consola. .

En este punto, sospecho que EF está realizando algún análisis / procesamiento para cada uno de los 10,000 idsmientras construye la consulta. Ojalá pudiera proporcionar una respuesta y una solución definitivas :(.

Aquí está el código que probé en SSMS y LINQPad (por favor, no critique con demasiada dureza, tengo prisa por dejar el trabajo):

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));
        }
    }
}
Jeff Ogata
fuente
Gracias por tu trabajo en esto. Saber que pudiste reproducirlo me hace sentir mejor, ¡al menos no estoy loco! Desafortunadamente, su solución no ayuda realmente en mi caso porque, como puede adivinar, el ejemplo que di aquí se simplificó tanto como fue posible para aislar el problema. Mi consulta real implica un esquema bastante complicado, .Include () en varias otras tablas, y algunos otros operadores LINQ también.
Mike
@ Mike, agregué otra idea que funcionaría para entidades complejas. Con suerte, no será demasiado difícil de implementar si no tiene otra opción.
Jeff Ogata
Hice algunas pruebas y creo que tienes razón en que la demora está en la creación del SQL antes de que se ejecute. Actualicé mi pregunta con los detalles.
Mike
@ Mike, ¿pudiste intentar unirte a los identificadores (ver la actualización en mi respuesta)?
Jeff Ogata
Terminé usando una variación de su enfoque para resolver el problema de rendimiento. Terminó siendo bastante feo, pero probablemente la mejor opción hasta (y si) Microsoft resuelve este problema.
Mike
5

No estoy familiarizado con Entity Framework, pero ¿el rendimiento es mejor si hace lo siguiente?

En lugar de esto:

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

qué tal esto (asumiendo que la ID es un int):

var ids = new HashSet<int>(Main.Select(a => a.Id));
var rows = Main.Where (a => ids.Contains(a.Id)).ToArray();
Shiv
fuente
No sé por qué y cómo, pero funcionó a las mil maravillas :) Muchas gracias :)
Wahid Bitar
1
La explicación de por qué el rendimiento es mejor es el int []. Contiene llamada en la primera llamada es O (n) - potencialmente un escaneo de matriz completo - mientras que la llamada HashSet <int> .Contains es O (1). Consulte stackoverflow.com/questions/9812020/… para conocer el rendimiento del hashset.
Shiv
3
@Shiv No creo que eso sea correcto. EF tomará cualquier colección y la traducirá a SQL. El tipo de colección no debería ser un problema.
Rob
@Rob Soy escéptico, no puedo explicar la diferencia de rendimiento si ese es el caso. Puede que tenga que analizar el binario para ver qué ha hecho.
Shiv
1
HashSet no es IEnumerable. IEnumerables que llaman .Contains en LINQ tienen un rendimiento deficiente (al menos antes de EF6)
Jason Beck
2

¿Una alternativa almacenable en caché a Contiene?

Esto solo me mordió, así que agregué mis dos centavos al enlace Sugerencias de funciones de Entity Framework.

El problema es definitivamente al generar el SQL. Tengo un cliente sobre cuyos datos la generación de consultas fue de 4 segundos, pero la ejecución fue de 0,1 segundos.

Me di cuenta de que al usar LINQ y OR dinámicos, la generación de sql tardaba el mismo tiempo, pero generaba algo que podía almacenarse en caché . Entonces, al ejecutarlo nuevamente, se redujo a 0.2 segundos.

Tenga en cuenta que todavía se generó un SQL in.

Solo algo más a considerar si puede soportar el golpe inicial, su recuento de matrices no cambia mucho y ejecuta la consulta mucho. (Probado en LINQ Pad)

Dave
fuente
También vote por él en el sitio del codeplex < entityframework.codeplex.com/workitem/245 >
Dave
2

El problema es con la generación de SQL de Entity Framework. No puede almacenar en caché la consulta si uno de los parámetros es una lista.

Para que EF almacene en caché su consulta, puede convertir su lista en una cadena y hacer un .Contains en la cadena.

Entonces, por ejemplo, este código se ejecutaría mucho más rápido ya que EF podría almacenar en caché la consulta:

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

Cuando se genera esta consulta, es probable que se genere utilizando un Me gusta en lugar de un In, por lo que acelerará su C #, pero podría ralentizar su SQL. En mi caso, no noté ninguna disminución del rendimiento en mi ejecución de SQL, y C # se ejecutó significativamente más rápido.

usuario2704238
fuente
1
Buena idea, pero esto no hará uso de ningún índice en la columna en cuestión.
gastador
Sí, eso es cierto, por eso mencioné que podría ralentizar la ejecución de SQL. Supongo que esta es solo una alternativa potencial si no puede usar el generador de predicados y está trabajando con un conjunto de datos lo suficientemente pequeño como para que pueda permitirse no usar un índice. También supongo que debería haber mencionado que el generador de predicados es la opción preferida
user2704238
1
Qué solución tan INCREÍBLE. Logramos aumentar nuestro tiempo de ejecución de consultas de producción de ~ 12,600 milisegundos a solo ~ 18 milisegundos. Esta es una GRAN mejora. Muchas gracias !!!
Jacob