Rubriques sur les performances avancées

Regroupement DbContext

Un DbContext est généralement un objet léger : la création et la suppression d’un objet n’implique pas d’opération de base de données, et la plupart des applications peuvent le faire sans aucun impact notable sur les performances. Toutefois, chaque instance de contexte configure différents services et objets internes nécessaires pour effectuer ses tâches, et la surcharge de cette opération peut être importante dans les scénarios hautes performances. Dans ce cas, EF Core peut mettre en pool vos instances de contexte : lorsque vous supprimez votre contexte, EF Core réinitialise son état et le stocke dans un pool interne ; lorsqu’une nouvelle instance est ensuite demandée, cette instance mise en pool est retournée au lieu de configurer une nouvelle instance. Le regroupement de contextes vous permet de payer les coûts d’installation du contexte une seule fois au démarrage du programme, plutôt qu’en continu.

Notez que le regroupement de contextes est orthogonal au regroupement de connexions de base de données, qui est géré à un niveau inférieur dans le pilote de base de données.

Le modèle classique d’une application ASP.NET Core à l’aide d’EF Core implique l’inscription d’un type de DbContext personnalisé dans le conteneur d’injection de dépendances via AddDbContext. Ensuite, les instances de ce type sont obtenues via des paramètres de constructeur dans des contrôleurs ou des pages Razor.

Pour activer le regroupement de contextes, remplacez simplement AddDbContext par AddDbContextPool:

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

Le paramètre poolSize de AddDbContextPool définit le nombre maximal d’instances conservées par le pool (par défaut, 1024). Une fois poolSize dépassé, les nouvelles instances de contexte ne sont pas mises en cache et EF revient au comportement de non-regroupement de création d’instances à la demande.

Benchmarks

Voici les résultats du benchmark pour extraire une seule ligne d’une base de données SQL Server exécutée localement sur le même ordinateur, avec et sans regroupement de contextes. Comme toujours, les résultats changent avec le nombre de lignes, la latence sur votre serveur de base de données et d’autres facteurs. Il est important de noter que ce test teste les performances de regroupement à thread unique, tandis qu’un scénario réel peut avoir des résultats différents ; référencez votre plateforme avant de prendre des décisions. Le code source est disponible ici, n’hésitez pas à l’utiliser comme base pour vos propres mesures.

Méthode NumBlogs Moyenne Erreur StdDev Gen 0 Gen1 Gen2 Affecté
WithoutContextPooling 1 701.6 us 26.62 us 78.48 us 11.7188 - - 50,38 Ko
WithContextPooling 1 350.1 us 6.80 us 14.64 us 0.9766 - - 4,63 Ko

Gestion de l’état dans les contextes mis en pool

Le regroupement de contextes fonctionne en réutilisant la même instance de contexte entre les requêtes ; cela signifie qu’elle est effectivement inscrite en tant que Singleton, et que la même instance est réutilisée sur plusieurs requêtes (ou étendues di). Cela signifie que des précautions particulières doivent être prises lorsque le contexte implique un état susceptible de changer entre les demandes. Crucialement, la OnConfiguring du contexte n’est appelée qu’une seule fois , lorsque le contexte d’instance est créé pour la première fois, et ne peut donc pas être utilisé pour définir l’état qui doit varier (par exemple, un ID de locataire).

Un scénario classique impliquant un état de contexte serait une application multilocataire ASP.NET Core, où l’instance de contexte a un ID de locataire qui est pris en compte par les requêtes (voir filtres de requêtes globaux pour plus d’informations). Étant donné que l’ID de locataire doit changer avec chaque requête web, nous devons suivre certaines étapes supplémentaires pour que tout fonctionne avec le regroupement de contextes.

Supposons que votre application inscrit un service ITenant étendu, qui encapsule l’ID de locataire et toutes les autres informations relatives au locataire :

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

Comme indiqué ci-dessus, portez une attention particulière à l’endroit où vous obtenez l’ID de locataire à partir duquel il s’agit d’un aspect important de la sécurité de votre application.

Une fois que nous avons notre ITenant service étendu, inscrivez une fabrique de contexte de regroupement en tant que service Singleton, comme d’habitude :

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

Ensuite, écrivez une fabrique de contexte personnalisée qui obtient un contexte mis en pool à partir de la fabrique Singleton que nous avons inscrite et injecte l’ID de locataire dans les instances de contexte qu’il transmet :

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

Une fois que nous avons notre fabrique de contexte personnalisée, inscrivez-la en tant que service étendu :

builder.Services.AddScoped<WeatherForecastScopedFactory>();

Enfin, arrangez l’injection d’un contexte à partir de notre fabrique étendue :

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

À ce stade, vos contrôleurs sont automatiquement injectés avec une instance de contexte qui a l’ID de locataire approprié, sans avoir à en savoir plus.

Le code source complet de cet exemple est disponible ici.

Remarque

Bien qu’EF Core s’occupe de réinitialiser l’état interne pour DbContext et ses services associés, il ne réinitialise généralement pas l’état dans le pilote de base de données sous-jacent, qui se trouve en dehors d’EF. Par exemple, si vous ouvrez et utilisez manuellement un DbConnection ou si vous manipulez ADO.NET’état, vous devez restaurer cet état avant de renvoyer l’instance de contexte au pool, par exemple en fermant la connexion. L’échec de cette opération peut entraîner la fuite de l’état sur les demandes non liées.

Requêtes compilées

Quand EF reçoit une arborescence de requêtes LINQ pour l’exécution, elle doit d’abord « compiler » cette arborescence, par exemple produire SQL à partir de celle-ci. Étant donné que cette tâche est un processus lourd, EF met en cache les requêtes par la forme de l’arborescence des requêtes, afin que les requêtes avec la même structure réutilisent les sorties de compilation mises en cache en interne. Cette mise en cache garantit que l’exécution de la même requête LINQ plusieurs fois est très rapide, même si les valeurs des paramètres diffèrent.

Toutefois, EF doit toujours effectuer certaines tâches avant de pouvoir utiliser le cache de requêtes interne. Par exemple, l’arborescence d’expressions de votre requête doit être récursivement comparée aux arborescences d’expressions des requêtes mises en cache, pour rechercher la requête mise en cache correcte. La surcharge pour ce traitement initial est négligeable dans la majorité des applications EF, en particulier par rapport aux autres coûts associés à l’exécution des requêtes (E/S réseau, traitement des requêtes réels et E/S de disque sur la base de données...). Toutefois, dans certains scénarios hautes performances, il peut être souhaitable de l’éliminer.

EF prend en charge requêtes compilées, qui permettent la compilation explicite d’une requête LINQ dans un délégué .NET. Une fois que ce délégué est acquis, il peut être appelé directement pour exécuter la requête, sans fournir l’arborescence d’expressions LINQ. Cette technique contourne la recherche de cache et fournit le moyen le plus optimisé d’exécuter une requête dans EF Core. Voici quelques résultats de test comparant les performances des requêtes compilées et non compilées ; référencez votre plateforme avant de prendre des décisions. Le code source est disponible ici, n’hésitez pas à l’utiliser comme base pour vos propres mesures.

Méthode NumBlogs Moyenne Erreur StdDev Gen 0 Affecté
WithCompiledQuery 1 564.2 us 6.75 us 5.99 us 1.9531 9 Ko
WithoutCompiledQuery 1 671.6 us 12.72 us 16.54 us 2.9297 13 Ko
WithCompiledQuery 10 645.3 us 10.00 us 9.35 us 2.9297 13 Ko
WithoutCompiledQuery 10 709.8 us 25.20 us 73.10 us 3.9063 18 Ko

Pour utiliser des requêtes compilées, commencez par compiler une requête avec EF.CompileAsyncQuery comme suit (utilisez EF.CompileQuery pour les requêtes synchrones) :

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

Dans cet exemple de code, nous fournissons EF avec une expression lambda acceptant une instance de DbContext et un paramètre arbitraire à passer à la requête. Vous pouvez maintenant appeler ce délégué chaque fois que vous souhaitez exécuter la requête :

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

Notez que le délégué est thread-safe et peut être appelé simultanément sur différentes instances de contexte.

Limites

  • Les requêtes compilées peuvent uniquement être utilisées sur un modèle EF Core unique. Différentes instances de contexte du même type peuvent parfois être configurées pour utiliser différents modèles ; l’exécution de requêtes compilées dans ce scénario n’est pas prise en charge.
  • Lorsque vous utilisez des paramètres dans des requêtes compilées, utilisez des paramètres scalaires simples. Les expressions de paramètre plus complexes , telles que les accès aux membres/méthodes sur les instances, ne sont pas prises en charge.

Mise en cache et paramétrage des requêtes

Quand EF reçoit une arborescence de requêtes LINQ pour l’exécution, elle doit d’abord « compiler » cette arborescence, par exemple produire SQL à partir de celle-ci. Étant donné que cette tâche est un processus lourd, EF met en cache les requêtes par la forme de l’arborescence des requêtes, afin que les requêtes avec la même structure réutilisent les sorties de compilation mises en cache en interne. Cette mise en cache garantit que l’exécution de la même requête LINQ plusieurs fois est très rapide, même si les valeurs des paramètres diffèrent.

Tenez compte des deux requêtes suivantes :

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

Étant donné que les arborescences d’expressions contiennent des constantes différentes, l’arborescence d’expressions diffère et chacune de ces requêtes sera compilée séparément par EF Core. En outre, chaque requête produit une commande SQL légèrement différente :

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'

Étant donné que SQL diffère, votre serveur de base de données devra probablement également produire un plan de requête pour les deux requêtes, plutôt que de réutiliser le même plan.

Une petite modification de vos requêtes peut changer considérablement les choses :

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

Étant donné que le nom du blog est désormais paramétrable, les deux requêtes ont la même forme d’arborescence et EF ne doit être compilé qu’une seule fois. Le SQL produit est également paramétré, ce qui permet à la base de données de réutiliser le même plan de requête :

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

Notez qu’il n’est pas nécessaire de paramétrer chaque requête : il est parfaitement correct d’avoir certaines requêtes avec des constantes, et en effet, les bases de données (et EF) peuvent parfois effectuer certaines optimisations autour des constantes qui ne sont pas possibles lorsque la requête est paramétrée. Consultez la section sur requêtes construites dynamiquement pour obtenir un exemple de paramétrage approprié.

Remarque

Les compteurs d’événements d’EF Core signalent le taux d’accès au cache de requêtes. Dans une application normale, ce compteur atteint 100 % peu après le démarrage du programme, une fois que la plupart des requêtes ont été exécutées au moins une fois. Si ce compteur reste stable en dessous de 100 %, c’est une indication que votre application peut faire quelque chose qui défait le cache de requêtes , il est judicieux d’examiner cela.

Remarque

La façon dont la base de données gère les plans de requête de cache dépend de la base de données. Par exemple, SQL Server conserve implicitement un cache de plan de requête LRU, tandis que PostgreSQL ne le fait pas (mais les instructions préparées peuvent produire un effet final très similaire). Pour plus d’informations, consultez la documentation de votre base de données.

Requêtes construites dynamiquement

Dans certaines situations, il est nécessaire de construire dynamiquement des requêtes LINQ plutôt que de les spécifier directement dans le code source. Cela peut se produire, par exemple, dans un site web qui reçoit des détails de requête arbitraires d’un client, avec des opérateurs de requête ouverts (tri, filtrage, pagination...). En principe, si elles sont effectuées correctement, les requêtes construites dynamiquement peuvent être aussi efficaces que les requêtes régulières (bien qu’il ne soit pas possible d’utiliser l’optimisation des requêtes compilées avec des requêtes dynamiques). Toutefois, dans la pratique, ils sont souvent la source de problèmes de performances, car il est facile de produire accidentellement des arborescences d’expressions avec des formes qui diffèrent à chaque fois.

L’exemple suivant utilise trois techniques pour construire l’expression lambda Where d’une requête :

  1. API Expression avec des constantes : générez dynamiquement l’expression avec l’API Expression à l’aide d’un nœud constant. Il s’agit d’une erreur fréquente lors de la création dynamique d’arborescences d’expressions et provoque la recompilation de la requête chaque fois qu’elle est appelée avec une valeur constante différente (elle provoque généralement une pollution du cache du plan sur le serveur de base de données).
  2. API Expression avec le paramètre: une meilleure version, qui remplace la constante par un paramètre. Cela garantit que la requête n’est compilée qu’une seule fois, quelle que soit la valeur fournie, et que le même SQL (paramétré) est généré.
  3. Simple avec le paramètre: version qui n’utilise pas l’API Expression, pour la comparaison, qui crée la même arborescence que la méthode ci-dessus, mais est beaucoup plus simple. Dans de nombreux cas, il est possible de créer dynamiquement votre arborescence d’expressions sans recourir à l’API Expression, ce qui est facile à se tromper.

Nous ajoutons un opérateur Where à la requête uniquement si le paramètre donné n’est pas null. Notez que ce n’est pas un bon cas d’usage pour construire dynamiquement une requête, mais nous l’utilisons pour simplifier :

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

L’évaluation de ces deux techniques donne les résultats suivants :

Méthode Moyenne Erreur StdDev Gen0 Première génération Affecté
ExpressionApiWithConstant 1,665.8 us 56.99 us 163.5 us 15.6250 - 109,92 Ko
ExpressionApiWithParameter 757.1 us 35.14 us 103.6 us 12.6953 0.9766 54,95 Ko
SimpleWithParameter 760.3 us 37.99 us 112.0 us 12.6953 - 55.03 Ko

Même si la différence de sous-millisecondes semble faible, gardez à l’esprit que la version constante pollue en permanence le cache et provoque la recompilation d’autres requêtes, les ralentissant ainsi et ayant un impact négatif général sur vos performances globales. Il est vivement recommandé d’éviter la recompilation de requête constante.

Remarque

Évitez de construire des requêtes avec l’API de l’arborescence d’expressions, sauf si vous avez vraiment besoin. Outre la complexité de l’API, il est très facile de provoquer par inadvertance des problèmes de performances significatifs lors de leur utilisation.

Modèles compilés

Les modèles compilés peuvent améliorer le temps de démarrage EF Core pour les applications avec des modèles volumineux. Un modèle volumineux signifie généralement des centaines à des milliers de types d’entités et de relations. L’heure de démarrage ici est le moment d’effectuer la première opération sur un DbContext lorsque ce type DbContext est utilisé pour la première fois dans l’application. Notez que la création d’une instance de DbContext n’entraîne pas l’initialisation du modèle EF. Au lieu de cela, les premières opérations classiques qui entraînent l’initialisation du modèle incluent l’appel DbContext.Add ou l’exécution de la première requête.

Les modèles compilés sont créés à l’aide de l’outil en ligne de commande dotnet ef. Vérifiez que vous avez installé la dernière version de l’outil avant de continuer.

Une nouvelle commande dbcontext optimize est utilisée pour générer le modèle compilé. Par exemple :

dotnet ef dbcontext optimize

Les options --output-dir et --namespace peuvent être utilisées pour spécifier le répertoire et l’espace de noms dans lesquels le modèle compilé sera généré. Par exemple :

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 sortie de l’exécution de cette commande inclut un élément de code permettant de copier-coller dans votre configuration de DbContext pour que EF Core utilise le modèle compilé. Par exemple :

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

Amorçage du modèle compilé

En général, il n’est pas nécessaire d’examiner le code d’amorçage généré. Toutefois, il peut parfois être utile de personnaliser le modèle ou son chargement. Le code d’amorçage se présente comme suit :

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

Il s’agit d’une classe partielle avec des méthodes partielles qui peuvent être implémentées pour personnaliser le modèle en fonction des besoins.

De plus, plusieurs modèles compilés peuvent être générés pour les types DbContext qui peuvent utiliser différents modèles en fonction de certaines configurations d’exécution. Ils doivent être placés dans différents dossiers et espaces de noms, comme indiqué ci-dessus. Les informations d’exécution, telles que la chaîne de connexion, peuvent ensuite être examinées et le modèle correct retourné si nécessaire. Par exemple :

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

Limites

Les modèles compilés présentent certaines limitations :

En raison de ces limitations, vous ne devez utiliser des modèles compilés que si le temps de démarrage de votre EF Core est trop lent. La compilation de petits modèles ne vaut généralement pas la peine.

Si vous prenez en charge l’une de ces fonctionnalités est essentielle à votre réussite, votez pour les problèmes appropriés liés ci-dessus.

Réduction de la surcharge du runtime

Comme pour n’importe quelle couche, EF Core ajoute un peu de surcharge d’exécution par rapport au codage directement sur les API de base de données de niveau inférieur. Cette surcharge d’exécution n’a pas d’impact sur la plupart des applications réelles de manière significative; les autres rubriques de ce guide de performances, telles que l’efficacité des requêtes, l’utilisation des index et la réduction des allers-retours, sont beaucoup plus importantes. En outre, même pour les applications hautement optimisées, la latence réseau et les E/S de base de données dominent généralement tout temps passé à l’intérieur d’EF Core lui-même. Toutefois, pour les applications hautes performances et à faible latence où chaque bit de perf est important, les recommandations suivantes peuvent être utilisées pour réduire au minimum la surcharge EF Core :

  • Activer regroupement DbContext ; nos benchmarks montrent que cette fonctionnalité peut avoir un impact décisif sur les applications à faible latence et à haute latence.
    • Assurez-vous que le maxPoolSize correspond à votre scénario d’utilisation ; s’il est trop faible, DbContext instances seront constamment créées et supprimées, ce qui dégrade les performances. La définition trop élevée peut être inutile de consommer de la mémoire, car les instances non utilisées DbContext sont conservées dans le pool.
    • Pour un boost de perf supplémentaire, envisagez d’utiliser PooledDbContextFactory au lieu d’avoir des instances de contexte d’injection de DI directement. La gestion des DI de DbContext regroupement entraîne une légère surcharge.
  • Utilisez des requêtes précompilées pour les requêtes à chaud.
    • Plus la requête LINQ est complexe , plus les opérateurs qu’il contient et plus l’arborescence d’expressions résultante est grande, plus les gains peuvent être attendus à l’aide de requêtes compilées.
  • Envisagez de désactiver les vérifications de sécurité des threads en définissant EnableThreadSafetyChecks sur false dans votre configuration de contexte.
    • L’utilisation simultanée de la même instance DbContext à partir de différents threads n’est pas prise en charge. EF Core dispose d’une fonctionnalité de sécurité qui détecte ce bogue de programmation dans de nombreux cas (mais pas tous), et lève immédiatement une exception informative. Toutefois, cette fonctionnalité de sécurité ajoute une surcharge d’exécution.
    • AVERTISSEMENT : désactiver uniquement les vérifications de sécurité des threads après avoir soigneusement testé que votre application ne contient pas de tels bogues d’accès concurrentiel.