Gestion de conflits d'accès concurrentiel

Conseil

Vous pouvez afficher cet exemple sur GitHub.

Dans la plupart des scénarios, les bases de données sont utilisées simultanément par plusieurs instances d’application, chacune effectuant des modifications des données indépendamment les unes des autres. Lorsque les mêmes données sont modifiées en même temps, des incohérences et une altération des données peuvent se produire, par exemple lorsque deux clients modifient des colonnes différentes dans une même ligne, associées d’une certaine manière. Cette page décrit les mécanismes permettant de s’assurer que vos données restent cohérentes face à de telles modifications simultanées.

Accès concurrentiel optimiste

EF Core implémente une concurrence optimiste, qui suppose que les conflits d’accès concurrentiel sont relativement rares. Contrairement aux approches pessimistes qui verrouillent les données avant de les modifier, l’accès concurrentiel optimiste n’utilise pas de verrous, mais provoque l’échec de la modification des données à l’enregistrement des données si les données ont changé depuis qu’elles ont été interrogées. Cet échec d’accès concurrentiel est signalé à l’application, qui le traite en conséquence, éventuellement en réessayant toute l’opération sur les nouvelles données.

Dans EF Core, la concurrence optimiste est implémentée en configurant une propriété en tant que jeton d’accès concurrentiel. Le jeton d’accès concurrentiel est chargé et suivi lorsqu’une entité est interrogée, comme n’importe quelle autre propriété. Lorsqu’une opération de mise à jour ou de suppression est déclenchée lors de SaveChanges(), la valeur du jeton d’accès concurrentiel dans la base de données est comparée à la valeur d’origine lue par EF Core.

Pour comprendre comment cela fonctionne, supposons que nous sommes sur SQL Server et que nous définissons un type d’entité Personne classique avec une propriété Version spéciale :

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

Dans SQL Server, cela configure un jeton d’accès concurrentiel qui change automatiquement dans la base de données chaque fois que la ligne est modifiée (plus de détails sont disponibles ci-dessous). Avec cette configuration en place, examinons ce qui se passe avec une opération de mise à jour simple :

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
context.SaveChanges();
  1. Dans la première étape, une Personne est chargée à partir de la base de données ; cela inclut le jeton d’accès concurrentiel, qui est désormais suivi comme d’habitude par EF, avec le reste des propriétés.
  2. L’instance de Personne est ensuite modifiée d’une certaine manière : nous modifions la propriété FirstName.
  3. Nous demandons ensuite à EF Core de conserver la modification. Étant donné qu’un jeton d’accès concurrentiel est configuré, EF Core envoie le code SQL suivant à la base de données :
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

Notez qu’en plus de PersonId dans la clause WHERE, EF Core a également ajouté une condition pour Version. Cela modifie uniquement la ligne si la colonne Version n’a pas changé depuis le moment où nous l’avons interrogée.

Dans le cas normal (« optimiste »), aucune mise à jour simultanée ne se produit et l’opération UPDATE se termine correctement, en modifiant la ligne ; la base de données signale à EF Core qu’une ligne a été affectée par l’opération UPDATE, comme prévu. Toutefois, si une mise à jour simultanée s’est produite, l’opération UPDATE ne trouve pas de lignes correspondantes et signale qu’aucune n’a été affectée. Par conséquent, le SaveChanges() d’EF Core lève un DbUpdateConcurrencyException, que l’application doit intercepter et gérer de manière appropriée. Les techniques pour ce faire sont détaillées ci-dessous, sous Résolution des conflits d’accès concurrentiel.

Les exemples ci-dessus ont abordé les mises à jour apportées aux entités existantes. EF lève également DbUpdateConcurrencyException lors de la tentative de suppression d’une ligne qui a été modifiée simultanément. Toutefois, cette exception n’est jamais levée lors de l’ajout d’entités ; alors que la base de données peut en effet déclencher une violation de contrainte unique si les lignes avec la même clé sont insérées, cela entraîne la levée d’une exception spécifique au fournisseur, et non DbUpdateConcurrencyException.

Jetons d’accès concurrentiel générés par une base de données native

Dans le code ci-dessus, nous avons utilisé l’attribut [Timestamp] pour mapper une propriété à une colonne SQL Server rowversion. Étant donné que rowversion est modifié automatiquement lorsque la ligne est mise à jour, il est très utile en tant que jeton d’accès concurrentiel à effort minimal qui protège l’intégralité de la ligne. La configuration d’une colonne SQL Server rowversion en tant que jeton d’accès concurrentiel est effectuée comme suit :

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

Le type rowversion indiqué ci-dessus est une fonctionnalité spécifique à SQL Server ; les détails sur la configuration d’un jeton d’accès concurrentiel mis à jour automatiquement diffèrent entre les bases de données, et certaines bases de données ne les prennent pas du tout en charge (par exemple SQLite). Pour plus d’informations, consultez la documentation de votre fournisseur.

Jetons d’accès concurrentiel gérés par l’application

Au lieu que la base de données gère automatiquement le jeton d’accès concurrentiel, vous pouvez le gérer dans le code de l’application. Cela permet d’utiliser l’accès concurrentiel optimiste sur les bases de données, comme SQLite, où aucun type de mise à jour automatique native n’existe. Mais même sur SQL Server, un jeton d’accès concurrentiel géré par l’application peut fournir un contrôle précis sur les modifications de colonne qui entraînent la régénération du jeton. Par exemple, vous pouvez avoir une propriété contenant une valeur mise en cache ou non importante et ne souhaitez pas qu’une modification de cette propriété déclenche un conflit d’accès concurrentiel.

Les éléments suivants configurent une propriété GUID pour qu’elle soit un jeton d’accès concurrentiel :

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }

    [ConcurrencyCheck]
    public Guid Version { get; set; }
}

Étant donné que cette propriété n’est pas générée par la base de données, vous devez l’affecter dans l’application chaque fois que vous conservez les modifications :

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
context.SaveChanges();

Si vous souhaitez qu’une nouvelle valeur GUID soit toujours affectée, vous pouvez le faire via un intercepteur SaveChanges. Toutefois, l’un des avantages de la gestion manuelle du jeton d’accès concurrentiel est que vous pouvez contrôler précisément quand il est régénéré, afin d’éviter les conflits d’accès concurrentiel inutiles.

Résolution des conflits d’accès concurrentiel

Quelle que soit la façon dont votre jeton d’accès concurrentiel est configuré, pour implémenter l’accès concurrentiel optimiste, votre application doit gérer correctement le cas où un conflit d’accès concurrentiel se produit et DbUpdateConcurrencyException est levée ; on appelle cela résoudre un conflit d’accès concurrentiel.

Une option consiste simplement à informer l’utilisateur que la mise à jour a échoué en raison de modifications en conflit ; l’utilisateur peut ensuite charger les nouvelles données et réessayer. Ou si votre application effectue une mise à jour automatisée, elle peut simplement effectuer une boucle et réessayer immédiatement après avoir réinterrogé les données.

Un moyen plus sophistiqué de résoudre les conflits d’accès concurrentiel consiste à fusionner les modifications en attente avec les nouvelles valeurs de la base de données. Les détails précis sur les valeurs à fusionner dépendent de l’application et le processus peut être dirigé par une interface utilisateur, où les deux ensembles de valeurs sont affichés.

Il existe trois ensembles de valeurs disponibles pour résoudre un conflit d’accès concurrentiel :

  • Les Valeurs actuelles sont les valeurs que l’application a tenté d’écrire dans la base de données.
  • Les Valeurs d’origine sont les valeurs qui ont été récupérées à l’origine à partir de la base de données, avant que les modifications soient apportées.
  • Les Valeurs de base de données sont les valeurs actuellement stockées dans la base de données.

L’approche générale pour gérer les conflits d’accès concurrentiel est la suivante :

  1. Interceptez DbUpdateConcurrencyException pendant SaveChanges.
  2. Utilisez DbUpdateConcurrencyException.Entries pour préparer un nouvel ensemble de modifications pour les entités concernées.
  3. Actualisez les valeurs d’origine du jeton d’accès concurrentiel pour refléter les valeurs actuelles dans la base de données.
  4. Recommencez le processus jusqu'à ce qu’aucun conflit ne se produise.

Dans l’exemple suivant, Person.FirstName et Person.LastName sont configurés en tant que jetons d’accès concurrentiel. Il existe un commentaire // TODO: à l’emplacement où vous incluez la logique spécifique de l’application pour choisir la valeur à enregistrer.

using var context = new PersonContext();
// Fetch a person from database and change phone number
var person = context.People.Single(p => p.PersonId == 1);
person.PhoneNumber = "555-555-5555";

// Change the person's name in the database to simulate a concurrency conflict
context.Database.ExecuteSqlRaw(
    "UPDATE dbo.People SET FirstName = 'Jane' WHERE PersonId = 1");

var saved = false;
while (!saved)
{
    try
    {
        // Attempt to save changes to the database
        context.SaveChanges();
        saved = true;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            if (entry.Entity is Person)
            {
                var proposedValues = entry.CurrentValues;
                var databaseValues = entry.GetDatabaseValues();

                foreach (var property in proposedValues.Properties)
                {
                    var proposedValue = proposedValues[property];
                    var databaseValue = databaseValues[property];

                    // TODO: decide which value should be written to database
                    // proposedValues[property] = <value to be saved>;
                }

                // Refresh original values to bypass next concurrency check
                entry.OriginalValues.SetValues(databaseValues);
            }
            else
            {
                throw new NotSupportedException(
                    "Don't know how to handle concurrency conflicts for "
                    + entry.Metadata.Name);
            }
        }
    }
}

Utilisation des niveaux d’isolation pour le contrôle d’accès concurrentiel

L’accès concurrentiel optimiste via des jetons d’accès concurrentiel n’est pas la seule façon de s’assurer que les données restent cohérentes face aux modifications simultanées.

Un mécanisme pour garantir la cohérence est le niveau d’isolation des transactions de lecture reproductible. Dans la plupart des bases de données, ce niveau garantit qu’une transaction voit les données de la base de données telle qu’elle était au démarrage de la transaction, sans être affectée par une activité simultanée ultérieure. En prenant notre exemple de base ci-dessus, lorsque nous interrogeons la Person pour la mettre à jour d’une certaine façon, la base de données doit s’assurer qu’aucune autre transaction n’interfère avec cette ligne de base de données tant que la transaction n’est pas terminée. Selon l’implémentation de votre base de données, cela se produit de l’une des deux manières suivantes :

  1. Lorsque la ligne est interrogée, votre transaction prend un verrou partagé sur celle-ci. Toute transaction externe qui tente de mettre à jour la ligne est bloquée jusqu’à la fin de votre transaction. Il s’agit d’une forme de verrouillage pessimiste qui est implémentée par le niveau d’isolation « lecture reproductible » de SQL Server.
  2. Au lieu de verrouiller, la base de données permet à la transaction externe de mettre à jour la ligne, mais lorsque votre propre transaction tente de la mettre à jour, une erreur de « sérialisation » est déclenchée, indiquant qu’un conflit d’accès concurrentiel s’est produit. Il s’agit d’une forme de verrouillage optimiste, semblable à la fonctionnalité de jeton d’accès concurrentiel d’EF, implémentée par le niveau d’isolation d’instantané de SQL Server, ainsi que par le niveau d’isolation de lecture reproductible de PostgreSQL.

Notez que le niveau d’isolation « sérialisable » offre les mêmes garanties que les lectures reproductibles (et en ajoute d’autres), de sorte qu’elle fonctionne de la même façon que par rapport à ce qui précède.

L’utilisation d’un niveau d’isolation supérieur pour gérer les conflits d’accès concurrentiel est plus simple, ne nécessite pas de jetons d’accès concurrentiel et offre d’autres avantages. Par exemple, les lectures reproductibles garantissent que votre transaction voit toujours les mêmes données entre les requêtes à l’intérieur de la transaction, ce qui évite les incohérences. Toutefois, cette approche présente des inconvénients.

Tout d’abord, si votre implémentation de base de données utilise le verrouillage pour implémenter le niveau d’isolation, d’autres transactions qui tentent de modifier la même ligne doivent être bloquées pour l’intégralité de la transaction. Cela peut avoir un effet négatif sur les performances simultanées (veillez à utiliser des transactions brèves), mais notez que le mécanisme d’EF lève une exception et vous force à réessayer à la place, ce qui a également un impact. Cela s’applique au niveau de lecture reproductible de SQL Server, mais pas au niveau d’instantané, qui ne verrouille pas les lignes interrogées.

Plus important encore, cette approche nécessite qu’une transaction couvre toutes les opérations. Par exemple, si vous interrogez Person afin d’afficher ses détails à un utilisateur, puis attendez que l’utilisateur apporte des modifications, la transaction doit rester active pendant une durée indéterminée, ce qui doit être évité dans la plupart des cas. Par conséquent, ce mécanisme est généralement approprié lorsque toutes les opérations contenues sont exécutées immédiatement et que la transaction ne dépend pas d’entrées externes qui peuvent augmenter sa durée.

Ressources supplémentaires

Consultez Détection des conflits dans EF Core pour obtenir un exemple ASP.NET Core avec la détection de conflit.