Dela via


Hantera samtidighetskonflikter

Tips

Du kan visa den här artikelns exempel på GitHub.

I de flesta scenarier används databaser samtidigt av flera programinstanser, var och en utför ändringar av data oberoende av varandra. När samma data ändras samtidigt kan inkonsekvenser och skadade data uppstå, t.ex. när två klienter ändrar olika kolumner på samma rad som är relaterade på något sätt. På den här sidan beskrivs mekanismer för att säkerställa att dina data förblir konsekventa inför sådana samtidiga ändringar.

Optimistisk konkurrenshantering

EF Core implementerar optimistisk samtidighet, vilket förutsätter att samtidighetskonflikter är relativt sällsynta. Till skillnad från pessimistiska metoder - som låser data i förväg och först sedan fortsätter att ändra dem - tar optimistisk samtidighetskontroll inga lås, men säkerställer att datamodifieringen misslyckas vid sparande om data har ändrats sedan den efterfrågades. Det här samtidighetsfelet rapporteras till programmet, som hanterar det i enlighet med detta, eventuellt genom att försöka utföra hela åtgärden på de nya data.

I EF Core implementeras optimistisk samtidighet genom att konfigurera en egenskap som en samtidighetstoken. Samtidighetstoken läses in och spåras när en entitet frågas efter – precis som vilken annan egenskap som helst. När en uppdaterings- eller borttagningsåtgärd utförs under SaveChanges()jämförs sedan värdet för samtidighetstoken i databasen med det ursprungliga värdet som lästes av EF Core.

För att förstå hur detta fungerar antar vi att vi är på SQL Server och definierar en typisk personentitetstyp med en speciell Version egenskap:

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

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

I SQL Server konfigurerar detta en samtidighetstoken som automatiskt ändras i databasen varje gång raden ändras (mer information finns nedan). Med den här konfigurationen på plats ska vi undersöka vad som händer med en enkel uppdateringsåtgärd:

var person = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
await context.SaveChangesAsync();
  1. I det första steget läses en person in från databasen. Detta inkluderar samtidighetstoken, som nu spåras som vanligt av EF tillsammans med resten av egenskaperna.
  2. Personinstansen ändras sedan på något sätt – vi ändrar egenskapen FirstName.
  3. Sedan instruerar vi EF Core att lagra ändringen. Eftersom en samtidighetstoken har konfigurerats skickar EF Core följande SQL till databasen:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

Observera att EF Core, förutom PersonId i WHERE-satsen, även har lagt till ett villkor för Version. Detta ändrar bara raden om kolumnen Version inte har ändrats sedan vi frågade den.

I det normala fallet ("optimistiskt") sker ingen samtidig uppdatering och UPPDATERINGEN har slutförts, vilket ändrar raden. databasen rapporterar till EF Core att en rad påverkades av UPDATE som förväntat. Men om en samtidig uppdatering inträffar misslyckas uppdateringen med att hitta några matchande rader och rapporterar att noll påverkades. Därför genererar EF Cores SaveChanges() en DbUpdateConcurrencyException, som programmet måste fånga och hantera på rätt sätt. Tekniker för att göra detta beskrivs nedan under Lösa samtidighetskonflikter.

I exemplen ovan beskrivs uppdateringar till befintliga entiteter. EF kastar också DbUpdateConcurrencyException när du försöker ta bort en rad som har ändrats samtidigt. Det här undantaget utlöses dock vanligtvis aldrig när entiteter läggs till. Även om databasen kan medföra en unik begränsningsöverträdelse om rader med samma nyckel infogas, resulterar detta i att ett providerspecifikt undantag utlöses och inte DbUpdateConcurrencyException.

Inbyggda databasgenererade samtidighetstoken

I koden ovan använde vi attributet [Timestamp] för att mappa en egenskap till en SQL Server-rowversion kolumn. Eftersom rowversion ändras automatiskt när raden uppdateras är det mycket användbart som en samtidighetstoken med minsta ansträngning som skyddar hela raden. Konfigurera en SQL Server-rowversion kolumn som en samtidighetstoken görs på följande sätt:

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

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

Den rowversion typ som visas ovan är en SQL Server-specifik funktion. Informationen om hur du konfigurerar en samtidighetstoken som uppdateras automatiskt skiljer sig åt mellan databaser och vissa databaser stöder inte dessa alls (t.ex. SQLite). Mer information finns i leverantörens dokumentation.

Applikationshanterade samtidighetstoken

I stället för att databasen ska hantera samtidighetstoken automatiskt kan du hantera den i programkoden. På så sätt kan du använda optimistisk samtidighet i databaser, till exempel SQLite, där det inte finns någon inbyggd typ av automatisk uppdatering. Men även på SQL Server kan en programhanterad samtidighetstoken ge detaljerad kontroll över exakt vilka kolumnändringar som gör att token återskapas. Du kan till exempel ha en egenskap som innehåller ett cachelagrat eller oviktigt värde och inte vill att en ändring av egenskapen ska utlösa en samtidighetskonflikt.

Följande konfigurerar en GUID-egenskap som en samtidighetstoken:

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

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

Eftersom den här egenskapen inte är databasgenererad måste du tilldela den i programmet när du bevarar ändringar:

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

Om du vill att ett nytt GUID-värde alltid ska tilldelas kan du göra detta via en SaveChanges interceptor. En fördel med att hantera samtidighetstoken manuellt är dock att du kan styra exakt när den återskapas för att undvika onödiga samtidighetskonflikter.

Lösa samtidighetskonflikter

Oavsett hur din samtidighetstoken är konfigurerad måste ditt program för att implementera optimistisk samtidighet hantera fallet där en samtidighetskonflikt inträffar och DbUpdateConcurrencyException genereras; detta kallas att lösa en samtidighetskonflikt.

Ett alternativ är att helt enkelt informera användaren om att uppdateringen misslyckades på grund av motstridiga ändringar. användaren kan sedan läsa in nya data och försöka igen. Om ditt program utför en automatiserad uppdatering kan det helt enkelt loopa och försöka igen direkt efter att ha begärt data på nytt.

Ett mer avancerat sätt att lösa samtidighetskonflikter är att slå ihop de väntande ändringarna med de nya värdena i databasen. Den exakta informationen om vilka värden som sammanfogas beror på programmet och processen kan styras av ett användargränssnitt, där båda uppsättningarna med värden visas.

Det finns tre uppsättningar med värden som hjälper dig att lösa en samtidighetskonflikt:

  • Aktuella värden är de värden som programmet försökte skriva till databasen.
  • Ursprungliga värden är de värden som ursprungligen hämtades från databasen innan några ändringar gjordes.
  • Databasvärden är de värden som för närvarande lagras i databasen.

Den allmänna metoden för att hantera en samtidighetskonflikt är:

  1. Fånga DbUpdateConcurrencyException under SaveChanges.
  2. Använd DbUpdateConcurrencyException.Entries för att förbereda en ny uppsättning ändringar för de berörda entiteterna.
  3. Uppdatera de ursprungliga värdena för samtidighetstoken för att återspegla de aktuella värdena i databasen.
  4. Försök igen tills inga konflikter inträffar.

I följande exempel konfigureras Person.FirstName och Person.LastName som samtidighetstoken. Det finns en // TODO: kommentar på den plats där du inkluderar programspecifik logik för att välja det värde som ska sparas.

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

Använda isoleringsnivåer för samtidighetskontroll

Optimistisk samtidighet via samtidighetstoken är inte det enda sättet att säkerställa att data förblir konsekventa inför samtidiga ändringar.

En mekanism för att säkerställa konsekvens är repeterbara läsningar transaktionsisoleringsnivå. I de flesta databaser garanterar den här nivån att en transaktion ser data i databasen som när transaktionen startade, utan att påverkas av någon efterföljande samtidig aktivitet. Om vi tar vårt grundläggande exempel ovan, när vi frågar efter Person för att uppdatera det på något sätt, måste databasen se till att inga andra transaktioner stör databasraden förrän transaktionen har slutförts. Beroende på databasimplementeringen sker detta på något av två sätt:

  1. När raden efterfrågas tar din transaktion ett delat lås på den. Alla externa transaktioner som försöker uppdatera raden blockeras tills transaktionen har slutförts. Detta är en form av pessimistisk låsning och implementeras på isoleringsnivån "repeterbar läsning" i SQL Server.
  2. I stället för att låsa tillåter databasen att den externa transaktionen uppdaterar raden, men när din egen transaktion försöker göra uppdateringen utlöses ett "serialiseringsfel", vilket indikerar att en samtidighetskonflikt uppstod. Det här är en form av optimistisk låsning – inte olikt EF:s funktion för samtidighetstoken – och implementeras av SQL Server-snapshot-isoleringsnivån samt av isoleringsnivån för PostgreSQL-upprepningsbara läsningar.

Observera att isoleringsnivån "serializable" ger samma garantier som repeterbar läsning (och lägger till ytterligare sådana), så att den fungerar på samma sätt med avseende på ovanstående.

Att använda en högre isoleringsnivå för att hantera samtidighetskonflikter är enklare, kräver inte samtidighetstoken och ger andra fördelar. Till exempel garanterar upprepningsbara läsningar att transaktionen alltid ser samma data mellan frågor i transaktionen, vilket undviker inkonsekvenser. Den här metoden har dock sina nackdelar.

Om databasimplementeringen först använder låsning för att implementera isoleringsnivån måste andra transaktioner som försöker ändra samma rad blockera hela transaktionen. Detta kan ha en negativ effekt på samtidiga prestanda (håll transaktionen kort!), men observera att EF:s mekanism utlöser ett undantag och tvingar dig att försöka igen i stället, vilket också påverkar. Detta gäller för den repeterbara läsnivån för SQL Server, men inte på ögonblicksbildsnivån, som inte låser efterfrågade rader.

Ännu viktigare är att den här metoden kräver en transaktion för att omfatta alla åtgärder. Om du till exempel frågar Person för att visa dess information för en användare och sedan väntar på att användaren ska göra ändringar, måste transaktionen hålla sig vid liv under en potentiellt lång tid, vilket bör undvikas i de flesta fall. Därför är den här mekanismen vanligtvis lämplig när alla inneslutna åtgärder körs omedelbart och transaktionen inte är beroende av externa indata som kan öka dess varaktighet.

Ytterligare resurser

Se Konfliktidentifiering i EF Core för ett ASP.NET Core-exempel med konfliktidentifiering.