Temas de rendimiento avanzado
Agrupación de DbContext
Un DbContext
es generalmente un objeto ligero: la creación y eliminación de una 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: al eliminar el contexto, EF Core restablece su estado y lo almacena en un grupo interno; cuando se solicita una nueva instancia, se devuelve esa instancia agrupada en lugar de configurar una nueva. La agrupación de contextos le permite pagar los costos 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 ortogonal a 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 de ASP.NET Core que usa EF Core implica registrar un tipo personalizado DbContext en el contenedor de inserción de dependencias mediante 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, basta con reemplazar por AddDbContext
AddDbContextPool:
builder.Services.AddDbContextPool<WeatherForecastContext>(
o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));
El poolSize
parámetro de AddDbContextPool establece el número máximo de instancias que conserva el grupo (el valor predeterminado es 1024). Una vez poolSize
superado, las nuevas instancias de contexto no se almacenan en caché y EF vuelve al comportamiento de no agrupación de la creación 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 el mismo equipo, con y sin agrupación de contextos. Como siempre, los resultados cambiarán con el número de filas, la latencia en el servidor de bases de datos y otros factores. Importantemente, esta prueba comparativa compara el rendimiento de agrupación de un solo subproceso, mientras que un escenario contendido en el mundo real puede tener resultados diferentes; benchmark en la 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 | Allocated |
---|---|---|---|---|---|---|---|---|
WithoutContextPooling | 1 | 701.6 | 26.62 nosotros | 78.48 nosotros | 11.7188 | - | - | 50,38 KB |
WithContextPooling | 1 | 350.1 nosotros | 6.80 nosotros | 14.64 nosotros | 0.9766 | - | - | 4,63 KB |
Administración del estado en contextos agrupados
La agrupación de contextos funciona mediante la reutilización de la misma instancia de contexto entre solicitudes; esto significa que se registra eficazmente como singleton y la misma instancia se reutiliza en varias solicitudes (o ámbitos de inserción de dependencias). Esto significa que se debe tener especial cuidado cuando el contexto implique cualquier estado que pueda cambiar entre solicitudes. Fundamentalmente, 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 un estado de contexto sería una aplicación de ASP.NET Core multiinquilino, 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 con ITenant
á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 se ha escrito 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 nuestro servicio con ITenant
ámbito, registre un generador de contextos de agrupación como un servicio Singleton, como de costumbre:
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 las 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 contextos personalizados, regístrelo como un 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());
Como punto, los controladores se insertan automáticamente con una instancia de contexto que tiene el identificador de inquilino correcto, sin tener que saber nada sobre él.
El código fuente completo de este ejemplo está disponible aquí.
Nota
Aunque EF Core se encarga de restablecer el estado interno para 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 manualmente y usa o DbConnection
manipula de otro modo ADO.NET estado, es necesario restaurar ese estado antes de devolver la instancia de contexto al grupo, por ejemplo, cerrando la conexión. Si no lo hace, es posible 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 SQL a partir de él. Dado que esta tarea es un proceso intensivo, EF almacena en caché las consultas por la forma del árbol de consultas, de modo que las consultas con la misma estructura reutilicen las salidas de compilación almacenadas internamente en caché. Este almacenamiento en caché garantiza que la ejecución de la misma consulta LINQ varias veces es muy rápida, incluso si los valores de parámetro difieren.
Sin embargo, EF todavía debe realizar ciertas tareas para poder usar la caché de consultas interna. Por ejemplo, el árbol de expresiones 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 de este procesamiento inicial es insignificante en la mayoría de las aplicaciones de EF, especialmente cuando se comparan con otros costos 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 deseable 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 expresiones 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; benchmark en la 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 | Allocated |
---|---|---|---|---|---|---|
WithCompiledQuery | 1 | 564.2 | 6.75 nosotros | 5.99 nosotros | 1.9531 | 9 KB |
WithoutCompiledQuery | 1 | 671.6 nosotros | 12.72 nosotros | 16.54 nosotros | 2.9297 | 13 KB |
WithCompiledQuery | 10 | 645.3 | 10.00 us | 9.35 | 2.9297 | 13 KB |
SinCompiledQuery | 10 | 709.8 | 25.20 us | 73.10 | 3.9063 | 18 KB |
Para usar consultas compiladas, primero compile 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 DbContext
instancia 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 diferentes 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 utilizar modelos diferentes; No se admite la ejecución de consultas compiladas en este escenario.
- Al usar parámetros en consultas compiladas, use parámetros escalares simples. No se admiten expresiones de parámetro más complejas, como accesos a miembros o métodos en instancias.
Almacenamiento en caché de consultas y parametrización
Cuando EF recibe un árbol de consulta LINQ para su ejecución, primero debe "compilar" ese árbol, por ejemplo, generar SQL a partir de él. Dado que esta tarea es un proceso intensivo, EF almacena en caché las consultas por la forma del árbol de consulta, de modo que las consultas con la misma estructura reutilizan las salidas de compilación almacenadas internamente en caché. Este almacenamiento en caché garantiza que ejecutar la misma consulta LINQ varias veces es muy rápido, incluso si los valores de parámetro difieren.
Tenga en cuenta las dos consultas siguientes:
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 por EF Core. Además, cada consulta genera un comando SQL ligeramente diferente:
SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = N'blog1'
SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = N'blog2'
Dado que SQL difiere, es probable que el servidor de base 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 a la base de datos reutilizar el mismo plan de consulta:
SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = @__blogName_0
Tenga en cuenta que no es necesario parametrizar cada consulta y cada consulta: es perfectamente adecuado tener algunas consultas con constantes y, de hecho, las bases de datos (y EF) a veces pueden realizar cierta optimización en torno a constantes que no son posibles cuando se parametriza la consulta. Consulte la sección sobre consultas construidas dinámicamente para ver un ejemplo en el que es fundamental la parametrización adecuada.
Nota
Los contadores de eventos de EF Core notifican la tasa de aciertos de caché de consultas. En una aplicación normal, este contador 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 este contador permanece estable por debajo del 100 %, es una indicación de que la aplicación puede estar haciendo algo que derrota la memoria caché de consultas, es una buena idea investigarlo.
Nota
La forma en que la base de datos administra los planes de consulta en 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). Consulte la documentación de la base de datos para obtener más detalles.
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 realiza 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 ser el origen de problemas de rendimiento, ya que es fácil producir 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 de Where
una consulta:
- EXPRESSION API con constante: compile dinámicamente la expresión con expression API mediante un nodo constante. Se trata de 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).
- Expression API con parámetro: una versión mejor, que sustituye la constante por un parámetro . Esto garantiza que la consulta solo se compila una vez, independientemente del valor proporcionado, y se genera el mismo SQL (con parámetros).
- Sencillo con parámetro: una versión que no usa expression API, para la comparación, que 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 expresiones sin recurrir a expression API, lo que es fácil de equivocar.
; agregamos un Where
operador a la consulta solo si el parámetro especificado no es NULL. Tenga en cuenta que este no es un buen caso de uso para construir dinámicamente una consulta, pero lo usamos para simplificar:
[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 | Allocated |
---|---|---|---|---|---|---|
ExpressionApiWithConstant | 1.665.8 | 56.99 nosotros | 163.5 | 15.6250 | - | 109.92 KB |
ExpressionApiWithParameter | 757.1 | 35.14 | 103.6 | 12.6953 | 0.9766 | 54,95 KB |
SimpleWithParameter | 760.3 | 37.99 nosotros | 112.0 | 12.6953 | - | 55,03 KB |
Incluso si la diferencia de submilisegundos parece pequeña, tenga en cuenta que la versión constante contamina continuamente la memoria caché y hace que se vuelvan a compilar otras consultas, ralentizarlas también y tener un impacto negativo general en el rendimiento general. Se recomienda encarecidamente evitar la recompilación de consultas constantes.
Nota
Evite construir consultas con la API de árbol de expresiones a menos que realmente necesite. Aparte de la complejidad de la API, es muy fácil causar accidentalmente problemas de rendimiento significativos al usarlos.
Modelos compilados
Los modelos compilados pueden mejorar el tiempo de inicio de EF Core para aplicaciones con modelos grandes. Un modelo grande normalmente significa cientos a miles de tipos de entidad y relaciones. La hora de inicio aquí es la hora de realizar la primera operación en un DbContext
cuando ese DbContext
tipo se usa por primera vez en la aplicación. Tenga en cuenta que simplemente crear una DbContext
instancia 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>
- Para obtener más información, vea
dotnet ef dbcontext optimize
. - Si está más cómodo trabajando en Visual Studio, también puede usar Optimize-DbContext.
La salida de la ejecución de este comando incluye un fragmento de código para copiar y pegar en la DbContext
configuración para 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 DbContext
los tipos que pueden usar diferentes modelos en función de alguna configuración en tiempo 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:
- No se admiten filtros de consulta globales.
- No se admiten la carga diferida ni los servidores proxy de seguimiento de cambios.
- El modelo se debe sincronizar manualmente y regenerarlo cada vez que cambie su definición o configuración.
- No se admiten implementaciones personalizadas de IModelCacheKeyFactory. Pero puede compilar varios modelos y cargar el adecuado según sea necesario.
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 directamente con las API de base de datos de nivel inferior. Esta sobrecarga en tiempo de ejecución es poco probable que afecte a la mayoría de las aplicaciones del mundo real de una manera significativa; Los otros temas de esta guía de rendimiento, como la eficiencia 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 el tiempo dedicado dentro de EF Core. Sin embargo, para las 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 a un mínimo:
- Active la agrupación de DbContext; nuestras pruebas comparativas muestran que esta característica puede tener un impacto decisivo en las aplicaciones de alta latencia y baja latencia.
- Asegúrese de que
maxPoolSize
corresponde a su escenario de uso; si es demasiado bajo,DbContext
las instancias se crearán y eliminarán constantemente, degradando el rendimiento. Establecerlo demasiado alto puede consumir memoria innecesariamente, ya que las instancias no utilizadaDbContext
se mantienen en el grupo. - Para un aumento de rendimiento pequeño adicional, considere la posibilidad de usar
PooledDbContextFactory
en lugar de tener instancias de contexto de inserción de dependencias directamente. La administración de dependencias deDbContext
agrupación conlleva una ligera sobrecarga.
- Asegúrese de que
- 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), se pueden esperar más ganancias mediante consultas compiladas.
- Considere la posibilidad de deshabilitar las comprobaciones de seguridad de subprocesos estableciendo
EnableThreadSafetyChecks
en false en la configuración del contexto.- No se admite el uso de la misma
DbContext
instancia simultáneamente desde subprocesos diferentes. 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: Deshabilite solo las comprobaciones de seguridad de subprocesos después de probar exhaustivamente que la aplicación no contenga estos errores de simultaneidad.
- No se admite el uso de la misma