Gestione dei conflitti di concorrenza

Suggerimento

È possibile visualizzare l'esempio di questo articolo in GitHub.

Nella maggior parte degli scenari, i database vengono usati simultaneamente da più istanze dell'applicazione, ognuna delle quali esegue modifiche ai dati indipendentemente l'una dall'altra. Quando gli stessi dati vengono modificati contemporaneamente, possono verificarsi incoerenze e danneggiamento dei dati, ad esempio quando due client modificano colonne diverse nella stessa riga correlate in qualche modo. Questa pagina illustra i meccanismi per garantire che i dati rimangano coerenti rispetto a tali modifiche simultanee.

Concorrenza ottimistica

EF Core implementa la concorrenza ottimistica, presupponendo che i conflitti di concorrenza siano relativamente rari. A differenza degli approcci pessimistici , che bloccano i dati in anticipo e quindi procedono solo a modificarli, la concorrenza ottimistica non accetta blocchi, ma prevede che la modifica dei dati abbia esito negativo in caso di salvataggio se i dati sono stati modificati dopo la query. Questo errore di concorrenza viene segnalato all'applicazione, che ne gestisce di conseguenza, possibilmente ritentando l'intera operazione sui nuovi dati.

In EF Core la concorrenza ottimistica viene implementata configurando una proprietà come token di concorrenza. Il token di concorrenza viene caricato e rilevato quando viene eseguita una query su un'entità, esattamente come qualsiasi altra proprietà. Quindi, quando viene eseguita un'operazione di aggiornamento o eliminazione durante SaveChanges(), il valore del token di concorrenza nel database viene confrontato con il valore originale letto da EF Core.

Per comprendere il funzionamento, si supponga di essere in SQL Server e di definire un tipo di entità Person tipico con una proprietà speciale Version :

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

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

In SQL Server viene configurato un token di concorrenza che cambia automaticamente nel database ogni volta che viene modificata la riga (sono disponibili altri dettagli di seguito). Con questa configurazione, esaminiamo cosa accade con un'operazione di aggiornamento semplice:

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
context.SaveChanges();
  1. Nel primo passaggio, un oggetto Person viene caricato dal database; include il token di concorrenza, che viene ora monitorato come di consueto da Entity Framework insieme al resto delle proprietà.
  2. L'istanza Person viene quindi modificata in qualche modo. La proprietà viene modificata FirstName .
  3. Viene quindi indicato a EF Core di rendere persistente la modifica. Poiché è configurato un token di concorrenza, EF Core invia il codice SQL seguente al database:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

Si noti che, oltre a PersonId nella clausola WHERE, EF Core ha aggiunto anche una condizione per Version . Questa modifica solo la riga se la Version colonna non è cambiata dal momento in cui è stata eseguita una query.

Nel caso normale ("ottimistico") non viene eseguito alcun aggiornamento simultaneo e l'aggiornamento viene completato correttamente, modificando la riga; il database segnala a EF Core che una riga è stata interessata dall'aggiornamento, come previsto. Tuttavia, se si è verificato un aggiornamento simultaneo, l'aggiornamento non riesce a trovare le righe e i report corrispondenti interessati da zero. Di conseguenza, EF Core SaveChanges() genera un DbUpdateConcurrencyExceptionoggetto , che l'applicazione deve intercettare e gestire in modo appropriato. Le tecniche per eseguire questa operazione sono descritte di seguito, in Risoluzione dei conflitti di concorrenza.

Mentre gli esempi precedenti hanno illustrato gli aggiornamenti alle entità esistenti. Ef genera DbUpdateConcurrencyException anche un'eccezione quando si tenta di eliminare una riga modificata contemporaneamente. Tuttavia, questa eccezione non viene mai generata quando si aggiungono entità; mentre il database può effettivamente generare una violazione di vincolo univoco se vengono inserite righe con la stessa chiave, ciò comporta la generazione di un'eccezione specifica del provider e non DbUpdateConcurrencyException.

Token di concorrenza generati dal database nativo

Nel codice precedente è stato usato l'attributo per eseguire il [Timestamp] mapping di una proprietà a una colonna di SQL Server rowversion . Poiché rowversion cambia automaticamente quando la riga viene aggiornata, è molto utile come token di concorrenza minimo che protegge l'intera riga. La configurazione di una colonna di SQL Server rowversion come token di concorrenza viene eseguita come segue:

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

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

Il tipo illustrato in precedenza è una funzionalità specifica di SQL Server. I rowversion dettagli relativi alla configurazione di un token di concorrenza ad aggiornamento automatico differiscono tra i database e alcuni database non supportano affatto questi database (ad esempio SQLite). Per informazioni dettagliate, consultare la documentazione del provider.

Token di concorrenza gestiti dall'applicazione

Anziché fare in modo che il database gestisca automaticamente il token di concorrenza, è possibile gestirlo nel codice dell'applicazione. Ciò consente di usare la concorrenza ottimistica nei database, ad esempio SQLite, in cui non esiste alcun tipo di aggiornamento automatico nativo. Tuttavia, anche in SQL Server, un token di concorrenza gestito dall'applicazione può fornire un controllo granulare su esattamente quali modifiche di colonna causano la rigenerazione del token. Ad esempio, potrebbe essere disponibile una proprietà contenente un valore memorizzato nella cache o non importante e non si vuole apportare una modifica a tale proprietà per attivare un conflitto di concorrenza.

Di seguito viene configurata una proprietà GUID come token di concorrenza:

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

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

Poiché questa proprietà non viene generata dal database, è necessario assegnarla all'applicazione ogni volta che vengono mantenute le modifiche:

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

Se si vuole assegnare sempre un nuovo valore GUID, è possibile farlo tramite un SaveChanges intercettore. Tuttavia, uno dei vantaggi della gestione manuale del token di concorrenza è che è possibile controllare con precisione quando viene rigenerato, per evitare conflitti di concorrenza inutili.

Risoluzione dei conflitti di concorrenza

Indipendentemente dal modo in cui viene configurato il token di concorrenza, per implementare la concorrenza ottimistica, l'applicazione deve gestire correttamente il caso in cui si verifica un conflitto di concorrenza e DbUpdateConcurrencyException viene generato. Questa operazione viene chiamata risoluzione di un conflitto di concorrenza.

Un'opzione consiste nel informare semplicemente l'utente che l'aggiornamento non è riuscito a causa di modifiche in conflitto; l'utente può quindi caricare i nuovi dati e riprovare. In alternativa, se l'applicazione esegue un aggiornamento automatico, può semplicemente eseguire un ciclo e riprovare immediatamente, dopo aver eseguito una nuova query sui dati.

Un modo più sofisticato per risolvere i conflitti di concorrenza consiste nell'unire le modifiche in sospeso con i nuovi valori nel database. I dettagli precisi dei valori uniti dipendono dall'applicazione e il processo può essere indirizzato da un'interfaccia utente, in cui vengono visualizzati entrambi i set di valori.

Sono disponibili tre set di valori per risolvere un conflitto di concorrenza:

  • I valori correnti sono i valori che l'applicazione stava tentando di scrivere nel database.
  • I valori originali sono i valori che sono stati originariamente recuperati dal database, prima di apportare eventuali modifiche.
  • I valori del database sono i valori attualmente archiviati nel database.

L'approccio generale per gestire i conflitti di concorrenza è il seguente:

  1. Intercettare DbUpdateConcurrencyException durante SaveChanges.
  2. Usare DbUpdateConcurrencyException.Entries per preparare un nuovo set di modifiche per le entità interessate.
  3. Aggiornare i valori originali del token di concorrenza in modo da riflettere i valori correnti nel database.
  4. Ripetere il processo fino a quando non si verificano conflitti.

Nell'esempio Person.FirstName seguente e Person.LastName vengono configurati come token di concorrenza. È presente un commento // TODO: nella posizione in cui viene inclusa la logica specifica dell'applicazione per scegliere il valore da salvare.

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

Uso dei livelli di isolamento per il controllo della concorrenza

La concorrenza ottimistica tramite token di concorrenza non è l'unico modo per garantire che i dati rimangano coerenti in caso di modifiche simultanee.

Un meccanismo per garantire la coerenza è il livello di isolamento delle transazioni di lettura ripetibile. Nella maggior parte dei database, questo livello garantisce che una transazione visualizzi i dati nel database così come era all'avvio della transazione, senza essere interessato da alcuna attività simultanea successiva. Prendendo l'esempio di base riportato in precedenza, quando si esegue una query per Person aggiornarlo in qualche modo, il database deve assicurarsi che nessun'altra transazione interferisca con tale riga del database fino al completamento della transazione. A seconda dell'implementazione del database, questo avviene in due modi:

  1. Quando viene eseguita una query sulla riga, la transazione accetta un blocco condiviso. Qualsiasi transazione esterna che tenta di aggiornare la riga bloccherà fino al completamento della transazione. Si tratta di una forma di blocco pessimistico e viene implementata dal livello di isolamento "lettura ripetibile" di SQL Server.
  2. Anziché bloccare, il database consente alla transazione esterna di aggiornare la riga, ma quando la propria transazione tenta di eseguire l'aggiornamento, verrà generato un errore di "serializzazione", che indica che si è verificato un conflitto di concorrenza. Si tratta di una forma di blocco ottimistico, non a differenza della funzionalità token di concorrenza di ENTITY, e viene implementata dal livello di isolamento dello snapshot di SQL Server, nonché dal livello di isolamento delle letture ripetibili postgreSQL.

Si noti che il livello di isolamento "serializzabile" garantisce le stesse garanzie della lettura ripetibile (e aggiunge altre), in modo che funzioni nello stesso modo rispetto a quanto sopra.

L'uso di un livello di isolamento superiore per gestire i conflitti di concorrenza è più semplice, non richiede token di concorrenza e offre altri vantaggi; Ad esempio, le letture ripetibili garantiscono che la transazione visualizzi sempre gli stessi dati tra query all'interno della transazione, evitando incoerenze. Tuttavia, questo approccio presenta i suoi svantaggi.

Prima di tutto, se l'implementazione del database usa il blocco per implementare il livello di isolamento, le altre transazioni che tentano di modificare la stessa riga devono bloccarsi per l'intera transazione. Questo potrebbe avere un effetto negativo sulle prestazioni simultanee (mantenere la transazione breve!), anche se si noti che il meccanismo di Entity Framework genera un'eccezione e forza invece a riprovare, che ha anche un impatto. Questo vale per il livello di lettura ripetibile di SQL Server, ma non per il livello di snapshot, che non blocca le righe sottoposte a query.

Più importante, questo approccio richiede una transazione per estendere tutte le operazioni. In caso affermativo, eseguire una query Person per visualizzarne i dettagli a un utente e quindi attendere che l'utente possa apportare modifiche, la transazione deve rimanere attiva per un periodo di tempo potenzialmente lungo, che deve essere evitata nella maggior parte dei casi. Di conseguenza, questo meccanismo è in genere appropriato quando tutte le operazioni contenute vengono eseguite immediatamente e la transazione non dipende da input esterni che possono aumentare la durata.

Risorse aggiuntive

Per un esempio di ASP.NET Core con rilevamento dei conflitti, vedere Rilevamento dei conflitti in EF Core .