Behandlung von Parallelitätskonflikten

Tipp

Das in diesem Artikel verwendete Beispiel finden Sie auf GitHub.

In den meisten Szenarien werden Datenbanken gleichzeitig von mehreren Anwendungsinstanzen verwendet, die Änderungen an Daten unabhängig voneinander vornehmen. Wenn dieselben Daten gleichzeitig geändert werden, können Inkonsistenzen und Datenbeschädigungen auftreten, z. B. wenn zwei Clients verschiedene Spalten in derselben Zeile ändern, die auf irgendeine Weise miteinander verknüpft sind. Auf dieser Seite werden Mechanismen erläutert, durch deren Hilfe sichergestellt wird, dass Ihre Daten im Hinblick auf solche gleichzeitigen Änderungen konsistent bleiben.

Optimistische Nebenläufigkeit

EF Core implementiert optimistische Nebenläufigkeitskonflikt. Dabei wird davon ausgegangen, dass Nebenläufigkeitskonflikte relativ selten sind. Im Gegensatz zu pessimistischen Ansätzen, die Daten im Vorfeld sperren und erst dann Änderungen vornehmen, werden bei der optimistischen Nebenläufigkeit keine Daten gesperrt. Stattdessen schlägt die Änderung der Daten beim Speichern fehl, sofern sich die Daten seit der Abfrage geändert haben. Dieser Nebenläufigkeitsfehler wird der Anwendung gemeldet, die sie dann entsprechend behandelt, indem sie möglicherweise den gesamten Vorgang für die neuen Daten noch einmal ausführt.

In EF Core wird optimistische Nebenläufigkeit implementiert, indem eine Eigenschaft als Parallelitätstoken konfiguriert wird. Das Parallelitätstoken wird geladen und nachverfolgt, wenn eine Entität (genau wie jede andere Eigenschaft) abgefragt wird. Wenn dann ein Aktualisierungs- oder Löschvorgang während SaveChanges() ausgelöst wird, wird der Wert des Parallelitätstokens in der Datenbank mit dem ursprünglichen von EF Core gelesenen Wert verglichen.

Um die Funktionsweise besser zu verstehen, gehen wir davon aus, dass wir mit SQL Server arbeiten und einen typischen Entitätstyp namens „Person“ mit einer speziellen Version-Eigenschaft definieren:

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 konfiguriert dies ein Parallelitätstoken, das bei jeder Änderung der Zeile automatisch in der Datenbank geändert wird (Details finden Sie weiter unten). Untersuchen wir im Rahmen dieser Konfiguration, was bei einem einfachen Aktualisierungsvorgang geschieht:

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
context.SaveChanges();
  1. Im ersten Schritt wird eine Person aus der Datenbank geladen. Dies schließt das Parallelitätstoken mit ein, das von EF nun wie gewohnt zusammen mit den restlichen Eigenschaften nachverfolgt wird.
  2. Die „Person“-Instanz wird dann auf irgendeine Weise geändert – wir ändern die Eigenschaft FirstName.
  3. Anschließend weisen wir EF Core an, die Änderung beizubehalten. Da ein Parallelitätstoken konfiguriert ist, sendet EF Core den folgenden SQL-Code an die Datenbank:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

Beachten Sie, dass EF Core zusätzlich zur PersonId in der WHERE-Klausel auch eine Bedingung für Version hinzugefügt hat. Dadurch wird die Zeile nur geändert, wenn sich die Spalte Version seit dem Zeitpunkt, an dem wir sie abgefragt haben, nicht verändert hat.

Im normalen („optimistischen“) Fall tritt keine gleichzeitige Aktualisierung auf, und „UPDATE“ wird erfolgreich abgeschlossen und ändert die Zeile. Die Datenbank meldet EF Core, dass eine Zeile wie erwartet von „UPDATE“ verändert wurde. Sollte jedoch eine gleichzeitige Aktualisierung erfolgt sein, findet „UPDATE“ keine übereinstimmenden Zeilen und Berichte, die mit Null betroffen waren. Daher löst die Methode SaveChanges() von EF Core die Ausnahme DbUpdateConcurrencyException aus, die die Anwendung entsprechend abfangen und verarbeiten muss. Die folgenden Verfahren werden weiter unten unter Beheben von Nebenläufigkeitskonfliktenausführlich beschrieben.

Die oben genannten Beispiele haben Aktualisierungen vorhandener Entitäten erläutert. EF löst aber auch die Ausnahme DbUpdateConcurrencyException aus, wenn versucht wird, eine Zeile zu löschen, die gleichzeitig geändert wurde. Diese Ausnahme wird jedoch beim Hinzufügen von Entitäten nie ausgelöst. Während die Datenbank möglicherweise eine eindeutige Einschränkungsverletzung auslöst, wenn Zeilen mit demselben Schlüssel eingefügt werden, führt dies zu einer anbieterspezifischen Ausnahme, die ausgelöst wird, und nicht zur Ausnahme DbUpdateConcurrencyException.

Native datenbankgenerierte Parallelitätstoken

Im obigen Code haben wir das [Timestamp]-Attribut verwendet, um einer rowversion-Spalte eines SQL Servers eine Eigenschaft zuzuordnen. Da rowversion beim Aktualisieren der Zeile automatisch geändert wird, ist es als Parallelitätstoken, das nur minimalen Aufwand verursacht und die gesamte Zeile schützt, sehr nützlich. Das Konfigurieren einer rowversion-Spalte eines SQL Servers als Parallelitätstoken erfolgt wie folgt:

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

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

Der oben gezeigte rowversion-Typ ist ein spezifisches Feature von SQL Server. Die Details zum Einrichten eines sich automatisch aktualisierenden Parallelitätstokens unterscheidet sich von Datenbank zu Datenbank, wobei manche Datenbanken (z. B. SQLite) dieses Feature überhaupt nicht unterstützen. Genaue Informationen finden Sie in der Anbieterdokumentation.

Von der Anwendung verwaltete Parallelitätstoken

Anstatt die Datenbank das Parallelitätstoken automatisch verwalten zu lassen, können Sie dies auch im Code der Anwendung tun. Dies ermöglicht die Verwendung optimistischer Nebenläufigkeit für Datenbanken wie SQLite, bei denen kein nativ Typ mit automatischer Aktualisierung vorhanden ist. Aber auch bei der Nutzung von SQL Server kann ein von der Anwendung verwaltetes Parallelitätstoken präzise steuern, welche Spaltenänderungen dazu führen, dass das Token neu generiert wird. Gehen wir beispielsweise davon aus. Sie hätten eine Eigenschaft, die einen zwischengespeicherten oder unwichtigen Wert enthält, und würden nicht wollen, dass eine Änderung an dieser Eigenschaft einen Parallelitätskonflikt auslöst.

Im Folgenden wird eine GUID-Eigenschaft als Parallelitätstoken konfiguriert:

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

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

Da diese Eigenschaft nicht von der Datenbank generiert wird, müssen Sie sie immer dann in der Anwendung zuweisen, wenn Änderungen beibehalten werden:

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

Wenn Sie möchten, dass stets ein neuer GUID-Wert zugewiesen wird, können Sie dies über einen SaveChanges-Interceptortun. Ein Vorteil der manuellen Verwaltung des Parallelitätstokens besteht jedoch darin, dass Sie genau steuern können, wann es neu generiert wird, um unnötige Parallelitätskonflikte zu vermeiden.

Beheben von Nebenläufigkeitskonflikten

Unabhängig davon, wie Ihr Parallelitätstoken eingerichtet ist, muss Ihre Anwendung das Auftreten eines Parallelitätskonflikts, wodurch die Ausnahme DbUpdateConcurrencyException ausgelöst wird; ordnungsgemäß behandeln. Die wird als Auflösen eines Parallelitätskonflikts bezeichnet.

Eine Möglichkeit besteht darin, den Benutzer einfach darüber zu informieren, dass die Aktualisierung aufgrund in Konflikt stehender Änderungen fehlgeschlagen ist. Der Benutzer kann dann die neuen Daten laden und es noch einmal versuchen. Wenn Ihre Anwendung hingegen eine automatisierte Aktualisierung durchführt, kann einfach eine Schleife erfolgen und erneut versucht werden, die Daten abzufragen.

Eine komplexere Möglichkeit zum Beheben von Parallelitätskonflikten besteht darin, die ausstehenden Änderungen mit den neuen Werten in der Datenbank zusammenführen. Die genauen Details dazu, welche Werte zusammengeführt werden, hängen von der Anwendung ab. Der Prozess kann dabei über eine Benutzeroberfläche gesteuert werden, in der beide Wertesätze angezeigt werden.

Nebenläufigkeitskonflikte können mit drei verschiedenen Wertetypen gelöst werden:

  • Aktuelle Werte sind die Werte, die die Anwendung in die Datenbank schreiben wollte.
  • Ursprüngliche Werte sind die Werte, die vor den Änderungen aus der Datenbank abgerufen wurden.
  • Datenbankwerte sind die Werte, die derzeit in der Datenbank gespeichert sind.

Nebenläufigkeitskonflikte werden im Allgemeinen folgendermaßen behoben:

  1. Fangen Sie DbUpdateConcurrencyException während SaveChanges ab.
  2. Bereiten Sie mit DbUpdateConcurrencyException.Entries neue Änderungen für die betroffenen Entitäten vor.
  3. Aktualisieren Sie die ursprünglichen Werte des Parallelitätstokens, sodass sie mit den aktuellen Werten in der Datenbank übereinstimmen.
  4. Wiederholen Sie den Prozess, bis keine Konflikte mehr auftreten.

Im folgenden Beispiel werden Person.FirstName und Person.LastName als Parallelitätstoken eingerichtet. Dort, wo Sie die anwendungsspezifische Logik platzieren, nach der der zu speichernde Wert ausgewählt wird, befindet sich ein // TODO:-Kommentar.

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

Verwenden von Isolationstufen zur Parallelitätssteuerung

Optimistische Nebenläufigkeit über Parallelitätstoken ist nicht die einzige Möglichkeit, um sicherzustellen, dass Daten bei gleichzeitigen Änderungen konsistent bleiben.

Ein Mechanismus zur Sicherstellung der Konsistenz besteht in wiederholbaren Lesevorgängen auf Transaktionsisolationsstufe. In den meisten Datenbanken garantiert diese Stufe, dass einer Transaktion Daten in der Datenbank so angezeigt werden, wie sie zu Beginn der Transaktion waren, ohne von nachfolgenden gleichzeitigen Aktivitäten betroffen zu sein. Um auf unser einfaches Beispiel weiter oben zurückzukommen: Wenn wir den Entitätstpy Person abfragen, um ihn auf irgendeine Weise zu aktualisieren, muss die Datenbank sicherstellen, dass keine anderen Transaktionen auf diese Zeile der Datenbank zugreifen, bis die Transaktion abgeschlossen ist. Je nach Datenbankimplementierung erfolgt dies auf eine von zwei Arten:

  1. Wenn die Zeile abgefragt wird, nimmt Ihre Transaktion eine gemeinsame Sperre vor. Jegliche externe Transaktion, die versucht, die Zeile zu aktualisieren, wird blockiert, bis die Transaktion abgeschlossen ist. Dies ist eine Form der pessimistischen Sperre, die von der SQL Server-Isolationsstufe „Repeatable Read“ (Wiederholbarer Lesevorgang) implementiert wird.
  2. Statt einer Sperre kann die Datenbank der externen Transaktion gestatten, die Zeile zu aktualisieren. Wenn jedoch Ihre eigene Transaktion versucht, die Aktualisierung durchzuführen, wird ein „Serialisierungsfehler“ ausgelöst, der angibt, dass ein Parallelitätskonflikt aufgetreten ist. Dies ist eine Form der optimistischen Sperre, die dem Parallelitätstokenfeature von EF ähnelt und von der SQL Server-Momentaufnahme- sowie von der Repeatable Read-Isolationsstufe implementiert wird.

Beachten Sie, dass die Isolationsstufe „Serializable“ die gleichen Garantien wie „Repeatable Red“ bietet (und zusätzliche hinzufügt), sodass sie in Bezug auf die oben genannten Punkte auf die gleiche Weise funktioniert.

Die Verwendung einer höheren Isolationsstufe zum Verwalten von Parallelitätskonflikten ist einfacher, erfordert keine Parallelitätstoken und bietet weitere Vorteile. Beispielsweise garantieren wiederholbare Lesevorgänge, dass Ihrer Transaktion bei Abfragen immer dieselben Daten innerhalb der Transaktion angezeigt und dadurch Inkonsistenzen vermieden werden. Dieser Ansatz birgt jedoch auch Nachteile.

Wenn ihre Datenbankimplementierung zum Implementieren der Isolationsstufe mit Sperren arbeitet, müssen andere Transaktionen, die versuchen, dieselbe Zeile zu ändern, für die gesamte Transaktion blockiert werden. Dies kann sich negativ auf die parallele Leistung auswirken (Stichwort: Halten Sie Ihre Transaktion kurz!). Beachten Sie jedoch, dass der EF-Mechanismus eine Ausnahme auslöst und Sie zwingt, einen neuen Versuch zu unternehmen, was ebenfalls Auswirkungen hat. Dies gilt für „Repeatable Read“-Stufe von SQL Server, nicht aber für die Momentaufnahme-Stufe, die abgefragte Zeilen nicht sperrt.

Wichtiger ist, dass bei diesem Ansatz eine Transaktion für alle Vorgänge erforderlich ist. Wenn Sie beispielsweise Person abfragen, um die Details für einen Benutzer anzuzeigen, und dann warten, bis der Benutzer Änderungen vorgenommen hat, muss die Transaktion möglicherweise lange Zeit aktiv bleiben, was in den meisten Fällen vermieden werden sollte. Daher ist dieser Mechanismus in der Regel geeignet, wenn alle enthaltenen Vorgänge sofort ausgeführt werden und die Transaktion nicht von externen Eingaben abhängt, die ihre Dauer verlängern können.

Zusätzliche Ressourcen

Ein ASP.NET Core-Beispiel mit Konflikterkennung finden Sie unter Konflikterkennung in EF Core.