Una inmersión profunda en el rendimiento de Entity Framework cuando se usa "Contiene"

Publicado: 2022-03-11

Durante mi trabajo diario, uso Entity Framework. Es muy conveniente, pero en algunos casos, su rendimiento es lento. A pesar de que hay muchos buenos artículos sobre mejoras en el rendimiento de EF, y se dan algunos consejos muy buenos y útiles (por ejemplo, evitar consultas complejas, parámetros en Omitir y tomar, usar vistas, seleccionar solo los campos necesarios, etc.), no mucho puede realmente se puede hacer cuando necesita usar Contains complejos en dos o más campos; en otras palabras, cuando une datos a una lista de memoria .

Problema

Veamos el siguiente ejemplo:

 var localData = GetDataFromApiOrUser(); var query = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId join t in localData on new { s.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; var result = query.ToList();

El código anterior no funciona en absoluto en EF 6 y, si bien funciona en EF Core, la combinación se realiza localmente, ya que tengo diez millones de registros en mi base de datos, todos se descargan y se consume toda la memoria. . Esto no es un error en EF. se espera Sin embargo, ¿no sería fantástico si hubiera algo para resolver esto? En este artículo, voy a hacer algunos experimentos con un enfoque diferente para solucionar este cuello de botella en el rendimiento.

Solución

Voy a probar diferentes formas de lograr esto, comenzando desde la más simple hasta la más avanzada. En cada paso, proporcionaré código y métricas, como el tiempo necesario y el uso de la memoria. Tenga en cuenta que interrumpiré la ejecución del programa de evaluación comparativa si funciona durante más de diez minutos.

El código para el programa de evaluación comparativa se encuentra en el siguiente repositorio. Utiliza C#, .NET Core, EF Core y PostgreSQL. Usé una máquina con Intel Core i5, 8 GB de RAM y un SSD.

El esquema de base de datos para la prueba se ve así:

Tablas en la base de datos: precios, valores y fuentes de precios

Solo tres tablas: precios, valores y fuentes de precios. La tabla de precios tiene decenas de millones de registros.

Opción 1. Simple e Ingenuo

Probemos algo simple, solo para comenzar.

 var result = new List<Price>(); using (var context = CreateContext()) { foreach (var testElement in TestData) { result.AddRange(context.Prices.Where( x => x.Security.Ticker == testElement.Ticker && x.TradedOn == testElement.TradedOn && x.PriceSourceId == testElement.PriceSourceId)); } }

El algoritmo es simple: para cada elemento de los datos de prueba, busque un elemento adecuado en la base de datos y agréguelo a la colección de resultados. Este código tiene una sola ventaja: es muy fácil de implementar. Además, es legible y mantenible. Su inconveniente evidente es que es el más lento. Aunque las tres columnas están indexadas, la sobrecarga de la comunicación de la red sigue creando un cuello de botella en el rendimiento. Aquí están las métricas:

Resultados del primer experimento

Entonces, para un volumen grande, toma aproximadamente un minuto. El consumo de memoria parece ser razonable.

Opción 2. Ingenuo con paralelo

Ahora intentemos agregar paralelismo al código. La idea central aquí es que acceder a la base de datos en subprocesos paralelos puede mejorar el rendimiento general.

 var result = new ConcurrentBag<Price>(); var partitioner = Partitioner.Create(0, TestData.Count); Parallel.ForEach(partitioner, range => { var subList = TestData.Skip(range.Item1) .Take(range.Item2 - range.Item1) .ToList(); using (var context = CreateContext()) { foreach (var testElement in subList) { var query = context.Prices.Where( x => x.Security.Ticker == testElement.Ticker && x.TradedOn == testElement.TradedOn && x.PriceSourceId == testElement.PriceSourceId); foreach (var el in query) { result.Add(el); } } } });

Es interesante que, para conjuntos de datos de prueba más pequeños, este enfoque funcione más lento que la primera solución, pero para muestras más grandes, es más rápido (aproximadamente 2 veces en este caso). El consumo de memoria cambia un poco, pero no significativamente.

Resultados del segundo experimento

Opción 3. Contenido múltiple

Intentemos otro enfoque:

  • Prepare 3 colecciones de valores únicos de Ticker, PriceSourceId y Date.
  • Realice la consulta con un filtrado de ejecución utilizando 3 Contiene.
  • Vuelva a comprobar localmente (ver más abajo).
 var result = new List<Price>(); using (var context = CreateContext()) { var tickers = TestData.Select(x => x.Ticker).Distinct().ToList(); var dates = TestData.Select(x => x.TradedOn).Distinct().ToList(); var ps = TestData.Select(x => x.PriceSourceId) .Distinct().ToList(); var data = context.Prices .Where(x => tickers.Contains(x.Security.Ticker) && dates.Contains(x.TradedOn) && ps.Contains(x.PriceSourceId)) .Select(x => new { x.PriceSourceId, Price = x, Ticker = x.Security.Ticker, }) .ToList(); var lookup = data.ToLookup(x => $"{x.Ticker}, {x.Price.TradedOn}, {x.PriceSourceId}"); foreach (var el in TestData) { var key = $"{el.Ticker}, {el.TradedOn}, {el.PriceSourceId}"; result.AddRange(lookup[key].Select(x => x.Price)); } }

Este enfoque es problemático. El tiempo de ejecución depende mucho de los datos. Puede recuperar solo los registros requeridos (en cuyo caso será muy rápido), pero puede devolver muchos más (tal vez incluso 100 veces más).

Consideremos los siguientes datos de prueba:

Datos de respuesta

Aquí consulto los precios de Ticker1 negociado el 2018-01-01 y de Ticker2 negociado el 2018-01-02. Sin embargo, en realidad se devolverán cuatro registros.

Los valores únicos para Ticker son Ticker1 y Ticker2 . Los valores únicos para TradedOn son 2018-01-01 y 2018-01-02 .

Entonces, cuatro registros coinciden con esta expresión.

Es por eso que se necesita una nueva verificación local y por qué este enfoque es peligroso. Las métricas son las siguientes:

Resultados del tercer experimento

¡Pésimo consumo de memoria! Las pruebas con grandes volúmenes fallaron debido a un tiempo de espera de 10 minutos.

Opción 4. Generador de predicados

Cambiemos el paradigma: construyamos una buena Expression antigua para cada conjunto de datos de prueba.

 var result = new List<Price>(); using (var context = CreateContext()) { var baseQuery = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId select new TestData() { Ticker = s.Ticker, TradedOn = p.TradedOn, PriceSourceId = p.PriceSourceId, PriceObject = p }; var tradedOnProperty = typeof(TestData).GetProperty("TradedOn"); var priceSourceIdProperty = typeof(TestData).GetProperty("PriceSourceId"); var tickerProperty = typeof(TestData).GetProperty("Ticker"); var paramExpression = Expression.Parameter(typeof(TestData)); Expression wholeClause = null; foreach (var td in TestData) { var elementClause = Expression.AndAlso( Expression.Equal( Expression.MakeMemberAccess( paramExpression, tradedOnProperty), Expression.Constant(td.TradedOn) ), Expression.AndAlso( Expression.Equal( Expression.MakeMemberAccess( paramExpression, priceSourceIdProperty), Expression.Constant(td.PriceSourceId) ), Expression.Equal( Expression.MakeMemberAccess( paramExpression, tickerProperty), Expression.Constant(td.Ticker)) )); if (wholeClause == null) wholeClause = elementClause; else wholeClause = Expression.OrElse(wholeClause, elementClause); } var query = baseQuery.Where( (Expression<Func<TestData, bool>>)Expression.Lambda( wholeClause, paramExpression)).Select(x => x.PriceObject); result.AddRange(query); }

El código resultante es bastante complejo. Construir expresiones no es lo más fácil e implica reflexión (que, en sí misma, no es tan rápida). Pero nos ayuda a construir una sola consulta usando muchos … (.. AND .. AND ..) OR (.. AND .. AND ..) OR (.. AND .. AND ..) ... . Estos son los resultados:

Resultados del cuarto experimento

Incluso peor que cualquiera de los enfoques anteriores.

Opción 5. Tabla de datos de consulta compartida

Probemos un enfoque más:

Agregué una nueva tabla a la base de datos que contendrá datos de consulta. Para cada consulta ahora puedo:

  • Iniciar una transacción (si aún no se ha iniciado)
  • Subir datos de consulta a esa tabla (temporal)
  • Realizar una consulta
  • Revertir una transacción: para eliminar los datos cargados
 var result = new List<Price>(); using (var context = CreateContext()) { context.Database.BeginTransaction(); var reducedData = TestData.Select(x => new SharedQueryModel() { PriceSourceId = x.PriceSourceId, Ticker = x.Ticker, TradedOn = x.TradedOn }).ToList(); // Here query data is stored to shared table context.QueryDataShared.AddRange(reducedData); context.SaveChanges(); var query = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId join t in context.QueryDataShared on new { s.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; result.AddRange(query); context.Database.RollbackTransaction(); }

Métricas primero:

Resultados del quinto experimento

El resultado es muy bueno. Muy rapido. El consumo de memoria también es bueno. Pero los inconvenientes son:

  • Debe crear una tabla adicional en la base de datos para realizar solo un tipo de consulta,
  • Debe iniciar una transacción (que de todos modos consume recursos DBMS), y
  • Tiene que escribir algo en la base de datos (¡en una operación de LECTURA!) y, básicamente, esto no funcionará si usa algo como una réplica de lectura.

Pero aparte de eso, este enfoque es agradable, rápido y legible. ¡Y en este caso se almacena en caché un plan de consulta!

Opción 6. Extensión MemoryJoin

Aquí voy a usar un paquete NuGet llamado EntityFrameworkCore.MemoryJoin. A pesar de que su nombre tiene la palabra Core, también es compatible con EF 6. Se llama MemoryJoin, pero de hecho, envía los datos de consulta especificados como VALORES al servidor y todo el trabajo se realiza en el servidor SQL.

Comprobemos el código.

 var result = new List<Price>(); using (var context = CreateContext()) { // better to select needed properties only, for better performance var reducedData = TestData.Select(x => new { x.Ticker, x.TradedOn, x.PriceSourceId }).ToList(); var queryable = context.FromLocalList(reducedData); var query = from p in context.Prices join s in context.Securities on p.SecurityId equals s.SecurityId join t in queryable on new { s.Ticker, p.TradedOn, p.PriceSourceId } equals new { t.Ticker, t.TradedOn, t.PriceSourceId } select p; result.AddRange(query); }

Métrica:

Resultados del experimento final

Esto se ve increíble. Tres veces más rápido que el enfoque anterior, lo que lo convierte en el más rápido hasta ahora. ¡3.5 segundos para registros de 64K! El código es simple y comprensible. Esto funciona con réplicas de solo lectura. Veamos la consulta generada para tres elementos:

 SELECT "p"."PriceId", "p"."ClosePrice", "p"."OpenPrice", "p"."PriceSourceId", "p"."SecurityId", "p"."TradedOn", "t"."Ticker", "t"."TradedOn", "t"."PriceSourceId" FROM "Price" AS "p" INNER JOIN "Security" AS "s" ON "p"."SecurityId" = "s"."SecurityId" INNER JOIN ( SELECT "x"."string1" AS "Ticker", "x"."date1" AS "TradedOn", CAST("x"."long1" AS int4) AS "PriceSourceId" FROM ( SELECT * FROM ( VALUES (1, @__gen_q_p0, @__gen_q_p1, @__gen_q_p2), (2, @__gen_q_p3, @__gen_q_p4, @__gen_q_p5), (3, @__gen_q_p6, @__gen_q_p7, @__gen_q_p8) ) AS __gen_query_data__ (id, string1, date1, long1) ) AS "x" ) AS "t" ON (("s"."Ticker" = "t"."Ticker") AND ("p"."PriceSourceId" = "t"."PriceSourceId")

Como puede ver, esta vez los valores reales se pasan de la memoria al servidor SQL en la construcción VALUES. Y esto funciona: el servidor SQL logró realizar una operación de combinación rápida y usar los índices correctamente.

Sin embargo, hay algunos inconvenientes (puedes leer más en mi blog):

  • Debe agregar un DbSet adicional a su modelo (sin embargo, no es necesario crearlo en la base de datos)
  • La extensión no admite clases de modelo con muchas propiedades: tres propiedades de cadena, tres propiedades de fecha, tres propiedades de guía, tres propiedades flotantes/dobles y tres propiedades int/byte/long/decimal. Esto es más que suficiente en el 90% de los casos, supongo. Sin embargo, si no es así, puede crear una clase personalizada y usarla. Entonces, SUGERENCIA: debe pasar los valores reales en una consulta; de lo contrario, se desperdician recursos.

Conclusión

Entre las cosas que he probado aquí, definitivamente elegiría MemoryJoin. Alguien más podría objetar que los inconvenientes son insalvables, y dado que no todos se pueden resolver de momento, deberíamos abstenernos de usar la extensión. Bueno, para mí es como decir que no debes usar un cuchillo porque podrías cortarte. La optimización no era una tarea para desarrolladores junior sino para alguien que entiende cómo funciona EF. Con ese fin, esta herramienta puede mejorar el rendimiento de forma espectacular. ¿Quién sabe? Tal vez algún día, alguien en Microsoft agregará soporte central para VALORES dinámicos.

Finalmente, aquí hay algunos diagramas más para comparar resultados.

A continuación se muestra un diagrama del tiempo necesario para realizar una operación. MemoryJoin es el único que hace el trabajo en un tiempo razonable. Solo cuatro enfoques pueden procesar grandes volúmenes: dos implementaciones ingenuas, tabla compartida y MemoryJoin.

Tiempo empleado en varios casos para cada experimento.

El siguiente diagrama es para el consumo de memoria. Todos los enfoques demuestran más o menos los mismos números, excepto el que tiene múltiples Contains . Este fenómeno fue descrito anteriormente.

Consumo de memoria en varios casos para cada experimento