Métodos para optimizar consultas LINQ en C # .NET

Introducción


En este artículo discutimos algunas técnicas de optimización de consultas LINQ .
Aquí hay algunos enfoques de optimización de código más relacionados con las consultas LINQ .

Se sabe que LINQ (Language-Integrated Query) es un lenguaje simple y conveniente para consultar una fuente de datos.

Y LINQ to SQL es una tecnología de acceso a datos en un DBMS. Esta es una herramienta poderosa para trabajar con datos, donde las consultas se construyen a través de un lenguaje declarativo, que luego la plataforma convertirá en consultas SQL y enviará al servidor de la base de datos para su ejecución. En nuestro caso, por DBMS nos referimos a MS SQL Server .

Sin embargo, las consultas LINQ no se convierten en consultas SQL escritas de manera óptima que un DBA experimentado podría escribir con todos los matices de la optimización de las consultas SQL :

  1. Conexiones óptimas ( JOIN ) y filtrado de resultados ( DONDE )
  2. muchos matices en el uso de compuestos y condiciones grupales
  3. muchas variaciones al reemplazar las condiciones IN con EXISTS y NOT IN , <> con EXISTS
  4. almacenamiento en caché intermedio de resultados a través de tablas temporales, CTE, variables de tabla
  5. utilizando una cláusula ( OPCIÓN ) con instrucciones y sugerencias de tabla CON (...)
  6. El uso de vistas indexadas, como uno de los medios para deshacerse de las lecturas redundantes de datos en muestras

Los principales cuellos de botella de rendimiento de las consultas SQL resultantes al compilar consultas LINQ son:

  1. consolidación de todo el mecanismo de selección de datos en una sola solicitud
  2. duplicación de bloques de código idénticos, lo que finalmente lleva a múltiples lecturas adicionales de datos
  3. grupos de condiciones multicomponentes (lógico "y" y "o") - AND y OR , combinados en condiciones difíciles, conducen al hecho de que el optimizador, que tiene índices no agrupados adecuados, por los campos necesarios, finalmente comienza a escanear por el índice de agrupación ( ESCANEO DE ÍNDICE ) por grupo de condiciones
  4. El anidamiento profundo de las subconsultas hace que sea muy problemático analizar las declaraciones SQL y analizar los planes de consulta de los desarrolladores y los DBA

Métodos de optimización


Ahora pasamos directamente a los métodos de optimización.

1) indexación adicional


Es mejor tener en cuenta los filtros en las tablas de muestreo principales, ya que muy a menudo la consulta completa se basa en una o dos tablas principales (aplicaciones-personas-operaciones) y con un conjunto estándar de condiciones (cerrado, cancelado, habilitado, estado). Es importante que las muestras identificadas creen los índices correspondientes.

Esta solución tiene sentido cuando elegir entre estos campos limita significativamente el conjunto devuelto a la consulta.

Por ejemplo, tenemos 500,000 aplicaciones. Sin embargo, solo hay 2.000 entradas activas. Luego, un índice seleccionado correctamente nos salvará de ESCANEO DE ÍNDICE sobre una tabla grande y nos permitirá seleccionar rápidamente los datos a través de un índice no agrupado.

La falta de índices también se puede detectar a través de indicaciones para analizar planes de consulta o recopilar estadísticas para vistas del sistemaServidor MS SQL :

  1. sys.dm_db_missing_index_groups
  2. sys.dm_db_missing_index_group_stats
  3. sys.dm_db_missing_index_details

Todos los datos de vista contienen información sobre índices faltantes, con la excepción de los índices espaciales.

Sin embargo, los índices y el almacenamiento en caché a menudo son métodos para tratar los efectos de consultas LINQ y consultas SQL mal escritas .

Como muestra la dura práctica de la vida para el negocio, a menudo es importante implementar las características del negocio en una fecha determinada. Y, por lo tanto, a menudo las consultas pesadas se ponen en segundo plano con el almacenamiento en caché.

Esto está parcialmente justificado, ya que el usuario no siempre necesita los datos más recientes y se produce un nivel aceptable de respuesta de la interfaz de usuario.

Este enfoque le permite resolver las necesidades comerciales, pero en última instancia reduce la eficiencia del sistema de información, simplemente retrasando la solución de problemas.

También vale la pena recordar que en el proceso de búsqueda necesario para agregar nuevos índices, las propuestas de optimización de MS SQL pueden ser incorrectas, incluso bajo las siguientes condiciones:

  1. si ya existen índices con un conjunto similar de campos
  2. Si los campos de la tabla no pueden ser indexados debido a las restricciones de indexación (más sobre esto se describe aquí ).

2) Fusionar atributos en un nuevo atributo


A veces, algunos campos de la misma tabla en la que se produce un grupo de condiciones se pueden reemplazar por la introducción de un nuevo campo.

Esto es especialmente cierto para los campos de estado, que por tipo generalmente son bit a bit o enteros.

Ejemplo:

IsClosed = 0 AND Cancelled = 0 AND Enabled = 0 se reemplaza por Status = 1 .

Aquí ingresa el atributo entero Estado, que se proporciona al completar estos estados en la tabla. El siguiente paso es indexar este nuevo atributo.

Esta es una solución fundamental para el problema de rendimiento, porque estamos pidiendo datos sin cálculos innecesarios.

3) Materialización de envío


Desafortunadamente, las consultas LINQ no pueden usar directamente tablas temporales, CTE y variables de tabla.

Sin embargo, hay otra forma de optimizar para este caso: las vistas indexadas.

Un grupo de condiciones (del ejemplo anterior) IsClosed = 0 AND Cancelled = 0 AND Enabled = 0 (o un conjunto de otras condiciones similares) se convierte en una buena opción para usarlas en una vista indexada, almacenando en caché una pequeña porción de datos de un conjunto grande.

Pero existen varias limitaciones al materializar una vista:

  1. usando subconsultas, las cláusulas EXISTS deben reemplazarse usando JOIN
  2. No se pueden usar las cláusulas UNION , UNION ALL , EXCEPTION , INTERSECT
  3. no puede usar sugerencias de tabla y cláusulas OPTION
  4. sin capacidad de trabajar con ciclos
  5. es imposible mostrar datos en una vista desde diferentes tablas

Es importante recordar que los beneficios reales de usar una vista indizada en realidad solo se pueden obtener indizándola.

Pero al invocar una vista, estos índices no pueden usarse, y para usarlos explícitamente, debe especificar WITH (NOEXPAND) .

Como es imposible definir sugerencias de tabla en las consultas LINQ , debe hacer otra representación: un "contenedor" de la siguiente forma:

CREATE VIEW _ AS SELECT * FROM MAT_VIEW WITH (NOEXPAND);

4) Usar funciones de tabla


A menudo, en las consultas LINQ, los bloques de subconsultas grandes o los bloques que usan representaciones con una estructura compleja forman la consulta final con una estructura de ejecución muy compleja y no óptima.

Beneficios clave del uso de funciones de tabla en consultas LINQ :

  1. La capacidad, como en el caso de las vistas, de usar y especificar como un objeto, pero puede pasar un conjunto de parámetros de entrada:
    DESDE LA FUNCIÓN (@ param1, @ param2 ...)
    al final, puede lograr un muestreo de datos flexible
  2. Cuando se utiliza una función de tabla, no existen restricciones tan fuertes como en el caso de las vistas indizadas descritas anteriormente:

    1. :
      LINQ .
      .
      ,
    2. , , :

      • ( )
      • UNION EXISTS

  3. OPTION , OPTION(MAXDOP N), . :

    • OPTION (RECOMPILE)
    • , OPTION (FORCE ORDER)

    OPTION .
  4. :
    ( ), .
    , , WHERE (a, b, c).

    a = 0 and b = 0.

    , c .

    a = 0 and b = 0 , .

    Aquí la función de tabla puede ser una mejor opción.

    Además, la función de tabla es más predecible y constante en el tiempo de ejecución.

Ejemplos


Consideremos un ejemplo de implementación utilizando el ejemplo de la base de datos de Preguntas.

Hay una consulta SELECT que combina varias tablas y utiliza una vista (OperativeQuestions), que verifica por correo electrónico la afiliación (a través de EXISTS ) de "Consultas activas" ([OperativeQuestions]):

Solicitud No. 1
(@p__linq__0 nvarchar(4000))SELECT
1 AS [C1],
[Extent1].[Id] AS [Id],
[Join2].[Object_Id] AS [Object_Id],
[Join2].[ObjectType_Id] AS [ObjectType_Id],
[Join2].[Name] AS [Name],
[Join2].[ExternalId] AS [ExternalId]
FROM [dbo].[Questions] AS [Extent1]
INNER JOIN (SELECT [Extent2].[Object_Id] AS [Object_Id],
[Extent2].[Question_Id] AS [Question_Id], [Extent3].[ExternalId] AS [ExternalId],
[Extent3].[ObjectType_Id] AS [ObjectType_Id], [Extent4].[Name] AS [Name]
FROM [dbo].[ObjectQuestions] AS [Extent2]
INNER JOIN [dbo].[Objects] AS [Extent3] ON [Extent2].[Object_Id] = [Extent3].[Id]
LEFT OUTER JOIN [dbo].[ObjectTypes] AS [Extent4] 
ON [Extent3].[ObjectType_Id] = [Extent4].[Id] ) AS [Join2] 
ON [Extent1].[Id] = [Join2].[Question_Id]
WHERE ([Extent1].[AnswerId] IS NULL) AND (0 = [Extent1].[Exp]) AND ( EXISTS (SELECT
1 AS [C1]
FROM [dbo].[OperativeQuestions] AS [Extent5]
WHERE (([Extent5].[Email] = @p__linq__0) OR (([Extent5].[Email] IS NULL) 
AND (@p__linq__0 IS NULL))) AND ([Extent5].[Id] = [Extent1].[Id])
));


La vista tiene una estructura bastante complicada: tiene uniones de subconsulta y el uso de la clasificación DISTINCT , que en el caso general es una operación bastante intensiva en recursos.

Una selección de aproximadamente diez mil registros de OperativeQuestions.

El principal problema de esta consulta es que para los registros de una consulta externa, se realiza una subconsulta interna en la vista [OperativeQuestions], que debería limitar la muestra de salida (a través de EXISTS ) a cientos de registros para [Email] = @ p__linq__0 .

Y puede parecer que la subconsulta debería calcular los registros una vez por [Correo electrónico] = @ p__linq__0, y luego estos doscientos registros deberían estar conectados por Id c Preguntas, y la consulta será rápida.

De hecho, todas las tablas están conectadas en serie: las preguntas de identificación y la identificación de las preguntas operativas se verifican para verificar el cumplimiento y el correo electrónico se filtra.

De hecho, la solicitud funciona con todas las decenas de miles de registros de preguntas operativas, y solo necesita datos de interés en el correo electrónico.

Texto de vista de preguntas operativas:

Solicitud No. 2
 
CREATE VIEW [dbo].[OperativeQuestions]
AS
SELECT DISTINCT Q.Id, USR.email AS Email
FROM            [dbo].Questions AS Q INNER JOIN
                         [dbo].ProcessUserAccesses AS BPU ON BPU.ProcessId = CQ.Process_Id 
OUTER APPLY
                     (SELECT   1 AS HasNoObjects
                      WHERE   NOT EXISTS
                                    (SELECT   1
                                     FROM     [dbo].ObjectUserAccesses AS BOU
                                     WHERE   BOU.ProcessUserAccessId = BPU.[Id] AND BOU.[To] IS NULL)
) AS BO INNER JOIN
                         [dbo].Users AS USR ON USR.Id = BPU.UserId
WHERE        CQ.[Exp] = 0 AND CQ.AnswerId IS NULL AND BPU.[To] IS NULL 
AND (BO.HasNoObjects = 1 OR
              EXISTS (SELECT   1
                           FROM   [dbo].ObjectUserAccesses AS BOU INNER JOIN
                                      [dbo].ObjectQuestions AS QBO 
                                                  ON QBO.[Object_Id] =BOU.ObjectId
                               WHERE  BOU.ProcessUserAccessId = BPU.Id 
                               AND BOU.[To] IS NULL AND QBO.Question_Id = CQ.Id));


Representación de mapeo original en DbContext (EF Core 2)
public class QuestionsDbContext : DbContext
{
    //...
    public DbQuery<OperativeQuestion> OperativeQuestions { get; set; }
    //...
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Query<OperativeQuestion>().ToView("OperativeQuestions");
    }
}


Consulta LINQ original
var businessObjectsData = await context
    .OperativeQuestions
    .Where(x => x.Email == Email)
    .Include(x => x.Question)
    .Select(x => x.Question)
    .SelectMany(x => x.ObjectQuestions,
                (x, bo) => new
                {
                    Id = x.Id,
                    ObjectId = bo.Object.Id,
                    ObjectTypeId = bo.Object.ObjectType.Id,
                    ObjectTypeName = bo.Object.ObjectType.Name,
                    ObjectExternalId = bo.Object.ExternalId
                })
    .ToListAsync();


En este caso particular, se considera una solución a este problema sin cambios de infraestructura, sin introducir una tabla separada con resultados listos ("Consultas activas"), para lo cual se necesitaría un mecanismo para completar sus datos y mantenerlos actualizados.

Aunque esta es una buena solución, hay otra opción para optimizar esta tarea.

El objetivo principal es almacenar en caché las entradas por [Email] = @ p__linq__0 desde la vista OperativeQuestions.

Ingresamos la función de tabla [dbo]. [OperativeQuestionsUserMail] en la base de datos.

Al enviar el correo electrónico como parámetro de entrada, recuperamos la tabla de valores:

Solicitud No. 3

CREATE FUNCTION [dbo].[OperativeQuestionsUserMail]
(
    @Email  nvarchar(4000)
)
RETURNS
@tbl TABLE
(
    [Id]           uniqueidentifier,
    [Email]      nvarchar(4000)
)
AS
BEGIN
        INSERT INTO @tbl ([Id], [Email])
        SELECT Id, @Email
        FROM [OperativeQuestions]  AS [x] WHERE [x].[Email] = @Email;
     
    RETURN;
END


Esto devuelve una tabla de valores con una estructura de datos predefinida.

Para que las consultas a OperativeQuestionsUserMail sean óptimas, para tener planes de consulta óptimos, se requiere una estructura estricta y no la TABLA DE DEVOLUCIONES COMO DEVOLUCIÓN ...

En este caso, la Solicitud 1 deseada se convierte en la Solicitud 4:

Solicitud No. 4
(@p__linq__0 nvarchar(4000))SELECT
1 AS [C1],
[Extent1].[Id] AS [Id],
[Join2].[Object_Id] AS [Object_Id],
[Join2].[ObjectType_Id] AS [ObjectType_Id],
[Join2].[Name] AS [Name],
[Join2].[ExternalId] AS [ExternalId]
FROM (
    SELECT Id, Email FROM [dbo].[OperativeQuestionsUserMail] (@p__linq__0)
) AS [Extent0]
INNER JOIN [dbo].[Questions] AS [Extent1] ON([Extent0].Id=[Extent1].Id)
INNER JOIN (SELECT [Extent2].[Object_Id] AS [Object_Id], [Extent2].[Question_Id] AS [Question_Id], [Extent3].[ExternalId] AS [ExternalId], [Extent3].[ObjectType_Id] AS [ObjectType_Id], [Extent4].[Name] AS [Name]
FROM [dbo].[ObjectQuestions] AS [Extent2]
INNER JOIN [dbo].[Objects] AS [Extent3] ON [Extent2].[Object_Id] = [Extent3].[Id]
LEFT OUTER JOIN [dbo].[ObjectTypes] AS [Extent4] 
ON [Extent3].[ObjectType_Id] = [Extent4].[Id] ) AS [Join2] 
ON [Extent1].[Id] = [Join2].[Question_Id]
WHERE ([Extent1].[AnswerId] IS NULL) AND (0 = [Extent1].[Exp]);


Asignación de vistas y funciones en DbContext (EF Core 2)
public class QuestionsDbContext : DbContext
{
    //...
    public DbQuery<OperativeQuestion> OperativeQuestions { get; set; }
    //...
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Query<OperativeQuestion>().ToView("OperativeQuestions");
    }
}
 
public static class FromSqlQueries
{
    public static IQueryable<OperativeQuestion> GetByUserEmail(this DbQuery<OperativeQuestion> source, string Email)
        => source.FromSql($"SELECT Id, Email FROM [dbo].[OperativeQuestionsUserMail] ({Email})");
}


Consulta final de LINQ
var businessObjectsData = await context
    .OperativeQuestions
    .GetByUserEmail(Email)
    .Include(x => x.Question)
    .Select(x => x.Question)
    .SelectMany(x => x.ObjectQuestions,
                (x, bo) => new
                {
                    Id = x.Id,
                    ObjectId = bo.Object.Id,
                    ObjectTypeId = bo.Object.ObjectType.Id,
                    ObjectTypeName = bo.Object.ObjectType.Name,
                    ObjectExternalId = bo.Object.ExternalId
                })
    .ToListAsync();


El orden del tiempo de ejecución disminuyó de 200 a 800 ms, a 2 a 20 ms, etc., es decir, diez veces más rápido.

Si tomamos un promedio más, entonces en lugar de 350 ms tenemos 8 ms.

De las ventajas obvias, también obtenemos:

  1. reducción general en la carga de lectura,
  2. reducción significativa en la probabilidad de bloqueo
  3. reducción del tiempo de bloqueo promedio a valores aceptables

Conclusión


La optimización y el ajuste fino de las llamadas a la base de datos MS SQL a través de LINQ es un problema que se puede resolver.

En este trabajo, el cuidado y la consistencia son muy importantes.

Al comienzo del proceso:

  1. es necesario verificar los datos con los que trabaja la consulta (valores, tipos de datos seleccionados)
  2. indexar adecuadamente estos datos
  3. verificar la corrección de las condiciones de conexión entre las tablas

En la próxima iteración, la optimización revela:

  1. se define la base de la solicitud y el filtro de solicitud principal
  2. repetir bloques de consulta similares y condiciones de intersección
  3. en el SSMS u otra GUI para SQL Server , la consulta SQL en sí misma está optimizada (asignación de un almacén de datos intermedio, construcción de la consulta resultante utilizando este almacén (puede haber varios))
  4. en la última etapa, tomando como base la consulta SQL resultante , se reconstruye la estructura de consulta LINQ

Como resultado, la consulta LINQ resultante debería tener una estructura idéntica a la consulta SQL óptima identificada del párrafo 3.

Expresiones de gratitud


Muchas gracias a los colegas. bolsa de trabajo y alex_ozrde Fortis por ayudarme con este artículo.

All Articles