⚡ Optimiser les performances SQL Server dans une application .NET : les optimisations qui font vraiment la différence

Quand une application commence à ralentir, le premier réflexe est souvent de regarder le code C#, l’API ou l’infrastructure.

Pourtant, sur beaucoup d’applications métier, une grande partie du temps de réponse se joue au niveau de la base de données.

Une requête exécutée 2 000 fois.
Un SELECT qui récupère beaucoup trop de colonnes.
Un index mal adapté.
Un traitement qui effectue un aller-retour SQL dans chaque itération d’une boucle.

Pris séparément, ces problèmes semblent parfois anodins.

À grande échelle, ils peuvent complètement dégrader les performances d’une application.

Dans cet article, je partage l’approche que j’utilise pour optimiser les accès SQL Server dans mes applications .NET :

👉 mesurer avant de modifier
👉 réduire les accès inutiles à la base
👉 optimiser les requêtes Entity Framework
👉 utiliser les index intelligemment
👉 traiter correctement les gros volumes
👉 surveiller les performances après déploiement


🔹 1. Avant d’optimiser : mesurer

La première règle est probablement la plus importante :

je n’optimise pas une requête simplement parce qu’elle “a l’air lente”.

Je commence par identifier précisément où le temps est consommé.

Quelques éléments que je regarde :

✔ temps d’exécution
✔ nombre d’appels SQL
✔ volume de données retourné
✔ CPU / I/O SQL Server
✔ requête SQL réellement générée
✔ plan d’exécution

Avec Entity Framework Core, il est très facile d’oublier qu’une requête LINQ finit par devenir une requête SQL.

Par exemple :

var projects = await context.Projects
    .Where(x => x.IsActive)
    .ToListAsync();

Avant de chercher à optimiser EF Core lui-même, je veux comprendre :

.NET
 ↓
Entity Framework
 ↓
SQL généré
 ↓
SQL Server
 ↓
Plan d'exécution

Le plan d’exécution réel est particulièrement intéressant : il montre les opérateurs utilisés par SQL Server et fournit des informations d’exécution réelles, contrairement au plan estimé qui repose sur les estimations de l’optimiseur. Microsoft Learn

👉 Une optimisation sérieuse commence toujours par une mesure.


🔹 2. Réduire les allers-retours vers la base

C’est probablement l’un des problèmes que je rencontre le plus souvent.

Imaginons un traitement sur plusieurs centaines d’éléments :

foreach (var item in items)
{
    var product = await context.Products
        .FirstOrDefaultAsync(x => x.Id == item.ProductId);

    // traitement...
}

Si items contient 1 000 lignes, on risque de générer jusqu’à 1 000 appels supplémentaires vers SQL Server.

Le code paraît simple.

La base, elle, souffre.

Je préfère récupérer les données nécessaires en une seule fois :

var productIds = items
    .Select(x => x.ProductId)
    .Distinct()
    .ToList();

var products = await context.Products
    .Where(x => productIds.Contains(x.Id))
    .ToDictionaryAsync(x => x.Id);

Puis travailler en mémoire :

foreach (var item in items)
{
    if (products.TryGetValue(item.ProductId, out var product))
    {
        // traitement...
    }
}

On passe alors d’un modèle :

Application
 ↓
SQL
 ↓
Application
 ↓
SQL
 ↓
Application
 ↓
SQL

à :

Application
 ↓
SQL
 ↓
Traitement mémoire

Les allers-retours réseau peuvent représenter une part importante du coût global de l’accès aux données, surtout lorsque l’application et la base ne sont pas hébergées sur la même machine. Microsoft recommande d’ailleurs de surveiller explicitement leur nombre. Microsoft Learn


🔹 3. Ne récupérer que ce dont on a besoin

Autre erreur classique :

charger une entité complète alors qu’on n’utilise que trois propriétés.

Exemple :

var users = await context.Users
    .Where(x => x.IsActive)
    .ToListAsync();

Puis :

return users.Select(x => new UserDto
{
    Id = x.Id,
    Name = x.Name
});

Dans ce cas, SQL Server peut retourner beaucoup plus d’informations que nécessaire.

Je préfère projeter directement :

var users = await context.Users
    .Where(x => x.IsActive)
    .Select(x => new UserDto
    {
        Id = x.Id,
        Name = x.Name
    })
    .ToListAsync();

Cela permet à EF Core de générer une requête qui ne sélectionne que les colonnes utiles.

Sur une petite table, la différence sera probablement négligeable.

Sur plusieurs centaines de milliers de lignes avec des colonnes volumineuses, elle peut devenir significative.

Microsoft recommande explicitement de projeter uniquement les propriétés nécessaires afin de limiter le volume transféré depuis la base. Microsoft Learn


🔹 4. Utiliser AsNoTracking() lorsque la lecture est réellement read-only

Par défaut, Entity Framework suit les entités chargées afin de détecter leurs modifications.

C’est très pratique lorsqu’on souhaite ensuite faire :

entity.Status = Status.Completed;

await context.SaveChangesAsync();

Mais pour une simple consultation, ce suivi n’est pas toujours nécessaire.

Dans ce cas :

var projects = await context.Projects
    .AsNoTracking()
    .Where(x => x.IsActive)
    .ToListAsync();

AsNoTracking() évite une partie du travail lié au Change Tracker.

Je l’utilise notamment pour :

✔ endpoints GET
✔ dashboards
✔ exports
✔ listes de référence
✔ traitements purement consultatifs

Attention cependant :

👉 AsNoTracking() n’est pas une baguette magique.

Si la requête SQL elle-même est mauvaise ou si elle retourne 500 000 lignes, supprimer le tracking ne réglera pas le vrai problème.

Les requêtes sans tracking sont principalement intéressantes lorsque les entités chargées n’ont pas besoin d’être modifiées ensuite. Microsoft Learn


🔹 5. Les index : très puissants… à condition de les choisir correctement

Ajouter un index est souvent l’une des optimisations les plus efficaces.

Mais :

mettre des index partout n’est pas une stratégie.

Prenons une requête :

SELECT Id, Name, Status
FROM Project
WHERE CustomerId = @CustomerId
AND Status = @Status

Un index comme celui-ci peut être pertinent :

CREATE NONCLUSTERED INDEX IX_Project_Customer_Status
ON Project(CustomerId, Status)
INCLUDE(Name);

Pourquoi ?

Parce que les colonnes utilisées pour rechercher les données sont dans la clé de l’index, tandis que certaines colonnes nécessaires au résultat peuvent être ajoutées avec INCLUDE.

Dans certains cas, cela permet à SQL Server de satisfaire entièrement la requête depuis l’index sans retourner lire les données dans la table ou l’index clusterisé. Microsoft Learn

Mais il faut garder en tête une chose :

un index accélère les lectures mais a aussi un coût sur les écritures.

À chaque :

INSERT
UPDATE
DELETE

SQL Server doit également maintenir les index concernés.

Je préfère donc :

✔ analyser les requêtes réellement utilisées
✔ observer le plan d’exécution
✔ regarder les filtres et les jointures
✔ créer les index qui répondent à un besoin réel
✔ mesurer le résultat après modification

Pas :

Cette colonne est souvent utilisée, donc ajoutons un index.


🧭 Clustered, Non-Clustered et Covering Index : ma vision simple

✅ Clustered Index

Un index clusterisé détermine l’organisation physique des lignes de la table selon sa clé.

Il ne peut donc y en avoir qu’un par table. SQL Server utilise par défaut la clé primaire comme index clusterisé dans de nombreux scénarios. Microsoft Learn

Je fais particulièrement attention au choix de cette clé sur les tables volumineuses.


✅ Non-Clustered Index

C’est une structure séparée permettant de retrouver plus rapidement certaines données.

Exemple :

CREATE INDEX IX_Order_CustomerId
ON Orders(CustomerId);

Très utile lorsque CustomerId est fréquemment utilisé comme filtre.


✅ Covering Index

Un covering index contient toutes les informations dont la requête a besoin.

Exemple :

CREATE INDEX IX_Order_CustomerId
ON Orders(CustomerId)
INCLUDE(Status, CreatedAt, TotalAmount);

La requête :

SELECT Status, CreatedAt, TotalAmount
FROM Orders
WHERE CustomerId = @customerId;

peut potentiellement être satisfaite directement par cet index.

👉 Très puissant sur certaines lectures fréquentes.

Mais là encore :

je ne transforme pas chaque index en arbre de Noël avec 25 colonnes incluses.

Les index trop larges consomment davantage d’espace et coûtent plus cher à maintenir lors des modifications. Microsoft Learn


🔹 6. Attention aux traitements en masse

Un cas que je rencontre régulièrement sur les applications métier :

l’import de fichiers Excel.

Imaginons un fichier contenant 50 000 lignes.

La première implémentation peut ressembler à ça :

foreach (var row in rows)
{
    var entity = new Quantity
    {
        ProjectId = row.ProjectId,
        Value = row.Value
    };

    context.Quantities.Add(entity);

    await context.SaveChangesAsync();
}

Là, on cumule plusieurs problèmes :

💥 beaucoup d’appels SQL
💥 beaucoup de transactions
💥 beaucoup de travail côté Entity Framework

Première amélioration :

foreach (var row in rows)
{
    context.Quantities.Add(new Quantity
    {
        ProjectId = row.ProjectId,
        Value = row.Value
    });
}

await context.SaveChangesAsync();

Déjà beaucoup mieux.

Pour des volumes réellement importants, je peux aller plus loin avec :

  • traitement par batchs
  • SqlBulkCopy
  • table temporaire / staging table
  • procédure stockée pour la consolidation

Exemple conceptuel :

Excel
  ↓
Validation
  ↓
Transformation
  ↓
Bulk Insert / Staging Table
  ↓
Traitement SQL
  ↓
Données finales

SqlBulkCopy est justement prévu pour charger efficacement de gros volumes de données dans SQL Server et offre de meilleures performances que des suites d’INSERT individuels dans ce type de scénario. Microsoft Learn


🔹 7. Procédures stockées vs Entity Framework : éviter les guerres de religion

J’entends parfois :

Entity Framework est lent, il faut tout faire en procédure stockée.

Ou l’inverse :

Les procédures stockées sont dépassées, tout doit être en LINQ.

Pour moi, les deux positions sont trop simplistes.

Entity Framework fonctionne très bien pour une grande partie des besoins :

✔ CRUD
✔ requêtes métier classiques
✔ projections
✔ relations
✔ développement rapide et maintenable

Je peux préférer une procédure stockée lorsqu’un traitement implique par exemple :

✔ des agrégations importantes
✔ plusieurs étapes SQL complexes
✔ de gros volumes de données
✔ un traitement naturellement beaucoup plus efficace côté serveur
✔ une logique SQL historique déjà maîtrisée

Exemple :

EXEC dbo.GetProjectIndicators
    @ProjectId = 42,
    @StartDate = '2026-01-01',
    @EndDate = '2026-12-31';

Mais je ne remplace pas une requête EF claire par une procédure stockée juste pour dire que j’ai “optimisé”.

Le SQL brut ou spécifique augmente aussi le coût de maintenance et doit être justifié par le besoin. La documentation EF Core recommande d’ailleurs de ne l’utiliser qu’après avoir vérifié qu’EF ne peut pas générer efficacement la requête souhaitée et que le gain potentiel le justifie. Microsoft Learn

👉 Je choisis l’outil adapté au problème.


🔹 8. async/await améliore la scalabilité, pas le plan SQL

Point important côté .NET.

Cette requête :

var users = await context.Users.ToListAsync();

n’est pas automatiquement plus rapide côté SQL Server que son équivalent synchrone.

Le await ne transforme pas :

SELECT *
FROM Users

en une meilleure requête.

Son intérêt est ailleurs :

pendant l’attente I/O, le thread n’a pas besoin de rester bloqué.

Dans une API qui traite beaucoup de requêtes simultanément, cela améliore la capacité de l’application à gérer la concurrence.

J’utilise donc les API async :

ToListAsync()
FirstOrDefaultAsync()
SaveChangesAsync()
ExecuteReaderAsync()

mais je distingue bien :

Performance SQL
≠
Scalabilité de l'application

Microsoft recommande généralement les API asynchrones pour éviter de bloquer des threads pendant les I/O de base de données. Microsoft Learn


🔹 9. Optimiser une requête… puis continuer à la surveiller

Une requête rapide aujourd’hui ne le restera pas forcément éternellement.

Pourquoi ?

Parce que :

  • le volume de données augmente
  • la distribution des données change
  • les statistiques évoluent
  • de nouveaux index apparaissent
  • le schéma change
  • SQL Server peut sélectionner un plan d’exécution différent

C’est là que des outils comme Query Store deviennent très intéressants.

Query Store permet notamment de conserver un historique :

✔ des requêtes
✔ des plans d’exécution
✔ des statistiques d’exécution
✔ des régressions de performance

Il devient alors possible de voir qu’une requête qui prenait :

120 ms

est soudainement passée à :

2,8 s

et d’investiguer le changement.

SQL Server Query Store conserve justement les plans et statistiques d’exécution dans le temps, ce qui permet notamment d’identifier les requêtes dont les performances ont régressé. Microsoft Learn

Pour moi, c’est une étape souvent oubliée :

optimiser sans monitorer, c’est ne connaître que la moitié du problème.


🧭 Ma méthode lorsque je rencontre une requête lente

Je procède généralement dans cet ordre :

✅ 1. Identifier précisément le traitement lent

API ?
SQL ?
appel externe ?
code métier ?


✅ 2. Observer le SQL réellement exécuté

Je vérifie ce qu’Entity Framework génère réellement.

Parce qu’une requête LINQ élégante peut parfois générer quelque chose de beaucoup moins élégant côté SQL.


✅ 3. Regarder le plan d’exécution

Je cherche notamment :

👉 scans coûteux
👉 index utilisés ou non
👉 gros écarts entre lignes estimées et réelles
👉 opérations de tri importantes
👉 jointures coûteuses


✅ 4. Vérifier le nombre d’appels SQL

Une requête de 20 ms exécutée une fois :

👍

Une requête de 20 ms exécutée 1 000 fois :

💥

Le problème n’est donc pas toujours la requête.

Parfois, c’est la façon dont l’application l’appelle.


✅ 5. Réduire les données transférées

Je regarde :

✔ SELECT inutiles
✔ propriétés inutiles
✔ Include() trop larges
✔ listes non paginées
✔ chargements inutiles d’objets liés


✅ 6. Optimiser les index si nécessaire

Seulement après avoir compris comment la requête travaille.


✅ 7. Mesurer avant / après

Par exemple :

Avant
------
Temps moyen : 2,4 s
Lectures : 180 000
Appels SQL : 420

Après
-----
Temps moyen : 180 ms
Lectures : 8 500
Appels SQL : 3

C’est ce genre de mesure qui permet de dire qu’une optimisation a réellement fonctionné.

Pas simplement :

« Ça me semble plus rapide. »


🎯 Conclusion : optimiser SQL, c’est surtout comprendre où part le temps

Les performances SQL ne se résument pas à écrire des requêtes plus compliquées ou à ajouter des index partout.

Une bonne optimisation consiste surtout à comprendre le chemin complet :

API .NET
   ↓
Entity Framework
   ↓
Requête SQL
   ↓
Plan d'exécution
   ↓
Index
   ↓
Données

Les règles que je retiens :

✔ mesurer avant d’optimiser
✔ réduire les allers-retours SQL
✔ ne récupérer que les données nécessaires
✔ utiliser AsNoTracking() pour les lectures lorsque c’est pertinent
✔ concevoir les index à partir des requêtes réelles
✔ traiter les gros volumes par lots ou avec des mécanismes adaptés
✔ ne pas opposer systématiquement EF Core et procédures stockées
✔ utiliser async/await pour la scalabilité, pas comme optimisation SQL
✔ surveiller les performances dans le temps

Dans beaucoup de cas, le meilleur gain ne vient pas d’une optimisation complexe.

Il vient simplement d’un changement de stratégie :

1000 requêtes
     ↓
1 requête

ou :

SELECT *
     ↓
SELECT uniquement ce dont j'ai besoin

ou encore :

Table Scan
     ↓
Index adapté

C’est souvent là que quelques lignes de code peuvent faire une énorme différence.