Konkurencia ütközések kezelése

Jótanács

A cikk mintáját a GitHubon tekintheti meg.

A legtöbb forgatókönyvben az adatbázisokat egyszerre több alkalmazáspéldány használja, amelyek egymástól függetlenül végeznek módosításokat az adatokon. Amikor ugyanazok az adatok egyszerre módosulnak, következetlenségek és adatsérülések léphetnek fel, például ha két ügyfél módosítja ugyanabban a sorban a különböző oszlopokat, amelyek valamilyen módon kapcsolódnak egymáshoz. Ez a lap azokat a mechanizmusokat ismerteti, amelyek biztosítják, hogy az adatok konzisztensek maradnak az ilyen egyidejű változások esetén.

Optimista konkurencia-kezelés

Az EF Core optimista egyidejűséget valósít meg, amely feltételezi, hogy az egyidejűségi ütközések viszonylag ritkák. A pesszimista megközelítésekkel ellentétben – amelyek előre zárolják az adatokat, és csak ezután módosítják őket – az optimista zárolási modell nem használ zárolást, hanem biztosítja, hogy az adatmódosítás sikertelen legyen mentéskor, ha az adatok a lekérdezés óta megváltoztak. Ezt az egyidejűségi hibát az alkalmazás számára jelentik, amely ennek megfelelően kezeli, esetleg a teljes művelet új adatokkal való újrapróbálásával.

Az EF Core-ban az optimista egyidejűség egy tulajdonság egyidejűségi jogkivonatként való konfigurálásával valósítható meg. Az egyidejűségi token betöltése és nyomon követése egy entitás lekérésekor – ugyanúgy, mint bármely más tulajdonság. Ezután, amikor frissítési vagy törlési műveletet hajtanak végre, SaveChanges()az adatbázis egyidejűségi jogkivonatának értéke össze lesz hasonlítva az EF Core által beolvasott eredeti értékkel.

A működés megértéséhez tegyük fel, hogy az SQL Serveren vagyunk, és egy tipikus Person entitástípust definiálunk egy speciális Version tulajdonsággal:

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

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

Az SQL Serverben ez konfigurál egy egyidejűségi jogkivonatot, amely a sor minden módosításakor automatikusan megváltozik az adatbázisban (további részleteket alább talál). Ezzel a konfigurációval vizsgáljuk meg, hogy mi történik egy egyszerű frissítési művelettel:

var person = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
await context.SaveChangesAsync();
  1. Az első lépésben egy Személy kerül betöltésre az adatbázisból; ez magában foglalja az egyidejűségi tokent, amelyet az EF a megszokott módon követ nyomon a többi tulajdonsággal együtt.
  2. A Person-példány ezután valamilyen módon módosul – módosítjuk a tulajdonságot FirstName .
  3. Ezután utasítjuk az EF Core-t, hogy őrizze meg a módosítást. Mivel az egyidejűségi jogkivonat konfigurálva van, az EF Core a következő SQL-t küldi az adatbázisba:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

Vegye figyelembe, hogy az EF Core nemcsak a WHERE záradékban szereplő PersonId mellett, hanem az Version számára is hozzáadott egy feltételt; ez csak akkor módosítja a sort, ha az Version oszlop a lekérdezés óta nem változott.

Normál ("optimista") esetben nem történik egyidejű frissítés, és az UPDATE sikeresen befejeződik, módosítva a sort; az adatbázis a vártnak megfelelően jelenti az EF Core-nak, hogy az UPDATE egy sort érintett. Ha azonban egyidejű frissítés történt, az UPDATE nem talál egyező sorokat, és jelenti, hogy nulla sort érintett. Ennek eredményeképpen az EF Core SaveChanges() dob egy DbUpdateConcurrencyException, amelyet az alkalmazásnak megfelelően kell elkapnia és kezelnie. Ennek módszereit az alábbiakban, az egyidejűségi ütközések feloldása szakaszban találja.

Míg a fenti példák a meglévő entitások frissítéseit tárgyalták. Az EF hibát is jelezDbUpdateConcurrencyException, amikor egy egyidejűleg módosított sor törlését próbálják meg. Ez a kivétel azonban általában soha nem fordul elő entitások hozzáadásakor; bár az adatbázis valóban egyedi korlátozási szabálysértést okozhat, ha ugyanazzal a kulccsal rendelkező sorokat szúr be, ez szolgáltatóspecifikus kivételt eredményez, és nem DbUpdateConcurrencyException.

Natív adatbázis által létrehozott egyidejűségi jelek

A fenti kódban az [Timestamp] attribútum használatával képeztünk le egy tulajdonságot egy SQL Server-oszlopra rowversion . Mivel rowversion a sor frissítésekor automatikusan változik, nagyon hasznos kis ráfordítással alkalmazható egyidejűségi jelzőként, amely védi az egész sort. Az SQL Server rowversion oszlopának konfigurálása egyidejűségi tokenként az alábbiak szerint történik:

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

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

A rowversion fent látható típus egy SQL Server-specifikus funkció; az automatikusan frissülő egyidejűségi jogkivonat beállításának részletei adatbázisonként eltérőek, és egyes adatbázisok egyáltalán nem támogatják ezeket (pl. SQLite). A pontos részletekért tekintse meg a szolgáltató dokumentációját.

Alkalmazás által felügyelt konkurencia tokenek

Ahelyett, hogy az adatbázis automatikusan kezelje az egyidejűségi jogkivonatot, az alkalmazáskódban is kezelheti. Ez lehetővé teszi optimista egyidejűség használatát olyan adatbázisokon – például az SQLite-en –, ahol nem létezik natív automatikus frissítési típus. Az alkalmazás által felügyelt egyidejűségi jogkivonatok azonban még az SQL Serveren is részletesen szabályozhatják, hogy pontosan mely oszlopváltozások okozzák a jogkivonat újragenerálását. Előfordulhat például, hogy van egy tulajdonsága, amely gyorsítótárazott vagy nem lényeges értéket tartalmaz, és nem szeretné, hogy a tulajdonság módosítása párhuzamossági ütközést okozzon.

A következők konfigurálják a GUID-tulajdonságot egyidejűségi jogkivonatként:

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

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

Mivel ez a tulajdonság nem adatbázis-generált, a módosítások megőrzésekor hozzá kell rendelnie az alkalmazásban:

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

Ha azt szeretné, hogy mindig új GUID-érték legyen hozzárendelve, ezt egy SaveChanges elfogón keresztül teheti meg. Az egyidejűségi jogkivonat manuális kezelésének egyik előnye azonban az, hogy pontosan megadhatja, mikor generálódik újra, elkerülve a szükségtelen egyidejűségi ütközéseket.

Egyidejűségi ütközések feloldása

Függetlenül attól, hogy az egyidejűségi jogkivonat hogyan van beállítva, az optimista egyidejűség implementálásához az alkalmazásnak megfelelően kell kezelnie azt az esetet, amikor egyidejűségi ütközés lép fel és DbUpdateConcurrencyException kerül sor; ezt egyidejűségi ütközés feloldásának nevezzük.

Az egyik lehetőség az, hogy egyszerűen tájékoztassa a felhasználót arról, hogy a frissítés ütköző módosítások miatt meghiúsult; a felhasználó ezután betöltheti az új adatokat, és újra próbálkozhat. Vagy ha az alkalmazás automatizált frissítést hajt végre, az adatok újbóli lekérdezése után egyszerűen visszacsatolást és újrapróbálkozást végezhet.

Az egyidejűségi ütközések megoldásának kifinomultabb módja a függőben lévő módosítások és az adatbázis új értékeinek egyesítése . Az egyesítendő értékek pontos részletei az alkalmazástól függenek, és a folyamatot egy felhasználói felület irányíthatja, ahol mindkét értékkészlet megjelenik.

Az egyidejűségi ütközések megoldásához három értékkészlet érhető el:

  • Az aktuális értékek azok az értékek, amelyeket az alkalmazás az adatbázisba próbált írni.
  • Az eredeti értékek azok az értékek, amelyeket eredetileg lekértek az adatbázisból a módosítások előtt.
  • Az adatbázis értékei az adatbázisban jelenleg tárolt értékek.

Az egyidejűségi ütközések kezeléséhez az általános megközelítés a következő:

  1. Fogás DbUpdateConcurrencyException közben SaveChanges.
  2. Az érintett entitások új módosításkészletének előkészítésére használható DbUpdateConcurrencyException.Entries .
  3. Frissítse az egyidejűségi jogkivonat eredeti értékeit az adatbázis aktuális értékeinek megfelelően.
  4. Próbálkozzon újra a folyamatokkal, amíg nem fordul elő ütközés.

Az alábbi példában a Person.FirstName és a Person.LastName egyidejűségi jogkivonatként vannak beállítva. Van egy // TODO: megjegyzés abban a helyen, ahol alkalmazásspecifikus logikával választja ki a menteni kívánt értéket.

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

Elkülönítési szintek használata egyidejűség-vezérléshez

Az egyidejűségi jogkivonatokon keresztüli optimista egyidejűség nem az egyetlen módja annak, hogy az adatok konzisztensek maradnak az egyidejű változások esetén.

A konzisztencia biztosításának egyik mechanizmusa az ismételhető olvasási tranzakciók elkülönítési szintje. A legtöbb adatbázisban ez a szint garantálja, hogy egy tranzakció az adatbázisban a tranzakció indításakor látható adatokat látja anélkül, hogy azokat a későbbi egyidejű tevékenység befolyásolta volna. Ha az alapszintű mintát felülről vesszük, amikor lekérdezzük a Person lekérdezést, hogy valamilyen módon frissíthessük, az adatbázisnak meg kell győződnie arról, hogy semmilyen más tranzakció nem zavarja az adatbázis sorát, amíg a tranzakció befejeződik. Az adatbázis-megvalósítástól függően ez kétféleképpen történik:

  1. A sor lekérdezésekor a tranzakció megosztott zárolást használ. A sort frissíteni próbáló külső tranzakciók mindaddig blokkolva lesznek, amíg a tranzakció befejeződik. Ez a pesszimista zárolás egy formája, amelyet az SQL Server "ismételhető olvasási" elkülönítési szintje valósít meg.
  2. Zárolás helyett az adatbázis lehetővé teszi a külső tranzakció számára a sor frissítését, de amikor a saját tranzakciója megpróbálja végrehajtani a frissítést, "szerializálási" hiba jelenik meg, ami azt jelzi, hogy egyidejűségi ütközés történt. Ez az optimista zárolás egy formája – hasonlóan az EF egyidejűségi token funkciójához –, és mind az SQL Server pillanatkép-elkülönítési szintje, mind a PostgreSQL megismételhető olvasási elkülönítési szintje által van implementálva.

Vegye figyelembe, hogy a "szerializálható" elkülönítési szint ugyanazokat a garanciákat biztosítja, mint az ismétlődő olvasás (és továbbiakat ad hozzá), így a fentiekhez hasonlóan működik.

Az egyidejűségi ütközések kezelésére magasabb elkülönítési szint használata egyszerűbb, nem igényel egyidejűségi jogkivonatokat, és egyéb előnyöket is biztosít; Az ismétlődő olvasások például garantálják, hogy a tranzakció mindig ugyanazokat az adatokat látja a tranzakció lekérdezéseiben, elkerülve az inkonzisztenciákat. Ennek a megközelítésnek azonban megvannak a maga hátrányai.

Először is, ha az adatbázis-implementáció zárolással valósítja meg az elkülönítési szintet, akkor az ugyanazon sor módosítására tett egyéb tranzakcióknak a tranzakció teljes egészében blokkolva kell lenniük. Ez kedvezőtlen hatással lehet az egyidejű teljesítményre (tartsa rövidre a tranzakciót!), bár vegye figyelembe, hogy az EF mechanizmusa kivételt okoz, és arra kényszeríti, hogy inkább próbálkozzon újra, ami szintén hatással van. Ez az SQL Server megismételhető olvasási szintjére vonatkozik, a pillanatkép szintjére nem, amely nem zárolja a lekérdezett sorokat.

Ennél is fontosabb, hogy ehhez a megközelítéshez egy tranzakcióra van szükség az összes műveletre kiterjedően. Ha például lekérdezi Person adatait, hogy megjelenítse azokat egy felhasználónak, majd megvárja, amíg a felhasználó változtatásokat hajt végre, akkor a tranzakciónak valószínűleg hosszú ideig életben kell maradnia, amit a legtöbb esetben el kell kerülni. Ennek eredményeképpen ez a mechanizmus általában akkor megfelelő, ha az összes zárt műveletet azonnal végrehajtják, és a tranzakció nem függ külső bemenettől, amely növelheti az időtartamot.

További erőforrások

Lásd az Ütközésészlelés az EF Core-ban című részt egy példaanyagért ASP.NET Core-hoz ütközésészlelési lehetőséggel.