Entity Framework Core : accélérer les accès avec les requêtes précompilées, la mise en cache et le DbContext pooling
Cet article fait partie de la série Entity Framework : 6 sur 7.
- Part 1 - Comprendre la "magie" derriere Entity Framework
- Part 2 - Le Database Context (EFcore)
- Part 3 - Le Change Tracker (EFcore)
- Part 4 - Bonnes pratiques pour les requêtes (EFcore)
- Part 5 - Manipuler le Change Tracker (EFcore)
- Part 6 - Cet article
- Part 7 - Mesurer et diagnostiquer les performances EF Core
1. Requêtes précompilées
1.1. Principe
Par défaut, EF Core analyse l’expression LINQ puis la traduit en SQL. Quand une requête est exécutée très souvent, il est possible de compiler cette requête une fois, puis de réutiliser le délégué compilé.
1.2. Compiled vs precompiled queries (et selon la version d’EF)
Le vocabulaire peut porter à confusion, car on voit souvent les deux termes.
- Compiled query : c’est le terme officiel des API EF Core avec
EF.CompileQuery/EF.CompileAsyncQuery. - Precompiled query : c’est souvent utilisé comme synonyme dans les articles, mais techniquement cela peut aussi désigner une génération anticipée au build (AOT) dans les versions récentes.
En pratique, retenez ceci :
- EF6 (Entity Framework “classique”) : on utilisait
CompiledQuery.Compile(...)et le gain pouvait être significatif sur des requêtes très répétées. - EF Core 1 à 7 : EF Core met déjà en cache une partie du pipeline de traduction. Les compiled queries explicites existent toujours et servent surtout sur les hot paths.
- EF Core 8+ : on garde
EF.CompileQuerypour les scénarios classiques, et on peut aussi rencontrer la notion de precompiled queries dans le contexte Native AOT/source generation.
Conclusion rapide : dans la plupart des projets, quand on dit “requête précompilée” en EF Core, on parle généralement des compiled queries via EF.CompileQuery.
1.3. Exemple
using Microsoft.EntityFrameworkCore;
public static class ProductQueries
{
// Requête asynchrone compilée et réutilisable
public static readonly Func<AppDbContext, int, IAsyncEnumerable<ProductDto>>
GetActiveProductById = EF.CompileAsyncQuery(
(AppDbContext db, int id) =>
db.Products
.AsNoTracking()
.Where(p => p.Id == id && p.IsActive)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
);
}
public sealed record ProductDto(int Id, string Name, decimal Price);
Utilisation :
var dto = await ProductQueries
.GetActiveProductById(context, productId)
.FirstOrDefaultAsync(cancellationToken);
1.4. Quand l’utiliser
- Requête très fréquente et stable (même forme, seuls les paramètres changent).
- Endpoints à fort trafic.
- Scénarios de lecture où la latence est critique.
1.5. Limites
- Gain faible pour les requêtes occasionnelles.
- Complexité supplémentaire si la requête évolue souvent.
- La compilation aide surtout le pipeline EF, pas une requête SQL lente et mal indexée.
2. Mise en cache applicative
2.1. Principe
Si certaines données changent peu, inutile d’interroger la base à chaque appel. On peut utiliser IMemoryCache pour les lectures rapides en local (ou un cache distribué pour plusieurs instances).
2.2. Exemple avec IMemoryCache
using Microsoft.Extensions.Caching.Memory;
public class ProductReadService
{
private readonly AppDbContext _db;
private readonly IMemoryCache _cache;
public ProductReadService(AppDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken ct)
{
var cacheKey = $"product:{id}";
if (_cache.TryGetValue(cacheKey, out ProductDto? cached))
{
return cached;
}
var dto = await _db.Products
.AsNoTracking()
.Where(p => p.Id == id && p.IsActive)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstOrDefaultAsync(ct);
if (dto is null)
{
return null;
}
_cache.Set(cacheKey, dto, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(5),
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
});
return dto;
}
}
2.3. Bonnes pratiques cache
- Mettre en cache des DTO, pas des entités suivies par le Change Tracker.
- Définir une stratégie d’invalidation claire (TTL, événement, suppression explicite).
- Éviter un TTL trop long sur des données métier sensibles.
- Mesurer le ratio hit/miss pour valider le gain.
3. DbContext pooling
3.1. Principe
Créer un DbContext à chaque requête a un coût. Le pooling permet de réutiliser des instances de contexte réinitialisées, ce qui réduit les allocations et la pression du GC.
3.2. Configuration
builder.Services.AddDbContextPool<AppDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
});
3.3. Points d’attention
- Un DbContext reste non thread-safe : une instance, un flux d’exécution.
- Ne pas stocker d’état métier mutable dans le contexte.
- Préférer des traitements courts par unité de travail.
- Vérifier les comportements spécifiques si vous injectez des services dépendants du contexte.
4. Combiner les 3 leviers
La combinaison la plus efficace en lecture fréquente :
- cache en premier niveau,
- requête compilée en fallback,
- contexte poolé pour limiter le coût de création.
Exemple de logique :
public async Task<ProductDto?> GetProductFastAsync(int id, CancellationToken ct)
{
var key = $"product:{id}";
if (_cache.TryGetValue(key, out ProductDto? dto))
{
return dto;
}
dto = await ProductQueries
.GetActiveProductById(_db, id)
.FirstOrDefaultAsync(ct);
if (dto is not null)
{
_cache.Set(key, dto, TimeSpan.FromMinutes(10));
}
return dto;
}
5. Checklist performance EF Core
- Requêtes de lecture en AsNoTracking().
- Projections DTO via
Selectpour limiter les colonnes. - Requêtes précompilées seulement sur les hot paths.
- Cache avec invalidation explicite.
- AddDbContextPool pour les applications à charge soutenue.
- Index SQL alignés avec vos filtres et vos tris.
- Mesure réelle avec logs, traces et benchmarks avant/après.
Conclusion
Les performances EF Core ne reposent pas sur un seul bouton magique. Le bon résultat vient de la combinaison :
- requêtes efficaces,
- cache maîtrisé,
- cycle de vie du contexte optimisé.
Commencez par mesurer vos endpoints les plus lents, appliquez ces trois leviers sur les chemins critiques, puis validez le gain en production avec de la télémétrie.