Delen via


Omgaan met gelijktijdigheidsconflicten

Hint

U kunt het voorbeeld van dit artikel bekijken op GitHub.

In de meeste scenario's worden databases gelijktijdig gebruikt door meerdere toepassingsexemplaren, waarbij wijzigingen in gegevens onafhankelijk van elkaar worden uitgevoerd. Wanneer dezelfde gegevens tegelijkertijd worden gewijzigd, kunnen inconsistenties en beschadiging van gegevens optreden, bijvoorbeeld wanneer twee clients verschillende kolommen in dezelfde rij wijzigen die op een of andere manier zijn gerelateerd. Op deze pagina worden mechanismen besproken om ervoor te zorgen dat uw gegevens consistent blijven ten aanzien van dergelijke gelijktijdige wijzigingen.

Optimistische gelijktijdigheid

EF Core implementeert optimistische gelijktijdigheid, waarbij ervan wordt uitgegaan dat gelijktijdigheidsconflicten relatief zeldzaam zijn. In tegenstelling tot pessimistische benaderingen, die gegevens vooraf vergrendelen en pas vervolgens wijzigen, neemt optimistische gelijktijdigheid geen vergrendelingen, maar zorgt ervoor dat de gegevenswijziging mislukt bij het opslaan als de gegevens zijn gewijzigd sinds de query is uitgevoerd. Deze gelijktijdigheidsfout wordt gerapporteerd aan de toepassing, die ermee omgaat, mogelijk door de hele bewerking opnieuw uit te voeren op de nieuwe gegevens.

In EF Core wordt optimistische gelijktijdigheid geïmplementeerd door een eigenschap te configureren als een gelijktijdigheidstoken. Het gelijktijdigheidstoken wordt geladen en bijgehouden wanneer een entiteit wordt opgevraagd, net als elke andere eigenschap. Wanneer vervolgens een update- of verwijderbewerking wordt uitgevoerd tijdens SaveChanges(), wordt de waarde van het gelijktijdigheidstoken in de database vergeleken met de oorspronkelijke waarde die door EF Core wordt gelezen.

Als u wilt weten hoe dit werkt, gaan we ervan uit dat we zich in SQL Server bevinden en een typisch entiteitstype Persoon definiëren met een speciale Version eigenschap:

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 configureert u hiermee een gelijktijdigheidstoken dat automatisch wordt gewijzigd in de database telkens wanneer de rij wordt gewijzigd (hieronder vindt u meer informatie). Nu deze configuratie is geïmplementeerd, gaan we kijken wat er gebeurt met een eenvoudige updatebewerking:

var person = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
await context.SaveChangesAsync();
  1. In de eerste stap wordt een persoon uit de database geladen, inclusief het gelijktijdigheidstoken, dat zoals gewoonlijk door Entity Framework (EF) samen met de overige eigenschappen wordt bijgehouden.
  2. Het exemplaar van de persoon wordt vervolgens op een of andere manier aangepast. We veranderen de FirstName eigenschap.
  3. Vervolgens wordt EF Core geïnstrueerd om de wijziging voort te zetten. Omdat een gelijktijdigheidstoken is geconfigureerd, verzendt EF Core de volgende SQL naar de database:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

Naast de PersonId WHERE-component heeft EF Core ook een voorwaarde toegevoegd Version . Hierdoor wordt alleen de rij gewijzigd als de Version kolom niet is gewijzigd sinds het moment dat we er query's op hebben uitgevoerd.

In het normale (optimistische) geval vindt er geen gelijktijdige update plaats en wordt de update voltooid, waarbij de rij wordt gewijzigd; de database rapporteert aan EF Core dat één rij is beïnvloed door de UPDATE, zoals verwacht. Als er echter een gelijktijdige update plaatsvond, kan de UPDATE geen overeenkomende rijen vinden en meldt dat er nul rijen zijn beïnvloed. Als gevolg hiervan genereert EF Core SaveChanges() een DbUpdateConcurrencyException, die de toepassing op de juiste wijze moet vangen en verwerken. Technieken om dit te doen, worden hieronder beschreven, onder Gelijktijdigheidsconflicten oplossen.

In de bovenstaande voorbeelden zijn updates voor bestaande entiteiten besproken. EF genereert DbUpdateConcurrencyException ook bij het verwijderen van een rij die gelijktijdig is gewijzigd. Deze uitzondering wordt echter meestal nooit gegenereerd bij het toevoegen van entiteiten; hoewel de database inderdaad een schending van een unieke beperking kan veroorzaken als rijen met dezelfde sleutel worden ingevoegd, resulteert dit in een providerspecifieke uitzondering en niet DbUpdateConcurrencyException.

Door systeemeigen database gegenereerde gelijktijdigheidstokens

In de bovenstaande code hebben we het [Timestamp] kenmerk gebruikt om een eigenschap toe te wijzen aan een SQL Server-kolom rowversion . Aangezien rowversion automatisch verandert wanneer de rij wordt bijgewerkt, is het zeer nuttig als een minimum-inspanning concurrentietoken dat de hele rij beveiligt. Het configureren van een SQL Server-kolom rowversion als een gelijktijdigheidstoken wordt als volgt uitgevoerd:

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

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

Het rowversion bovenstaande type is een SQL Server-specifieke functie. De details over het instellen van een automatisch bijwerkend gelijktijdigheidstoken verschillen tussen databases en sommige databases ondersteunen deze helemaal niet (bijvoorbeeld SQLite). Raadpleeg de documentatie van uw provider voor de exacte details.

Door de applicatie beheerde concurrentietokens

In plaats van dat de database het gelijktijdigheidstoken automatisch beheert, kunt u het in toepassingscode beheren. Dit maakt het gebruik van optimistische gelijktijdigheid mogelijk voor databases, zoals SQLite, waarbij er geen systeemeigen type automatisch bijwerken bestaat. Maar zelfs op SQL Server kan een door de toepassing beheerd gelijktijdigheidstoken nauwkeurige controle bieden over precies welke kolomwijzigingen ertoe leiden dat het token opnieuw wordt gegenereerd. U hebt bijvoorbeeld een eigenschap die een bepaalde cachewaarde of een onbelangrijke waarde bevat, en u wilt niet dat een wijziging van die eigenschap een gelijktijdigheidsconflict veroorzaakt.

Met het volgende configureert u een GUID-eigenschap als een gelijktijdigheidstoken:

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

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

Omdat deze eigenschap niet door de database is gegenereerd, moet u deze toewijzen in de toepassing wanneer deze wijzigingen persistent zijn:

var person = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
await context.SaveChangesAsync();

Als u wilt dat er altijd een nieuwe GUID-waarde wordt toegewezen, kunt u dit doen via een SaveChanges interceptor. Een voordeel van het handmatig beheren van het gelijktijdigheidstoken is echter dat u precies kunt bepalen wanneer het opnieuw wordt gegenereerd, om onnodige gelijktijdigheidsconflicten te voorkomen.

Gelijktijdigheidsconflicten oplossen

Ongeacht hoe uw gelijktijdigheidstoken is ingesteld, moet uw toepassing, om optimistische gelijktijdigheid te implementeren, correct omgaan met het geval waarin een gelijktijdigheidsconflict optreedt en DbUpdateConcurrencyException wordt gegooid; dit wordt het oplossen van een gelijktijdigheidsconflict genoemd.

Een optie is om de gebruiker te informeren dat de update is mislukt vanwege conflicterende wijzigingen; de gebruiker kan de nieuwe gegevens vervolgens laden en het opnieuw proberen. Of als uw toepassing een geautomatiseerde update uitvoert, kan deze eenvoudig herhalen en onmiddellijk opnieuw proberen, nadat de gegevens opnieuw zijn opgevraagd.

Een geavanceerdere manier om gelijktijdigheidsconflicten op te lossen, is door de in behandeling zijnde wijzigingen samen te voegen met de nieuwe waarden in de database. De precieze details van welke waarden worden samengevoegd, zijn afhankelijk van de toepassing en het proces kan worden geleid door een gebruikersinterface, waarbij beide sets waarden worden weergegeven.

Er zijn drie sets waarden beschikbaar om een gelijktijdigheidsconflict op te lossen:

  • Huidige waarden zijn de waarden die de toepassing probeerde te schrijven naar de database.
  • Oorspronkelijke waarden zijn de waarden die oorspronkelijk zijn opgehaald uit de database, voordat er wijzigingen zijn aangebracht.
  • Databasewaarden zijn de waarden die momenteel zijn opgeslagen in de database.

De algemene benadering voor het aanpakken van een gelijktijdigheidsconflict is:

  1. Vangen DbUpdateConcurrencyException tijdens SaveChanges.
  2. Gebruik DbUpdateConcurrencyException.Entries deze optie om een nieuwe set wijzigingen voor te bereiden voor de betrokken entiteiten.
  3. Vernieuw de oorspronkelijke waarden van het gelijktijdigheidstoken om de huidige waarden in de database weer te geven.
  4. Voer het proces opnieuw uit totdat er geen conflicten optreden.

In het volgende voorbeeld worden Person.FirstName en Person.LastName ingesteld als gelijktijdigheidstokens. Er is een // TODO: opmerking op de locatie waar u toepassingsspecifieke logica opneemt om de waarde te kiezen die moet worden opgeslagen.

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

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

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

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

Isolatieniveaus gebruiken voor gelijktijdigheidsbeheer

Optimistische gelijktijdigheid via gelijktijdigheidstokens is niet de enige manier om ervoor te zorgen dat gegevens consistent blijven ten opzichte van gelijktijdige wijzigingen.

Eén mechanisme om consistentie te garanderen, is het herhaalbare isolatieniveau van leesbewerkingen . In de meeste databases garandeert dit niveau dat een transactie gegevens in de database ziet zoals het was toen de transactie werd gestart, zonder dat dit wordt beïnvloed door latere gelijktijdige activiteiten. Als we een query uitvoeren op het Person voorbeeld om het op een of andere manier bij te werken, moet de database ervoor zorgen dat er geen andere transacties zijn die invloed hebben op die databaserij totdat de transactie is voltooid. Afhankelijk van uw database-implementatie gebeurt dit op twee manieren:

  1. Wanneer de rij wordt opgevraagd, heeft uw transactie een gedeelde vergrendeling. Elke externe transactie die de rij probeert bij te werken, wordt geblokkeerd totdat uw transactie is voltooid. Dit is een vorm van pessimistische vergrendeling en wordt geïmplementeerd door het isolatieniveau 'herhaalbaar lezen' van SQL Server.
  2. In plaats van te vergrendelen, staat de database toe dat de externe transactie de rij bijwerkt. Echter, wanneer uw eigen transactie de update probeert uit te voeren, wordt er een "serialisatiefout" gegenereerd, wat aangeeft dat er een gelijktijdigheidsconflict is opgetreden. Dit is een vorm van optimistische vergrendeling, vergelijkbaar met de gelijktijdigheidstokenfunctie van EF, en wordt geïmplementeerd door het SQL Server momentopname-isolatieniveau, evenals door het repeatable reads-isolatieniveau van PostgreSQL.

Houd er rekening mee dat het isolatieniveau 'serialiseerbare' dezelfde garanties biedt als herhaalbare leesbewerkingen (en extra gegevens toevoegt), zodat het op dezelfde manier functioneert met betrekking tot het bovenstaande.

Het gebruik van een hoger isolatieniveau voor het beheren van gelijktijdigheidsconflicten is eenvoudiger, vereist geen gelijktijdigheidstokens en biedt andere voordelen; Herhaalbare leesbewerkingen garanderen bijvoorbeeld dat uw transactie altijd dezelfde gegevens ziet in query's binnen de transactie, waardoor inconsistenties worden voorkomen. Deze aanpak heeft echter wel de nadelen.

Als uw database-implementatie gebruikmaakt van vergrendeling om het isolatieniveau te implementeren, moeten andere transacties die dezelfde rij proberen te wijzigen, worden geblokkeerd voor de gehele transactie. Dit kan een nadelig effect hebben op gelijktijdige prestaties (houd uw transactie kort!), hoewel het mechanisme van EF een uitzondering genereert en u in plaats daarvan dwingt om het opnieuw te proberen, wat ook gevolgen heeft. Dit is van toepassing op het herhaalbare leesniveau van SQL Server, maar niet op het momentopnameniveau, waardoor query's op rijen niet worden vergrendeld.

Belangrijker is dat voor deze benadering een transactie nodig is om alle bewerkingen te omvatten. Als u bijvoorbeeld query's Person uitvoert om de details van een gebruiker weer te geven en vervolgens te wachten totdat de gebruiker wijzigingen heeft aangebracht, moet de transactie actief blijven voor een potentieel lange tijd, wat in de meeste gevallen moet worden vermeden. Als gevolg hiervan is dit mechanisme meestal geschikt wanneer alle ingesloten bewerkingen onmiddellijk worden uitgevoerd en de transactie niet afhankelijk is van externe invoer die de duur ervan kan verhogen.

Aanvullende bronnen

Zie conflictdetectie in EF Core voor een ASP.NET Core-voorbeeld met conflictdetectie.