Optymistyczna współbieżność w EF Core

Wprowadzenie

W tym wpisie zajmę się tematem, który mam wrażenie bardzo często jest pomijany. To może niestety prowadzić do poważnych konsekwencji (utraty danych lub ich niepoprawności), które ciężko będzie namierzyć. Zobaczysz, w jaki sposób można rozwiązać ten problem dzięki optymistycznej współbieżności w .NET Core.

Problem?

Wcześniej czy później natrafimy na problem, w którym dwóch użytkowników/dwa procesy, czy jakieś inne elementy, będą próbowały zmienić w tym samym czasie jeden rekord w bazie danych (załóżmy, że zapis rekordu/formularza do bazy powoduje aktualizację wszystkich pól, niezależnie, czy były zmieniane, czy nie).

Całą sytuację widać na diagramie poniżej:

Pierwszy użytkownik pobiera produkt, który ma ustawioną cenę na wartość 10. Z jakiegoś powodu może go edytować dłuższą chwilę.

Chwilę później, również inny użytkownik pobiera do edycji ten sam produkt (w tym samym czasie, gdy pierwszy użytkownik go jeszcze edytuje). Może on zmienić nazwę produktu i zapisać go w bazie danych. W tym momencie, w bazie danych jest stara cena oraz nowa nazwa produktu.

Na końcu pierwszy użytkownik zmienia cenę produktu na 11 i zapisuje produkt w bazie. Przy okazji niestety nadpisuje nazwę produktu ustawioną przez drugiego użytkownika, przez co tracimy dane w systemie, a co może spowodować spore problemy.

Powyższe zachowanie nazywa się Last Win (ostatni wygrywa), w którym właśnie ostatni zapis nadpisuje wszystkie inne wcześniejsze zapisy. A jak widać, może to doprowadzić do utraty danych.

Oczywiście wszystko też zależy od tego, w jaki sposób aktualizujemy dane w bazie. Czy wszystkie pola, czy tylko te, które się zmieniły. Jedno i drugie podejście ma swoje konsekwencje i problemy.

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

Rozwiązania problemu

Mamy dwa podstawowe modele, które umożliwiają nam zaadresowanie tego problemu. Jest to pesymistyczna oraz optymistyczna współbieżność, które w różny sposób podchodzą do niego.

W pesymistycznej współbieżności na czas edycji blokujemy dany rekord przed innymi osobami/procesami/aplikacjami. Blokada może być tylko przed zapisem lub również i odczytem. W takiej sytuacji oczywiście nie ma problemu, że ktoś w między czasie zmieni dane, które następnie pierwsza osoba może nadpisać. Tutaj mogą pojawić się inne problemy, które musimy w jakiś sposób rozwiązać – na przykład deadlocki.

Natomiast w przypadku optymistycznej współbieżności nie blokujemy rekordu, a jedynie upewniamy się podczas zapisu, czy ktoś w międzyczasie go nie zmienił. Jeśli rekord znajduje się w tym samym stanie/wersji jak podczas początkowego odczytu, to go normalnie zapisujemy. Natomiast jeśli ktoś w między czasie go zmodyfikował, to mamy konflikt, który musimy rozwiązać.

W zależności od aplikacji konflikt możemy rozwiązać w różny sposób. Możemy wyświetlić odpowiednio informacje użytkownikowi z listą pól, które się różnią. Albo sami próbować na podstawie jakiejś logiki określić, która zawartość jest aktualna. Wszystko zależy od tego, co robi nasza aplikacja.

Optymistyczną współbieżność na ogół realizuje się poprzez dodanie dedykowanej kolumny do bazy danych. Kolumna ta określa wersję rekordu. Może to być jakiś int, który jest inkrementowany przy każdym zapisie. Równie dobrze sprawdza się data ostatniej modyfikacji rekordu. Często silniki baz danych mają dedykowane typy danych do tego celu.

W momencie aktualizacji danych porównujemy wcześniej odczytaną wersję rekordu, gdy jest taka sama, to zapisujemy dane. Gdy różna, to oznacza, że ktoś w między czasie zmienił dane i mamy konflikt. Obie sytuacje widać na diagramie poniżej:

Jak widzisz, oba typu współbieżności mają inną charakterystykę i dobrze sprawdzają się w różnych scenariuszach. Współbieżność optymistyczna sprawdza się w sytuacjach, gdy mamy dużo odczytów, a mało zapisów. Natomiast pesymistyczna, gdy częściej modyfikujemy dane.

Last Win

Czas przejść do kodu i zobaczyć, jak zachowa się Entity Framework Core w domyślnym modelu Last Win.

W testowej aplikacji mam prostą klasę Product z trzema właściwościami, która wygląda tak:

public class Product
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; }
public decimal Price { get; set; }
}
view raw Product.cs hosted with ❤ by GitHub

Do tego klasa DataContext do dostępu do bazy, w której na sztywno jest ustawiony connection string do testowej bazy:

public class DataContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(@"Server=.\SQLEXPRESS;Database=EFCoreOptimisticConcurrency;Trusted_Connection=True;");
}
public DbSet<Product> Products { get; set; }
}
view raw DataContext.cs hosted with ❤ by GitHub

W samej klasie Program, w której znajduje się cały przykład, na początku wywołuję metodę Setup. Jej zadaniem jest przygotowanie testowej bazy danych. Czyli w pierwszym uruchomieniu metoda dodaje testowy produkt. Natomiast w kolejnych przywraca go do stanu początkowego.

private static async Task Setup(Guid productId)
{
await using var db = new DataContext();
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == productId);
if (product == null)
{
product = new Product()
{
Id = productId
};
await db.Products.AddAsync(product);
}
product.Name = "product";
product.Price = 10;
await db.SaveChangesAsync();
}
view raw Setup.cs hosted with ❤ by GitHub

Do samej edycji produktu utworzyłem metodę EditProduct:

private static async Task EditProduct(Guid productId, Action<Product> editAction, int delay)
{
await using var db = new DataContext();
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == productId);
await Task.Delay(delay);
editAction(product);
await db.SaveChangesAsync();
}
view raw EditProduct.cs hosted with ❤ by GitHub

Metoda ma 3 parametry:

  • id produktu do edycji
  • delegat, w którym przekażę kod edytujący produkt
  • opóźnienie – do symulacji długiej edycji produktu

Logika metody jest dość prosta. Pobieramy produkt z bazy. Symulujemy opóźnienie. Edytujemy produkt i zapisujemy go do bazy.

Całość jest odpalana w metodzie Main w ten sposób:

static async Task Main(string[] args)
{
var productId = Guid.Parse("E440B325-AD1F-4B4D-9162-36DFB0F3A357");
await Setup(productId);
var tasks = new[]
{
EditProduct(productId, p => p.Price = 11, 1000),
EditProduct(productId, p => p.Price = 12, 100)
};
await Task.WhenAll(tasks);
var product = await GetProduct(productId);
Console.WriteLine($"Product: {product.Name}, price: {product.Price}");
}
view raw Main.cs hosted with ❤ by GitHub

Na początku wywołujemy metodę Setup, która przygotuje bazę do testów. Później w dwóch taskach odpalana jest metoda EditProduct. Pierwsza zmienia cenę na 11 i czeka 1000 milisekund. Natomiast druga zmienia cenę na 12 i czeka 100 milisekund.

Na końcu czekamy na wykonanie obu tasków. Pobieramy produkt i wyświetlamy jego nazwę oraz cenę. W efekcie działania aplikacji zobaczymy coś takiego:

Model last win - nadpisanie ceny produktu

Jak widać task, który miał dłuższe opóźnienie, a co za tym idzie jego zapis był później, nadpisał dane znajdujące się w bazie danych. W tym przypadku nie mamy żadnej informacji albo ostrzeżenia, że w międzyczasie dane się zmieniły.

Niepoprawność biznesowa danych

Za nim jeszcze przejdę do opisywania optymistycznej współbieżności w Entity Framework Core, chciałbym zwrócić Ci jeszcze uwagę na jeden problem.

Zmieńmy trochę nasz przykład. Zamiast modyfikować w obu taskach cenę, w drugim zmienimy nazwę produktu bez zmiany ceny. Czyli w metodzie Main będziemy mieli taki fragment kodu:

EditProduct(productId, p => p.Price = 11, 1000),
EditProduct(productId, p => p.Name = "new product", 100)
view raw Main2.cs hosted with ❤ by GitHub

W efekcie działania aplikacji zobaczymy coś takiego:

Zmiana ceny oraz nazwy produktu w różnych taskach

Czyli tak naprawdę w bazie zostały zapisane obie zmiany z tych dwóch tasków. Dzieje się tak, ponieważ Entity Framework Core podczas aktualizacji danych w zapytaniu uwzględnia tylko te właściwości/kolumny, które zostały zmodyfikowane. Co widać ładnie w profilerze:

Zapytanie aktualizujące cenę w bazie danych wygenerowane przez Entity Frameworka

Na pierwszy rzut oka mogłoby się wydać, że takie zachowanie jest poprawne i zadowalające. W większości przypadków faktycznie tak jest, ale czy zawsze?

Niestety okazuje się, że czasami takie zachowanie może przysporzyć więcej problemów niż korzyści. Zwróć uwagę, że w tym momencie pierwszy task operuje na danych („product”, 11). Natomiast drugi na („new product”, 10). Przy czym każdy z tych stanów może być poprawny, więc ewentualna walidacja nie zwróci błędów i dane zapiszą się w bazie.

A co w sytuacji, gdy ostateczny stan („new product”, 11) jest niepoprawny biznesowo? Niestety nie wyłapiemy czegoś takiego i możemy doprowadzić do poważnych konsekwencji.

Co gorsza znalezienie oraz odtworzenie takiego problemu jest bardzo trudne. Możemy później spędzić długie godziny w szukaniu problemu w kodzie, który doprowadził do niepoprawności danych. A niestety nie jest on jawnie widoczny.

Na szczęście optymistyczna współbieżność rozwiązuje nam ten problem.

Optymistyczna współbieżność w EF Core

Entity Framework Core ma wsparcie dla optymistycznej współbieżności i dodanie jej do aplikacji jest dość proste.

Po pierwsze do modelu dodaje właściwość, w której zapisze wersję obiektu/rekordu. W moim przypadku będzie to właściwość RowVersion w klasie Product:

public class Product
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; }
public decimal Price { get; set; }
public byte[] RowVersion { get; set; }
}
view raw Product2.cs hosted with ❤ by GitHub

Następnie w konfiguracji Entity Framework określamy poprzez metodę IsRowVersion, że tak właściwość właśnie będzie wykorzystywana do optymistycznej współbieżności. Robię to z wykorzystaniem klas EntityTypeConfiguration, która w przypadku klasy Product wygląda tak:

public class ProductEntityTypeConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.Property(p => p.RowVersion)
.IsRowVersion();
}
}

Aby powyższa klasa została wczytana przez Entity Framework musimy jeszcze nadpisać metodę OnModelCreating w klasie DataContext:

public class DataContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(@"Server=.\SQLEXPRESS;Database=EFCoreOptimisticConcurrency;Trusted_Connection=True;");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
public DbSet<Product> Products { get; set; }
}

Linijka z metody OnModelCreating spowoduje wczytanie wszystkich klas EntityTypeConfiguration z aktualnego assembly.

Po tych zmianach wystarczy wygenerować migrację i ją wykonać. A następnie możemy się cieszyć z optymistycznej współbieżności. W tym momencie, gdy uruchomimy testową aplikację, zobaczymy coś takiego:

Wyjątek podczas drugiej aktualizacji obiektu

Przy aktualizacji z dłuższego taska otrzymaliśmy wyjątek z informacją, że próbowaliśmy zaktualizować jeden rekord, ale żaden nie został zaktualizowany. W profilerze widzimy coś takiego:

Wygenerowane zapytanie przez Entity Framework przy optymistycznej współbieżności

W tym momencie Entity Framework Core do instrukcji UPDATE dodaje dodatkowy warunek w WHERE, w którym sprawdzana jest wartość w kolumnie RowVersion. Gdy obiekt w międzyczasie się zmienił, to RowVersion jest inne w bazie niż to wcześniej wczytane. Przez co rekord nie zostanie zmodyfikowany i Entity Framework Core wyrzuci ładnie wyjątek, który następnie trzeba w jakiś sposób obsłużyć.

Rozwiązywanie konfliktów

W przypadku optymistycznej współbieżności musimy w jakiś sposób obsłużyć występowanie konfliktów. Może być to wyświetlenie użytkownikowi informacji, że ktoś zmienił rekord i należy nanieść zmiany jeszcze raz. Możemy również spróbować w jakiś sposób automatycznie nanieść zmiany ponownie i spróbować jeszcze raz zapisać rekord w bazie. Tutaj w sumie wszystko zależy od sytuacji jaką mamy.

Co fajne w Entity Framework Core możemy w miarę łatwo wyciągnąć informacje o właściwościach i ich wartościach, które powodują konflikt. Mamy dostęp do oryginalnych oraz nowych wartości. A dodatkowo możemy pobrać z bazy aktualne dane i na podstawie tych wszystkich informacji wyświetlić użytkownikowi jakiś ładny widok, w którym może rozwiązać konflikt.

W testowym przykładzie rozszerzyłem metodę EditProduct o wyświetlanie informacji o skonfliktowanych właściwościach. Nowa wersja metody wygląda tak:

private static async Task EditProduct(Guid productId, Action<Product> editAction, int delay)
{
await using var db = new DataContext();
try
{
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == productId);
await Task.Delay(delay);
editAction(product);
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var databaseValues = await entry.GetDatabaseValuesAsync();
foreach (var property in entry.CurrentValues.Properties)
{
var proposedValue = entry.CurrentValues[property];
var databaseValue = databaseValues[property];
var originalValue = entry.OriginalValues[property];
if (proposedValue.Equals(databaseValue) || originalValue.Equals(databaseValue) || property.Name == "RowVersion")
{
continue;
}
Console.WriteLine($"ProposedValue: {proposedValue}, DatabaseValue: {databaseValue}, OriginalValue: {originalValue}.");
//proposedValues[property] = wartość do zapisu;
}
entry.OriginalValues.SetValues(databaseValues);
}
}
}

Starą zawartość metody opakowałem w blok try-catch. W catch wykorzystałem wyjątek DbUpdateConcurrencyException, który posiada właściwość Entries zawierającą listę obiektów, których nie udało się zapisać.

Pojedynczy obiekt z Entries zawiera dwie przydatne właściwości:

  • CurrentValues – lista aktualnych wartości, które miały zostać zapisane w bazie danych
  • OriginalValues – lista wartości pobranych pierwotnie z bazy danych.

Dodatkowo obiekt z Entries posiada metodę GetDatabaseValuesAsync, która umożliwia pobranie aktualnych wartości z bazy danych.

Metoda przechodzi po liście właściwości obiektu. Następnie ignoruje właściwości, których wartość do zapisu jest taka sama jak wartość w bazie (czyli nikt inny jej nie zmienił) lub wartość oryginalna jest taka sama (czyli dany użytkownik jej nie zmienił). Dodatkowo ignorujemy jeszcze właściwość RowVersion, bo jest ona naszą wewnętrzną właściwością i nie chcemy pokazać o niej informacji użytkownikowi.

Pozostałe właściwości wyświetlamy użytkownikowi, może on na podstawie tego rozwiązać konflikt i spróbować zapisać jeszcze raz rekordu.

W przypadku chęci automatycznego rozwiązania konfliktu możemy ręcznie ustawić nowe aktualne wartości. Następnie odświeżamy oryginalne wartości wywołując metodę SetValues na właściwości OriginalValues i wywołujemy ponownie metodę SaveChanges.

Oczywiście tutaj musimy pamiętać o tym, że po raz kolejny może wystąpić konflikt, który musimy ponownie obsłużyć.

Można również pokusić się o próbę generycznej obsługi konfliktów jakimś middleware lub innym tego typu mechanizmem.

Przykład

Na githubie (https://github.com/danielplawgo/EFCoreOptimisticConcurrency) znajdziesz przykład do tego wpisu. Gorąco zachęcam do pobrania go i zabawy z kodem.

Po jego pobraniu należy w klasie DataContext zmienić connection stringa do testowej bazy danych.

Podsumowanie

Mam nadzieje, że udało mi się pokazać Ci, że bazowanie tylko na modelu Last Win może powodować poważne problemy w aplikacji, które co gorsza mogą być dość trudne do zlokalizowania.

Dodatkowo z artykułu dowiedziałeś się, w jaki sposób można małym nakładem pracy włączyć optymistyczną współbieżność w Entity Framework Core.

A Ty wykorzystujesz optymistyczną współbieżność w swoich projektach?

Szkolenie Entity Framework Core

Szkolenie Entity Framework Core

Zainteresował Ciebie ten temat? A może chcesz więcej? Jak tak to zapraszam na moje autorskie szkolenie z Entity Framework Core.

3 thoughts on “Optymistyczna współbieżność w EF Core

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.