Mert Özen Her Satırda Daha İleri
Veritabanı Entity Framework Core — #14

EF Core'da Concurrency: Token, rowversion ve DbUpdateConcurrencyException

Mert Özen 5 Kas 2026 10 dk 1 görüntülenme
EF Core'da Concurrency: Token, rowversion ve DbUpdateConcurrencyException

Lost update'i gerçek bir örnekle görüyor, concurrency token ile optimistic concurrency kuruyor, SQL Server'da rowversion'ı, SQLite'ta Guid sürüm kolonunu kullanıyor ve DbUpdateConcurrencyException'ı retry ile çözüyoruz.

Sürüm notu: Bu yazıdaki kodlar EF Core 6 ile yazılmıştır ve EF Core 10'da da değişmeden çalışır.

Önce Tahmin Et: Stokta Kaç Kitap Kaldı?

Elimizde en sade hâliyle bir Book entity'si ve context'i var. Henüz concurrency'ye dair hiçbir ayar yok:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
    public int Stock { get; set; }
}

public class BookstoreContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=bookstore.db");
}

Dune'dan stokta 5 tane var. Alice ve Bob aynı anda ürün sayfasını açıyor; iki ayrı istek, iki ayrı context. Alice 2, Bob 1 kitap alıyor:

using var alice = new BookstoreContext();
using var bob = new BookstoreContext();

var aliceBook = await alice.Books.SingleAsync(b => b.Title == "Dune");
var bobBook = await bob.Books.SingleAsync(b => b.Title == "Dune"); // ikisi de Stock = 5 gördü

aliceBook.Stock -= 2; // Alice 2 tane alıyor
await alice.SaveChangesAsync();

bobBook.Stock -= 1; // Bob 1 tane alıyor
await bob.SaveChangesAsync();

using var check = new BookstoreContext();
Console.WriteLine($"Stock: {(await check.Books.SingleAsync()).Stock}");

Toplam 3 kitap satıldı, stokta 2 kalması gerekiyor. Ekrana ne yazar? Tahminini yap, sonra devam et.

Lost Update: Son Yazan Kazanır

Çıktı EF Core 6'da da EF Core 10'da da aynı:

Stock: 4

İki SaveChanges da hatasız çalıştı ve Alice'in satışı kayboldu. Bob'un context'i kitabı stok 5 iken okumuştu. Bob 1 düşüp 4'ü yazdığında, Alice'in bu arada yazdığı 3'ün üzerine yazdı. Bu hatanın adı lost update. Önceki yazıdaki transaction'lar burada işe yaramaz: iki SaveChanges ayrı ayrı mükemmel şekilde atomik, sorun ikisinin arasında. Veritabanı açısından ortada bir hata yok; UPDATE "Books" SET "Stock" = 4 WHERE "Id" = 1 gayet geçerli bir komut.

Bu duruma iki klasik cevap var. Pessimistic concurrency, okurken satırı kilitler; Bob, Alice işini bitirene kadar bekler. Kilitler pahalıdır, uzun sürerse uygulamayı kilitler ve EF Core'da bunun hazır bir API'si yoktur. Optimistic concurrency ise "çakışma nadirdir" diye varsayar: kimse kilitlenmez, ama yazarken "okuduğumdan beri bu satır değişti mi?" diye kontrol edilir. Değiştiyse yazma reddedilir ve uygulama ne yapacağına karar verir. EF Core'un desteklediği yöntem budur.

Optimistic Concurrency ve Concurrency Token

EF Core'a hangi property'nin değişimini kontrol etmesi gerektiğini söylemen yeterli. Bu property'ye concurrency token denir. Data annotation ile:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }

    [ConcurrencyCheck]
    public int Stock { get; set; }
}

Aynı ayar Fluent API ile şöyle:

modelBuilder.Entity<Book>()
    .Property(b => b.Stock)
    .IsConcurrencyToken();

Aynı Alice ve Bob senaryosunu bu modelle çalıştırıyoruz. Bob'un SaveChanges'ini bir try bloğuna aldık:

try
{
    await bob.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    Console.WriteLine(ex.Message);
}

EF Core 10 logunda iki UPDATE var ve ikisi birebir aynı. Biri şöyle:

Executed DbCommand (0ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (DbType = Int32), @p2='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
UPDATE "Books" SET "Stock" = @p0
WHERE "Id" = @p1 AND "Stock" = @p2
RETURNING 1;

Fark WHERE satırında. EF Core artık yalnız Id'ye bakmıyor, "Stock" = @p2 ile token'ın okunduğu andaki değerini, yani original value'sunu da şart koşuyor. Alice'in komutu stok 5 iken çalıştı ve bir satırı güncelledi. Bob'un komutu da Stock = 5 şartıyla gitti, ama satırdaki değer artık 3. Hiçbir satır eşleşmedi, etkilenen satır sayısı 0 geldi ve EF Core şu exception'ı fırlattı:

The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.

EF Core 6'da mesaj aynı; yalnız link http ile başlıyor ve SQL'de RETURNING 1 yerine SELECT changes(); var. Mekanizmanın özü şu: "beklediğim kadar satır etkilenmedi" bilgisi. Satır silinmiş olsa da aynı exception gelir.

Stok gibi tek bir property'yi token yapmak, yalnız o property'deki çakışmaları yakalar. Bob stok yerine fiyatı değiştirseydi, Alice'in stok güncellemesine rağmen kaydı başarılı olurdu. Çoğu zaman istediğin "satırın herhangi bir yeri değiştiyse haber ver" olur. Bunun için satırın her güncellemede değişen bir sürüm kolonuna ihtiyacı var.

SQL Server'da rowversion

SQL Server bu işi kendisi yapar. rowversion tipindeki bir kolon, satır her güncellendiğinde veritabanı tarafından otomatik artırılır. EF Core'da bunu [Timestamp] attribute'u ile işaretlersin; Fluent API karşılığı .IsRowVersion():

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public int Stock { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; } = null!;
}

Database.GenerateCreateScript() ile SQL Server provider'ının ürettiği DDL'e baktık. Bunun için çalışan bir SQL Server gerekmiyor; script'i model üretiyor:

CREATE TABLE [Books] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Stock] int NOT NULL,
    [RowVersion] rowversion NOT NULL,
    CONSTRAINT [PK_Books] PRIMARY KEY ([Id])
);

Bu kolon hem concurrency token'dır hem de değeri veritabanında üretilir. EF Core her UPDATE'in WHERE koşuluna RowVersion'ı ekler ve yeni değeri aynı komutta geri okur. Kendi kodunda bu property'ye dokunman gerekmez. SQL Server'ı denemek istersen Docker ile mcr.microsoft.com/mssql/server imajını kullanabilirsin.

Aynı modeli SQLite'a taşırsan ne olur? Denedik:

SQLite Error 19: 'NOT NULL constraint failed: Books.RowVersion'.

SQLite'ta rowversion gibi bir tip yok. EF Core kolonu sıradan bir BLOB NOT NULL olarak oluşturuyor, değeri de veritabanının üreteceğini varsayıp INSERT'e koymuyor. Kimse üretmeyince ilk kayıtta patlıyor. SQLite'ta sürümü uygulama tarafında üretmek gerekiyor.

SQLite'ta Sürüm Kolonu: Guid Token

En sade yöntem, bir Guid property'yi concurrency token yapmak ve her kayıtta yeni bir değer atamak. Atamayı unutmamak için SaveChangesAsync'i override ediyoruz:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public Guid Version { get; set; }
}

public class BookstoreContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=bookstore.db");

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>()
            .Property(b => b.Version)
            .IsConcurrencyToken();
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        foreach (var entry in ChangeTracker.Entries<Book>())
        {
            if (entry.State is EntityState.Added or EntityState.Modified)
                entry.Entity.Version = Guid.NewGuid(); // her yazışta yeni sürüm
        }

        return base.SaveChangesAsync(cancellationToken);
    }
}

Bu modelde stoğu güncelleyen bir SaveChanges, EF Core 10'da şu SQL'i üretiyor:

Executed DbCommand (0ms) [Parameters=[@p2='?' (DbType = Int32), @p0='?' (DbType = Int32), @p1='?' (DbType = Guid), @p3='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']
UPDATE "Books" SET "Stock" = @p0, "Version" = @p1
WHERE "Id" = @p2 AND "Version" = @p3
RETURNING 1;

Yeni sürüm SET'e, eski sürüm WHERE'e gidiyor. Artık satırın hangi kolonu değişirse değişsin, sürüm de değişiyor ve elinde eski sürüm olan herkesin yazması reddediliyor. Guid yerine her seferinde bir artırılan bir int da kullanabilirsin; mantık aynı. Atamayı burada override ile yaptık; interceptor'lar yazısında aynı işi daha temiz bir yerde yapacağız. Bir uyarı: bu override yalnız SaveChangesAsync'i kapsıyor. Kodda senkron SaveChanges da kullanılıyorsa onu da override etmelisin.

DbUpdateConcurrencyException'ı Çözmek

Exception'ı yakalamak işin yarısı. Asıl soru: şimdi ne yapacağız? ex.Entries çakışan entity'lerin EntityEntry'lerini verir. Her entry üzerinde üç değer seti var: CurrentValues (senin yazmak istediğin), OriginalValues (okuduğun andaki) ve GetDatabaseValuesAsync() ile çekebileceğin veritabanındaki güncel hâl. Çözüm stratejileri bu üçünü nasıl birleştirdiğine göre değişir.

Veritabanı kazanır: kendi değişikliklerinden vazgeçip güncel hâli yüklersin.

// veritabanı kazanır: kendi değişikliklerini at, güncel hâli yükle
await entry.ReloadAsync();

İstemci kazanır: original value'ları veritabanındaki hâle çekersin. Token artık güncel olduğu için ikinci SaveChanges geçer ve senin değerlerin üzerine yazar. Bu, lost update'i bilerek seçmek demektir; ne yaptığını biliyorsan kullan.

// istemci kazanır: token'ı güncelle, kendi değerlerinle tekrar yaz
var databaseValues = await entry.GetDatabaseValuesAsync();
entry.OriginalValues.SetValues(databaseValues!);
await bob.SaveChangesAsync();

Stok gibi bir iş kuralında ikisi de yanlış. Doğrusu, güncel veriyle kuralı yeniden çalıştırmak: stok hâlâ yetiyorsa tekrar düş, yetmiyorsa satışı reddet. Bunu küçük bir retry döngüsüyle yapalım:

public static async Task BuyAsync(BookstoreContext db, Book book, int quantity)
{
    for (var attempt = 1; ; attempt++)
    {
        if (book.Stock < quantity)
            throw new InvalidOperationException($"Not enough stock for {book.Title}");

        book.Stock -= quantity;
        try
        {
            await db.SaveChangesAsync();
            return;
        }
        catch (DbUpdateConcurrencyException ex) when (attempt < 3)
        {
            var entry = ex.Entries.Single();
            var databaseValues = await entry.GetDatabaseValuesAsync();
            if (databaseValues is null)
                throw new InvalidOperationException($"{book.Title} was deleted");

            Console.WriteLine($"Conflict on attempt {attempt}, stock in database: {databaseValues["Stock"]}");

            // veritabanındaki hâli hem original hem current değer yap, kuralı baştan uygula
            entry.OriginalValues.SetValues(databaseValues);
            entry.CurrentValues.SetValues(databaseValues);
        }
    }
}

Alice ve Bob'u bu method ile tekrar çalıştırıyoruz. Bob yine eski veriyle başlıyor:

using var alice = new BookstoreContext();
using var bob = new BookstoreContext();

var aliceBook = await alice.Books.SingleAsync(b => b.Title == "Dune");
var bobBook = await bob.Books.SingleAsync(b => b.Title == "Dune");

await BuyAsync(alice, aliceBook, 2);
await BuyAsync(bob, bobBook, 1); // eski veriyle başlıyor

using var check = new BookstoreContext();
Console.WriteLine($"Stock: {(await check.Books.SingleAsync()).Stock}");
Conflict on attempt 1, stock in database: 3
Stock: 2

Bob'un ilk denemesi çakıştı, döngü veritabanındaki 3'ü aldı, kuralı yeniden uyguladı ve ikinci denemede 2'yi yazdı. databaseValues'un null gelmesi, satırın bu arada silindiği anlamına gelir; o durumda tekrar denemenin anlamı yok. when (attempt < 3) filtresi de sonsuz döngüye karşı sigorta. Çok yoğun bir üründe üç deneme de çakışırsa exception yukarı çıkar ve kullanıcıya "tekrar dene" demek gerekir.

API Senaryosu: Token İstemciden Gelir

Web API'de durum biraz farklı. Kullanıcı formu açtığında GET ile kitabı alır, beş dakika sonra PUT ile gönderir. Bu iki istek arasında context çoktan dispose olmuştur. PUT isteğinde kitabı yeniden okursan, token'ın original value'su da yeni okunan değer olur ve aradaki değişikliği hiçbir zaman fark etmezsin. Çözüm, istemcinin gördüğü sürümü istekle geri göndermesi ve onu original value olarak vermek:

public record UpdatePriceRequest(decimal Price, Guid Version);

public static async Task UpdatePriceAsync(BookstoreContext db, int id, UpdatePriceRequest request)
{
    var book = await db.Books.SingleAsync(b => b.Id == id);

    book.Price = request.Price;
    db.Entry(book).Property(b => b.Version).OriginalValue = request.Version; // istemcinin gördüğü sürüm

    await db.SaveChangesAsync();
}

Kullanıcı formu açtıktan sonra biri kitabı satın alırsa:

Guid versionSeenByClient;
using (var db = new BookstoreContext())
    versionSeenByClient = (await db.Books.SingleAsync()).Version; // GET /books/1

using (var db = new BookstoreContext())
{
    var book = await db.Books.SingleAsync();
    book.Stock -= 1; // bu arada biri kitap satın aldı
    await db.SaveChangesAsync();
}

try
{
    using var db = new BookstoreContext();
    await UpdatePriceAsync(db, 1, new UpdatePriceRequest(14.9m, versionSeenByClient)); // PUT /books/1
}
catch (DbUpdateConcurrencyException)
{
    Console.WriteLine("409 Conflict: the book was changed by someone else");
}
409 Conflict: the book was changed by someone else

Kritik satır OriginalValue = request.Version. Onu silersen aynı akış hatasız çalışır ve kullanıcının eski ekrana bakarak verdiği karar sessizce kaydedilir. ASP.NET Core Web API serisindeki dille söylersek, bu exception'ı 409 Conflict'e çevirmek controller'ın ya da global hata yönetiminin işi. Sürüm bilgisi DTO'da istemciye gider; HTTP tarafında aynı fikri ETag ve If-Match header'larıyla da kurabilirsin.

Sık Düşülen Tuzaklar

Catch sırası. DbUpdateConcurrencyException, DbUpdateException'dan türer. Önce DbUpdateException'ı yakalayan bir catch yazarsan concurrency bloğuna hiç gelinmez. Özelden genele sırala.

Raw SQL token'ı atlar. ExecuteSqlRaw ile yazılan bir UPDATE, Guid sürümünü değiştirmez; SQL Server'daki rowversion ise her durumda değişir. Uygulama tarafında token üretiyorsan, change tracker'ı atlayan her yazma yolunda token'ı da sen güncellemelisin.

Token olarak DateTime. "Son güncelleme zamanı" kolonunu token yapmak cazip görünür, ama aynı milisaniyede gelen iki yazma ya da veritabanının hassasiyeti yüzünden çakışmayı kaçırabilir. Guid ya da artan sayı daha güvenlidir.

Exception sonrası context'i olduğu gibi kullanmak. Çakışan entry hâlâ Modified ve eski token'ı taşıyor. Çözmeden tekrar SaveChanges dersen aynı exception'ı yine alırsın. Ya değerleri yukarıdaki gibi düzelt ya da context'i bırakıp yenisiyle başla.

Küçük Bir Deneme

★☆☆ Guid sürümlü modelde Bob'un BuyAsync çağrısından hemen önce Alice kitabı silsin. Bob hangi mesajla duruyor? Kodda bu yolu hangi satır yakalıyor?

★★☆ Guid sürümlü modelde Bob stoğa değil fiyata dokunsun. Çakışma yine yakalanıyor mu? Aynı denemeyi yalnız Stock'u token yapan modelle tekrarla ve farkı açıkla.

★★★ BuyAsync'i birden fazla kitap içeren bir sepet için yeniden yaz: ex.Entries birden fazla entry döndürebilir. Hangi kitabın stoğu yetmediyse onun adını içeren bir hata mesajı üret, yeten kitaplar için tekrar dene.

Buraya kadar bütün okuma ve yazmaları LINQ ve change tracker üzerinden yaptık. Ama bazen elinde EF Core'un üretmediği bir SQL vardır: hazır bir view, veritabanına özgü bir fonksiyon ya da yıllardır çalışan bir stored procedure. Bir sonraki yazıda FromSqlRaw, FromSqlInterpolated, ExecuteSqlRaw ve SQL injection'a bakacağız.