Datensatz sperren in Entity Framework 6: Optimistisches vs. Pessimistisches Sperren

„Wie arbeite ich mit Datensatzsperren?“  ist eine der häufigsten Fragen beim Einstieg in die Arbeit mit dem Entity Framework. Hintergrund ist der Wunsch, die Änderungen, die ein Benutzer in der Datenbank hinterlassen hat, nicht ohne Weiteres durch andere Schreibzugriffe zu überschreiben – die sogenannte Concurrency.
Aus älteren Datenbanksystemen kennt man das pessimistische Sperren, was mit Technologien wie dem klassischen Recordset oder ähnlichen, Cursor-artigen Konzepten kein Problem war: Ein Datensatz wurde einfach zur Bearbeiten durch andere gesperrt, sobald ein Benutzer begann, diesen zu bearbeiten.
In mehrschichtigen Szenarien, wie sie heute in modernen Desktopanwendungen und Apps verwendet werden, ist diese Sperrung nur auf Datenbankserverseite möglich, erst Recht bei Webanwendungen, wo noch ein aufwändiges Session-Handling dazukommt. Entity Framework 6 arbeitet jedoch applikations- oder clientseitig, ein direkter Serverzugriff ist nicht vorgesehen. Daher unterstützt Entity Framework nur „Optimistisches Sperren“ – heißt: Alle Änderungen werden zugelassen, und erst beim Schreibzugriff wird geprüft, ob sich in der Zwischenzeit die zu bearbeitenden Daten geändert haben.

Entity Framework 6 unterstützt den Entwickler auf sehr einfache, aber effektive Weise: Datenbanktabellen, in denen konkurrierende Schreibzugriffe abgefangen werden müssen (gilt längst nicht für jede Tabelle in der DB!), bekommen zusätzlich eine Spalte, deren Wert beim Schreiben grundsätzlich verändert wird. In Microsofts SQL-Welt gibt es dafür den timestamp-Datentyp, der hier nachträglich in die Customers-Tabelle der Nordwind-Datenbank eingebaut wird:

timestamp_column
Beim Erstellen des Entity-Modells wird daraus eine TimeStamp-Property in der generierten Entitätsklasse:
efmodell
Spannend für die Concurrency-Behandlung sind die beiden Properties Parallelitätsmodus (engl. „Concurrency Mode“) und StoreGeneratedPattern (zum Glück hat man darauf verzichtet, dies zu übersetzen) der generierten TimeStamp-Property:

efmodell_props
Während „StoreGeneratedPattern“ bei Feldern vom Typ timestamp bereits vom EF-Designer auf „Computed“ gesetzt wird (die anderen hier zulässigen Werte sind „Identity“ – siehe Auto-Inkrement-Primärschlüssel – und „None“), muss der „Parallelitätsmodus“ von Hand auf „Fixed“ gesetzt werden. Hierdurch wird beim Speichern des Datensatzes auf anderweitige Veränderungen geprüft und eine DbUpdateConcurrencyException geworfen, falls der Datensatz anderweitig geändert wurde, während er im speichernden Client geöffnet war.

Um das alles einfach zu testen, kann man sich hervorragend den Umstand zu Nutze machen, dass zwei verschiedene Objekt-Kontexte auch innerhalb einer Routine denselben Datensatz verändern können, ohne dass der jew. andere Kontext etwas davon mitbekommt. ctxA und ctxCC seien diese Kontexte, die beide versuchen, ein Customer-Objekt (die Nordwind-Datenbank ist nicht tot!) zu speichern:

using (NWindEntities ctxAA = new NWindEntities())
{
   using (NWindEntities ctxCC = new NWindEntities())
   {
      Customer cuCC = ctxCC.Customers.Where(cu => cu.CustomerID == cuAA.CustomerID).FirstOrDefault();

      cuCC.ContactName = new string(cuAA.ContactName.Reverse().ToArray());
      ctxCC.SaveChanges();
   }

   ctxAA.SaveChanges();
}

cuAA ist ein Customer-Objekt, das bereits geändert wurde. Bevor ctxAA unseren cuAA speichert, schnappt sich ctxCC unseren Customer mit Hilfe einer weiteren Objektreferenz cuCC, dreht den ContactName auf links und speichert diese Änderung direkt. Wenn ctxAA speichern möchte, tritt besagte DbUpdateConcurrencyException auf, die verhindert, dass die Änderungen in der Datenbank überschrieben werden.

Und nun?

Zum einen natürlich ein ordentliches Exception-Handling: Die DbUpdateConcurrencyException wird sauber abgefangen und an die GUI weitergereicht, damit der Benutzer informiert wird. Je nach Anwendungsfall, wobei vielleicht auch Berechtigungskonzepte eine Rolle spielen können, gibt es verschiedene Möglichkeiten:

  • Die Änderungen werden verworfen (Database wins).
    • Bei einem sauberen Aufbau der Applikation ist hierzu häufig lediglich das Disposen des geänderten Objekts oder des DbContext nötig. Anderenfalls würde man im obigen Beispiel mit ctx.Entry(cuAA).Reload(); das Customer-Objekt auch zurücksetzen können.
      -> Der klassische Weg aus Framework 5 und früher über ObjectContext.Refresh(RefreshMode.StorageWins, entity) steht im Framework 6 nur noch über ein mühsames Casting zur Verfügung, weil die im Framework 6 verwendete DbContext-Klasse nicht von ObjectContext erbt, sondern lediglich das IObjectContextAdapter-Interface implementiert.
  • Die Änderungen werden dennoch geschrieben (Client wins).
    • Damit beim Speichern keine erneute Exception auftritt, muss dem DbContext vorgegaukelt werden, die alten und die neuen Werte seien gleich: ctxAA.Entry(cuAA).OriginalValues.SetValues(ctxAA.Entry(cuAA).GetDatabaseValues());. Ein erneutes Speichern wirft dann keine Exception mehr.
      -> Der klassische Weg aus Framework 5 und früher über ObjectContext.Refresh(RefreshMode.ClientWins, entity) steht im Framework 6 nur noch über ein mühsames Casting zur Verfügung, weil die im Framework 6 verwendete DbContext-Klasse nicht von ObjectContext erbt, sondern lediglich das IObjectContextAdapter-Interface implementiert.
  • Die Änderungen werden mit den Originalwerten verglichen, der Benutzer kann entscheiden, welche er haben will.
    • Erfordert deutlich mehr Coding-Aufwand, dürfte aber die höchste Benutzerfreundlichkeit bieten.