Temas de rendimiento avanzados

Agrupación de DbContext

Un DbContext suele ser un objeto pequeño: crear y eliminar uno no implica una operación de base de datos, y la mayoría de las aplicaciones pueden hacerlo sin ningún impacto notable en el rendimiento. Sin embargo, cada instancia de contexto configura varios servicios internos y objetos necesarios para realizar sus tareas, y la sobrecarga de hacerlo continuamente puede ser significativa en escenarios de alto rendimiento. En estos casos, EF Core puede agrupar las instancias de contexto. Cuando usted elimina su contexto, EF Core restablece su estado y lo almacena en un grupo interno. Si se solicita una nueva instancia, se devuelve esa instancia agrupada en lugar de configurar una nueva. La agrupación de contextos permite pagar los costes de configuración de contexto solo una vez al inicio del programa, en lugar de continuamente.

Tenga en cuenta que la agrupación de contextos es independiente de la agrupación de conexiones de base de datos, que se administra en un nivel inferior en el controlador de base de datos.

El patrón típico de una aplicación ASP.NET Core mediante EF Core implica registrar un tipo de DbContext personalizado en el contenedor de inserción de dependencias a través de AddDbContext. A continuación, las instancias de ese tipo se obtienen a través de parámetros de constructor en controladores o Razor Pages.

Para habilitar la agrupación de contextos, simplemente reemplace AddDbContext por AddDbContextPool:

builder.Services.AddDbContextPool<WeatherForecastContext>(
    o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));

El parámetro poolSize de AddDbContextPool establece el número máximo de instancias conservadas por el grupo (el valor predeterminado es 1024). Una vez que se excede poolSize, las nuevas instancias de contexto no se almacenan en caché y EF vuelve al comportamiento de no agrupar y crear de instancias a petición.

Pruebas comparativas

A continuación se muestran los resultados de la prueba comparativa para capturar una sola fila de una base de datos de SQL Server que se ejecuta localmente en la misma máquina, con y sin agrupación de contextos. Como siempre, los resultados cambiarán con el número de filas, la latencia del servidor de bases de datos y otros factores. No olvide que este banco de pruebas comparativas sigue un rendimiento de agrupación de un solo subproceso, mientras que un escenario del mundo real puede tener resultados diferentes. Haga pruebas comparativas en su plataforma antes de tomar decisiones. El código fuente está disponible aquí, no dude en usarlo como base para sus propias medidas.

Método NumBlogs Media Error StdDev Gen 0 Gen 1 Gen 2 Asignado
WithoutContextPooling 1 701,6 us 26,62 us 78,48 us 11,7188 - - 50,38 KB
WithContextPooling 1 350,1 us 6,80 us 14,64 us 0,9766 - - 4,63 KB

Administración del estado en contextos agrupados

La agrupación de contextos reutiliza la misma instancia de contexto entre solicitudes. Esto significa que se registra como un singleton y la misma instancia se reutiliza en varias solicitudes (o ámbitos de inserción de dependencias). Por ello, se debe tener especial cuidado cuando el contexto implique cualquier estado que pueda cambiar entre solicitudes. El contexto OnConfiguring solo se invoca una vez (cuando se crea por primera vez el contexto de instancia) y, por tanto, no se puede usar para establecer el estado que debe variar (por ejemplo, un identificador de inquilino).

Un escenario típico que implica el estado de contexto sería una aplicación multiinquilino ASP.NET Core, donde la instancia de contexto tiene un identificador de inquilino que las consultas tienen en cuenta (consulte Filtros de consulta global para obtener más detalles). Dado que el identificador de inquilino debe cambiar con cada solicitud web, es necesario seguir algunos pasos adicionales para que todo funcione con la agrupación de contextos.

Supongamos que la aplicación registra un servicio de ITenant con ámbito, que encapsula el identificador de inquilino y cualquier otra información relacionada con el inquilino:

// Below is a minimal tenant resolution strategy, which registers a scoped ITenant service in DI.
// In this sample, we simply accept the tenant ID as a request query, which means that a client can impersonate any
// tenant. In a real application, the tenant ID would be set based on secure authentication data.
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenant>(sp =>
{
    var tenantIdString = sp.GetRequiredService<IHttpContextAccessor>().HttpContext.Request.Query["TenantId"];

    return tenantIdString != StringValues.Empty && int.TryParse(tenantIdString, out var tenantId)
        ? new Tenant(tenantId)
        : null;
});

Como decíamos anteriormente, preste especial atención a dónde obtiene el identificador de inquilino: este es un aspecto importante de la seguridad de la aplicación.

Una vez que tengamos el servicio ITenant con ámbito, registre un generador de contextos de agrupación como servicio singleton, de la forma habitual:

builder.Services.AddPooledDbContextFactory<WeatherForecastContext>(
    o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));

A continuación, escriba un generador de contexto personalizado que obtenga un contexto agrupado de la factoría singleton que registramos e inserte el identificador de inquilino en instancias de contexto que entrega:

public class WeatherForecastScopedFactory : IDbContextFactory<WeatherForecastContext>
{
    private const int DefaultTenantId = -1;

    private readonly IDbContextFactory<WeatherForecastContext> _pooledFactory;
    private readonly int _tenantId;

    public WeatherForecastScopedFactory(
        IDbContextFactory<WeatherForecastContext> pooledFactory,
        ITenant tenant)
    {
        _pooledFactory = pooledFactory;
        _tenantId = tenant?.TenantId ?? DefaultTenantId;
    }

    public WeatherForecastContext CreateDbContext()
    {
        var context = _pooledFactory.CreateDbContext();
        context.TenantId = _tenantId;
        return context;
    }
}

Una vez que tengamos nuestro generador de contexto personalizado, regístrelo como servicio con ámbito:

builder.Services.AddScoped<WeatherForecastScopedFactory>();

Por último, organice un contexto para insertarse desde nuestra fábrica con ámbito:

builder.Services.AddScoped(
    sp => sp.GetRequiredService<WeatherForecastScopedFactory>().CreateDbContext());

En esta fase, los controladores se insertan automáticamente con una instancia de contexto que tiene el identificador de inquilino adecuado, sin necesitar información sobre él.

El código fuente completo de este ejemplo está disponible aquí.

Nota:

Aunque EF Core se encarga de restablecer el estado interno de DbContext y sus servicios relacionados, generalmente no restablece el estado en el controlador de base de datos subyacente, que está fuera de EF. Por ejemplo, si abre y usa manualmente DbConnection o si manipula el estado ADO.NET de otro modo, será necesario restaurar ese estado antes de devolver la instancia de contexto al grupo, por ejemplo, cerrando la conexión. Si no, puede provocar que el estado se filtre entre solicitudes no relacionadas.

Consultas compiladas

Cuando EF recibe un árbol de consulta LINQ para su ejecución, primero debe "compilar" ese árbol, por ejemplo, generar una instancia de SQL a partir de él. Dado que esta tarea es un proceso intensivo, EF almacena en caché las consultas en forma del árbol de consulta, de modo que las consultas con la misma estructura vuelvan a usar las salidas de compilación almacenadas en caché internamente. Este almacenamiento en caché permite ejecutar la misma consulta LINQ varias veces de forma muy rápida, incluso si los valores del parámetro difieren.

Sin embargo, EF debe seguir realizando ciertas tareas antes de poder usar la caché de consultas interna. Por ejemplo, el árbol de expresión de la consulta debe compararse recursivamente con los árboles de expresión de las consultas almacenadas en caché para buscar la consulta almacenada en caché correcta. La sobrecarga para este procesamiento inicial es mínima en la mayoría de las aplicaciones de EF, especialmente cuando se comparan con otros costes asociados a la ejecución de consultas (E/S de red, procesamiento real de consultas y E/S de disco en la base de datos...). Sin embargo, en ciertos escenarios de alto rendimiento puede ser conveniente eliminarlo.

EF admite consultas compiladas, que permiten la compilación explícita de una consulta LINQ en un delegado de .NET. Una vez adquirido este delegado, se puede invocar directamente para ejecutar la consulta, sin proporcionar el árbol de expresión LINQ. Esta técnica omite la búsqueda de caché y proporciona la manera más optimizada de ejecutar una consulta en EF Core. A continuación se muestran algunos resultados de pruebas comparativas que comparan el rendimiento de las consultas compiladas y no compiladas. Haga pruebas comparativas en su plataforma antes de tomar decisiones. El código fuente está disponible aquí, no dude en usarlo como base para sus propias medidas.

Método NumBlogs Media Error StdDev Gen 0 Asignado
WithCompiledQuery 1 564,2 us 6,75 us 5,99 us 1,9531 9 KB
WithoutCompiledQuery 1 671,6 us 12,72 us 16,54 us 2,9297 13 KB
WithCompiledQuery 10 645,3 us 10,00 us 9,35 us 2,9297 13 KB
WithoutCompiledQuery 10 709,8 us 25,20 us 73,10 us 3,9063 18 KB

Para usar consultas compiladas, compile primero una consulta con EF.CompileAsyncQuery como se indica a continuación (use EF.CompileQuery para consultas sincrónicas):

private static readonly Func<BloggingContext, int, IAsyncEnumerable<Blog>> _compiledQuery
    = EF.CompileAsyncQuery(
        (BloggingContext context, int length) => context.Blogs.Where(b => b.Url.StartsWith("http://") && b.Url.Length == length));

En este ejemplo de código, se proporciona a EF una expresión lambda que acepta una instancia de DbContext y un parámetro arbitrario que se va a pasar a la consulta. Ahora puede invocar ese delegado siempre que desee ejecutar la consulta:

await foreach (var blog in _compiledQuery(context, 8))
{
    // Do something with the results
}

Tenga en cuenta que el delegado es seguro para subprocesos y se puede invocar simultáneamente en distintas instancias de contexto.

Limitaciones

  • Las consultas compiladas solo se pueden usar en un único modelo de EF Core. A veces, se pueden configurar diferentes instancias de contexto del mismo tipo para usar modelos diferentes. No se admite la ejecución de consultas compiladas en este escenario.
  • Al usar parámetros en consultas compiladas, use parámetros simples y escalares. No se admiten expresiones de parámetros más complejas, como accesos a miembros o métodos en instancias.

Parametrización y almacenamiento en caché de consultas

Cuando EF recibe un árbol de consulta LINQ para su ejecución, primero debe "compilar" ese árbol, por ejemplo, generar una instancia de SQL a partir de él. Dado que esta tarea es un proceso intensivo, EF almacena en caché las consultas en forma del árbol de consulta, de modo que las consultas con la misma estructura vuelvan a usar las salidas de compilación almacenadas en caché internamente. Este almacenamiento en caché permite ejecutar la misma consulta LINQ varias veces de forma muy rápida, incluso si los valores del parámetro difieren.

Considere estas dos consultas:

var post1 = context.Posts.FirstOrDefault(p => p.Title == "post1");
var post2 = context.Posts.FirstOrDefault(p => p.Title == "post2");

Dado que los árboles de expresión contienen constantes diferentes, el árbol de expresión difiere y cada una de estas consultas se compilará por separado mediante EF Core. Además, cada consulta genera un comando SQL ligeramente diferente:

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Posts] AS [b]
WHERE [b].[Name] = N'post1'

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Posts] AS [b]
WHERE [b].[Name] = N'post2'

Dado que SQL difiere, es probable que el servidor de bases de datos también tenga que generar un plan de consulta para ambas consultas, en lugar de reutilizar el mismo plan.

Una pequeña modificación de las consultas puede cambiar considerablemente:

var postTitle = "post1";
var post1 = context.Posts.FirstOrDefault(p => p.Title == postTitle);
postTitle = "post2";
var post2 = context.Posts.FirstOrDefault(p => p.Title == postTitle);

Dado que el nombre del blog ahora está parametrizado, ambas consultas tienen la misma forma de árbol y EF solo debe compilarse una vez. El SQL generado también se parametriza, lo que permite que la base de datos reutilice el mismo plan de consulta:

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Posts] AS [b]
WHERE [b].[Name] = @__postTitle_0

Tenga en cuenta que no es necesario parametrizar cada consulta: es aceptable tener algunas consultas con constantes y, de hecho, las bases de datos (y EF) a veces pueden optimizar en torno a constantes, algo que no es posible cuando se parametriza la consulta. Consulte la sección sobre consultas construidas dinámicamente para ver un ejemplo en el que la parametrización adecuada es fundamental.

Nota:

Las métricas de EF Core notifican la tasa de aciertos de caché de consultas. En una aplicación normal, esta métrica alcanza el 100 % poco después del inicio del programa, una vez que la mayoría de las consultas se han ejecutado al menos una vez. Si esta métrica permanece por debajo del 100 % de forma constante, la aplicación podría estar haciendo algo que perjudica la caché de consultas. En ese caso, sería buena idea investigar qué ocurre.

Nota:

La forma en que la base de datos administra los planes de consulta de caché depende de la base de datos. Por ejemplo, SQL Server mantiene implícitamente una caché del plan de consulta LRU, mientras que PostgreSQL no (pero las instrucciones preparadas pueden producir un efecto final muy similar). Para más información, consulte la documentación de la base de datos.

Consultas construidas dinámicamente

En algunas situaciones, es necesario construir dinámicamente consultas LINQ en lugar de especificarlas directamente en el código fuente. Esto puede ocurrir, por ejemplo, en un sitio web que recibe detalles arbitrarios de consulta de un cliente, con operadores de consulta abiertos (ordenación, filtrado, paginación...). En principio, si se realizan correctamente, las consultas construidas dinámicamente pueden ser tan eficaces como las normales (aunque no es posible usar la optimización de consultas compiladas con consultas dinámicas). Sin embargo, en la práctica suelen causar problemas de rendimiento, ya que es fácil generar accidentalmente árboles de expresión con formas que difieren cada vez.

En el ejemplo siguiente se usan tres técnicas para construir la expresión lambda Where de una consulta:

  1. API Expression con constante: compila dinámicamente la expresión con la API Expression mediante un nodo constante. Esto es un error frecuente al compilar dinámicamente árboles de expresión y hace que EF vuelva a compilar la consulta cada vez que se invoque con un valor constante diferente (normalmente también provoca la contaminación de la caché del plan en el servidor de bases de datos).
  2. API Expression con parámetro: una versión mejor, que sustituye la constante por un parámetro. Esto garantiza que la consulta solo se compile una vez independientemente del valor proporcionado y se genere el mismo SQL (con parámetros).
  3. Simple con parámetro: una versión que no usa la API Expression para la comparación. Esto crea el mismo árbol que el método anterior, pero es mucho más sencillo. En muchos casos, es posible compilar dinámicamente el árbol de expresión sin recurrir a la API Expression, que suele causar errores.

Agregamos un operador Where a la consulta solo si el parámetro especificado no tiene un valor nulo. Tenga en cuenta que este no es un buen caso de uso para construir dinámicamente una consulta, pero lo usamos por su sencillez:

[Benchmark]
public int ExpressionApiWithConstant()
{
    var url = "blog" + Interlocked.Increment(ref _blogNumber);
    using var context = new BloggingContext();

    IQueryable<Blog> query = context.Blogs;

    if (_addWhereClause)
    {
        var blogParam = Expression.Parameter(typeof(Blog), "b");
        var whereLambda = Expression.Lambda<Func<Blog, bool>>(
            Expression.Equal(
                Expression.MakeMemberAccess(
                    blogParam,
                    typeof(Blog).GetMember(nameof(Blog.Url)).Single()),
                Expression.Constant(url)),
            blogParam);

        query = query.Where(whereLambda);
    }

    return query.Count();
}

La prueba comparativa de estas dos técnicas proporciona los siguientes resultados:

Método Media Error StdDev Gen0 Gen1 Asignado
ExpressionApiWithConstant 1,665.8 us 56,99 us 163,5 us 15,6250 - 109,92 KB
ExpressionApiWithParameter 757,1 us 35,14 us 103,6 us 12,6953 0,9766 54,95 KB
SimpleWithParameter 760,3 us 37,99 us 112,0 us 12,6953 - 55,03 KB

Una diferencia menor a un milisegundo puede parecer pequeña, pero tenga en cuenta que la versión constante contamina continuamente la memoria caché y hace que otras consultas se vuelvan a compilar (y se ralenticen) y esto implica un impacto negativo general en el rendimiento global. Se recomienda encarecidamente evitar la recompilación de consultas constantes.

Nota:

Evite construir consultas con la API de árbol de expresión a menos que realmente lo necesite. Además de la complejidad de la API, su uso puede causar fácilmente problemas de rendimiento significativos.

Modelos compilados

Los modelos compilados pueden mejorar el tiempo de inicio de EF Core para aplicaciones con modelos grandes. Un modelo grande suele implicar cientos o miles de relaciones y tipos de entidad. En este contexto, el tiempo de inicio es el tiempo de realización de la primera operación en DbContext cuando ese tipo de DbContext se usa por primera vez en la aplicación. Tenga en cuenta que la creación de una instancia de DbContext no hace que se inicialice el modelo de EF. En su lugar, las primeras operaciones típicas que hacen que el modelo se inicialice incluyen llamar a DbContext.Add o ejecutar la primera consulta.

Los modelos compilados se crean con la herramienta de línea de comandos dotnet ef. Asegúrese de que ha instalado la versión más reciente de la herramienta antes de continuar.

Se usa un nuevo comando dbcontext optimize para generar el modelo compilado. Por ejemplo:

dotnet ef dbcontext optimize

Las opciones --output-dir y --namespace se pueden usar para especificar el directorio y el espacio de nombres en el que se generará el modelo compilado. Por ejemplo:

PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels> dotnet ef dbcontext optimize --output-dir MyCompiledModels --namespace MyCompiledModels
Build started...
Build succeeded.
Successfully generated a compiled model, to use it call 'options.UseModel(MyCompiledModels.BlogsContextModel.Instance)'. Run this command again when the model is modified.
PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels>

La salida de la ejecución de este comando incluye un fragmento de código para copiar y pegar en la configuración de DbContext para hacer que EF Core use el modelo compilado. Por ejemplo:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseModel(MyCompiledModels.BlogsContextModel.Instance)
        .UseSqlite(@"Data Source=test.db");

Arranque de modelos compilados

Normalmente no es necesario mirar el código de arranque generado, pero a veces puede ser útil personalizar el modelo o su carga. El código de arranque tiene una apariencia similar a la siguiente:

[DbContext(typeof(BlogsContext))]
partial class BlogsContextModel : RuntimeModel
{
    private static BlogsContextModel _instance;
    public static IModel Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new BlogsContextModel();
                _instance.Initialize();
                _instance.Customize();
            }

            return _instance;
        }
    }

    partial void Initialize();

    partial void Customize();
}

Se trata de una clase parcial con métodos parciales que se pueden implementar para personalizar el modelo según sea necesario.

Además, se pueden generar varios modelos compilados para tipos DbContext que pueden usar diferentes modelos en función de alguna configuración del entorno de ejecución. Estos deben colocarse en diferentes carpetas y espacios de nombres, como se muestra anteriormente; a continuación se puede examinar la información del entorno de ejecución, como la cadena de conexión, y se devuelve el modelo correcto según sea necesario. Por ejemplo:

public static class RuntimeModelCache
{
    private static readonly ConcurrentDictionary<string, IModel> _runtimeModels
        = new();

    public static IModel GetOrCreateModel(string connectionString)
        => _runtimeModels.GetOrAdd(
            connectionString, cs =>
            {
                if (cs.Contains("X"))
                {
                    return BlogsContextModel1.Instance;
                }

                if (cs.Contains("Y"))
                {
                    return BlogsContextModel2.Instance;
                }

                throw new InvalidOperationException("No appropriate compiled model found.");
            });
}

Limitaciones

Los modelos compilados tienen algunas limitaciones:

Debido a estas limitaciones, solo debe usar modelos compilados si el tiempo inicio de EF Core es demasiado lento. La compilación de modelos pequeños normalmente no merece la pena.

Si la compatibilidad con cualquiera de estas características es fundamental para el éxito, vote por los problemas adecuados vinculados anteriormente.

Reducción de la sobrecarga en tiempo de ejecución

Al igual que con cualquier capa, EF Core agrega un poco de sobrecarga en tiempo de ejecución en comparación con la codificación directa con las API de base de datos de nivel inferior. Es poco probable que esta sobrecarga en tiempo de ejecución afecte a la mayoría de las aplicaciones del mundo real de forma significativa. Los otros temas de esta guía de rendimiento, como la eficacia de las consultas, el uso de índices y la minimización de los recorridos de ida y vuelta, son mucho más importantes. Además, incluso para aplicaciones altamente optimizadas, la latencia de red y la E/S de base de datos normalmente dominarán cualquier tiempo dedicado dentro de EF Core. Sin embargo, para aplicaciones de alto rendimiento y baja latencia en las que cada bit de rendimiento es importante, se pueden usar las siguientes recomendaciones para reducir la sobrecarga de EF Core al mínimo:

  • Active agrupación dbContext. Nuestras pruebas comparativas muestran que esta característica puede tener un impacto decisivo en aplicaciones de alto rendimiento y baja latencia.
    • Asegúrese de que maxPoolSize corresponde a su escenario de uso. Si es demasiado bajo, las instancias de DbContext se crearán y eliminarán constantemente, lo que perjudicará el rendimiento. Si es demasiado alto, puede consumir memoria innecesariamente, ya que las instancias de DbContext no usadas se mantienen en el grupo.
    • Para aumentar un poco más el rendimiento, puede usar PooledDbContextFactory en lugar de tener instancias de contexto de inserción de dependencias directamente. La administración de DI de agrupación DbContext produce una ligera sobrecarga.
  • Use consultas precompiladas para consultas activas.
    • Cuanto más compleja sea la consulta LINQ —cuantos más operadores contenga y mayor sea el árbol de expresión resultante— mayores serán las ganancias que puede esperar de las consultas compiladas.
  • Plantéese deshabilitar las comprobaciones de seguridad de subprocesos. Para ello, establezca EnableThreadSafetyChecks en false en la configuración del contexto.
    • No se admite el uso de la misma instancia de DbContext simultáneamente desde diferentes subprocesos. EF Core tiene una característica de seguridad que detecta este error de programación en muchos casos (pero no todos) e inicia inmediatamente una excepción informativa. Sin embargo, esta característica de seguridad agrega cierta sobrecarga en tiempo de ejecución.
    • ADVERTENCIA: Solo deshabilite las comprobaciones de seguridad de subprocesos si ha probado exhaustivamente que la aplicación no contiene estos errores de simultaneidad.