Aller au contenu

Privilégier les mises à jour en masse

Introduction

Dans une application utilisant Entity Framework Core, la modification de données est souvent réalisée en chargeant les entités concernées dans le contexte de données via une requête LINQ to Entities, puis en modifiant leurs propriétés, et enfin en appelant la méthode SaveChangesAsync. Cette approche est naturelle et parfaitement adaptée lorsque les modifications dépendent d’une logique métier propre à chaque entité.

Elle devient toutefois moins performante lorsqu’une même modification doit être appliquée à un grand nombre d’enregistrements. Charger toutes les entités en mémoire uniquement pour modifier une ou plusieurs propriétés engendre des lectures et des transferts de données inutiles. Le suivi des modifications mobilise également des ressources, tandis que la persistance peut nécessiter l’exécution de nombreuses requêtes SQL sur le serveur de base de données.

Depuis la version 7 d'Entity Framework Core, la méthode ExecuteUpdateAsync permet d’effectuer une mise à jour en masse directement dans la base de données en une seule requête, sans charger ni suivre les entités dans le DbContext.

Dans cet article, nous allons comparer les deux approches, découvrir comment utiliser ExecuteUpdateAsync et identifier les précautions nécessaires avant de choisir d'utiliser une mise à jour en masse.

Le modèle utilisé dans les exemples

Pour illustrer les différentes approches, prenons l’exemple d’une application de commerce électronique contenant des produits :

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }
    public bool IsAvailable { get; set; }
    public DateTime? DiscontinuedAt { get; set; }
    public int CategoryId { get; set; }
    public byte[] Version { get; set; } = [];
}

Le contexte de données expose ces produits au moyen d’un jeu d'entités de type DbSet<Product> :

public class ApplicationDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();

    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }
}

Nous devons modifier le prix de tous les produits d'une catégorie afin de les diminuer de 10 %.

Modifier les entités de manière unitaire

Une première implémentation consiste à charger les produits d'une catégorie dans le contexte de données, à modifier leur prix, puis à enregistrer les changements dans la base de données :

var products = await dbContext.Products
    .Where(product => product.CategoryId == categoryId)
    .ToListAsync(cancellationToken);

foreach (var product in products)
    product.Price *= 0.90m;

await dbContext.SaveChangesAsync(cancellationToken);

Ce code est simple, mais son exécution implique plusieurs opérations :

  1. Exécuter une requête SELECT pour récupérer tous les produits de la catégorie.

  2. Transférer les produits de la base de données vers l’application.

  3. Matérialiser et suivre chaque produit.

  4. Modifier le prix de chaque produit en mémoire.

  5. Détecter les changements grâce au mécanisme de suivi (Change Tracker).

  6. Envoyer les commandes UPDATE lors de l’appel à SaveChangesAsync.

EF Core regroupe les requêtes UPDATE dans un même lot afin de limiter le nombre d’allers-retours avec la base de données. Cependant, ce regroupement, appelé batching, ne transforme pas nécessairement la modification des prix en une seule requête SQL ensembliste : une requête UPDATE est générée pour chaque produit modifié.

Ainsi, plus le nombre de produits augmente, plus la matérialisation des objets, leur suivi et l’exécution des commandes SQL représentent un coût important, pénalisant les performances.

Exécuter une mise à jour en masse avec la méthode ExecuteUpdateAsync

Lorsque tous les enregistrements doivent subir une modification pouvant être exprimée en SQL, il est judicieux d’exécuter ce traitement directement sur le serveur de base de données. Voici un exemple :

var updatedProductCount = await dbContext.Products
    .Where(product => product.CategoryId == categoryId)
    .ExecuteUpdateAsync(
        setters => setters.SetProperty(
            product => product.Price,
            product => product.Price * 0.90m),
        cancellationToken);

La méthode Where définit les produits pour lesquels le prix doit être modifié. La méthode SetProperty définit la propriété à modifier et l’expression permettant de calculer sa nouvelle valeur. Avec SQL Server, EF Core traduit cette requête LINQ to Entities en requête SQL suivante :

UPDATE Products
SET Price = Price * 0.90
WHERE CategoryId = @categoryId;

Avec cette requête LINQ to Entities, les performances sont nettement améliorées pour les raisons suivantes :

  • Aucune requête SELECT n'est exécutée pour obtenir les produits à mettre à jour.

  • Aucun produit n’est chargé dans la mémoire de l’application.

  • Aucun produit n’est matérialisé en entité.

  • Le mécanisme de suivi des modifications n’est pas sollicité.

  • Une seule requête UPDATE est exécutée immédiatement sur le serveur de base de données.

La méthode retourne également le nombre de lignes affectées. Cette information peut être enregistrée dans les journaux ou utilisée pour vérifier le résultat de l’opération :

logger.LogInformation("{UpdatedProductCount} produits ont été mis à jour.", updatedProductCount);

Il s’agit d’un traitement ensembliste : la base de données applique la même règle à toutes les lignes concernées.

Plusieurs appels à la méthode SetProperty peuvent être chaînés afin de modifier différentes colonnes dans une seule commande SQL. Par exemple, pour retirer tous les produits d’une catégorie du catalogue, nous pouvons modifier leur indicateur de disponibilité et leur date de retrait :

DateTime discontinuedAt = DateTime.UtcNow;

int updatedProductCount = await dbContext.Products
    .Where(product => product.CategoryId == categoryId)
    .ExecuteUpdateAsync(
        setters => setters
            .SetProperty(
                product => product.IsAvailable,
                false)
            .SetProperty(
                product => product.DiscontinuedAt,
                discontinuedAt),
        cancellationToken);

Enfin, contrairement aux modifications effectuées sur des entités suivies dans le contexte de données, la méthode ExecuteUpdateAsync exécute immédiatement la requête SQL. Il n’est donc pas nécessaire d'exécuter la méthode SaveChangesAsync.

Attention aux entités déjà suivies

La méthode ExecuteUpdateAsync contourne le mécanisme de suivi des modifications. Par conséquent, les entités déjà suivies dans le contexte de données ne sont pas mises à jour lorsque la requête UPDATE (de mise à jour en masse) est exécutée.

var product = await dbContext.Products
    .SingleAsync(
        product => product.Id == productId,
        cancellationToken);

await dbContext.Products
    .Where(product => product.Id == productId)
    .ExecuteUpdateAsync(
        setters => setters.SetProperty(
            product => product.IsAvailable,
            false),
        cancellationToken);

Après l'exécution de la méthode ExecuteUpdateAsync, le produit mis à jour est indiqué comme indisponible dans la base de données, mais la propriété IsAvailable de l'objet product conserve la valeur initialement chargée. Ainsi, cet objet est devenu obsolète.

Une exécution ultérieure de la méthode SaveChangesAsync peut écraser une modification réalisée en masse si la même propriété est ensuite modifiée sur le produit suivi. Il est donc préférable d’éviter de combiner des modifications effectuées avec ExecuteUpdateAsync et des modifications portant sur les mêmes données suivies par le contexte de données.

Utiliser les transactions

L’exécution de la méthode ExecuteUpdateAsync est atomique : soit la requête est exécutée avec succès, soit aucune ligne n’est modifiée. Plusieurs appels successifs à la méthode ExecuteUpdateAsync ne sont pas automatiquement regroupés dans une transaction commune.

Lorsque plusieurs opérations doivent réussir ou échouer ensemble, une transaction doit être créée de manière explicite :

await using var transaction = await dbContext.Database
    .BeginTransactionAsync(cancellationToken);

try
{
    await dbContext.Products
        .Where(product => product.CategoryId == sourceCategoryId)
        .ExecuteUpdateAsync(
            setters => setters.SetProperty(
                product => product.IsAvailable,
                false),
            cancellationToken);

    await dbContext.Products
        .Where(product => product.CategoryId == targetCategoryId)
        .ExecuteUpdateAsync(
            setters => setters.SetProperty(
                product => product.IsAvailable,
                true),
            cancellationToken);

    await transaction.CommitAsync(cancellationToken);
}
catch
{
    await transaction.RollbackAsync(cancellationToken);
    throw;
}

La transaction garantit ici que la disponibilité des produits des deux catégories est modifiée de manière cohérente.

Supprimer des données en masse

EF Core propose également de supprimer toutes les lignes correspondant à une requête, via la méthode ExecuteDeleteAsync :

int deletedProductCount = await dbContext.Products
    .Where(product =>
        !product.IsAvailable &&
        product.DiscontinuedAt < deletionDate)
    .ExecuteDeleteAsync(cancellationToken);

Cette instruction est traduite en une seule requête DELETE. De même que pour la méthode ExecuteUpdateAsync, elle s’exécute immédiatement, contourne le suivi des modifications et nécessite les mêmes précautions concernant les transactions.