EF Core'da (Entity Framework Core) eşzamanlılık çakışmalarını önlemek için bir özelliği concurrency token olarak işaretlersiniz; SQL Server'da en kolay yol [Timestamp] özniteliği ya da IsRowVersion() ile bir rowversion kolonu kullanmaktır. EF Core bundan sonra her UPDATE ve DELETE komutunun WHERE koşuluna token'ın okunduğu andaki değerini ekler. Satır siz okuduktan sonra başkası tarafından değiştirildiyse komut hiçbir satırı etkilemez ve SaveChanges bir DbUpdateConcurrencyException fırlatır; uygulamanız bu istisnayı yakalayıp çakışmayı çözer.
Bu yazı iyimser eşzamanlılığın (optimistic concurrency) nasıl çalıştığını, veritabanı tarafından üretilen ve uygulama tarafından yönetilen token'ları, çakışma çözme kalıbını, bağlantısız senaryolardaki inceliği ve izolasyon seviyeleriyle alternatif yaklaşımı resmî belgelere dayanarak anlatır. Kaydetmenin temeli EF Core SaveChanges, orijinal ve veritabanı değerlerine erişim EF Core change tracker yazısında anlatılıyor.
Sorun: aynı veriyi aynı anda değiştirmek
Çoğu senaryoda veritabanı aynı anda birden çok uygulama örneği tarafından kullanılır ve her biri veriyi diğerlerinden bağımsız değiştirir. Aynı veri aynı anda değiştirildiğinde tutarsızlık ve veri bozulması oluşabilir. Belgelerin verdiği örnek, iki istemcinin aynı satırda birbiriyle bağlantılı farklı kolonları değiştirmesidir. Daha tanıdık bir durum da iki kullanıcının aynı kaydı formda açıp sırayla kaydetmesidir: herhangi bir önlem yoksa ikinci kayıt birincinin değişikliğini sessizce ezer.
Kendi sunucunuzda çalışan bir uygulamada bu çakışmalar, aynı veriyi işleyen birden çok istek, arka plan işi ya da birden çok uygulama örneği olduğunda doğal olarak ortaya çıkar. EF Core bu durum için kilitlemeye değil, çakışmayı kayıt anında yakalamaya dayalı bir mekanizma sunar.
İyimser ve kötümser eşzamanlılık

EF Core iyimser eşzamanlılık uygular; bu yaklaşım çakışmaların görece nadir olduğunu varsayar. Veriyi önceden kilitleyip sonra değiştiren kötümser (pessimistic) yaklaşımların aksine iyimser yaklaşım hiç kilit almaz; bunun yerine veri sorgulandığından beri değiştiyse kayıt işleminin başarısız olmasını sağlar. Hata uygulamaya bildirilir ve uygulama duruma göre davranır, örneğin tüm işlemi yeni veriyle tekrar dener.
| Yaklaşım | Kilit | Çakışma ne zaman fark edilir | EF Core karşılığı |
|---|---|---|---|
| İyimser (concurrency token) | Yok | Kayıt sırasında, etkilenen satır sayısıyla | DbUpdateConcurrencyException |
| Kötümser (kilitleme) | Okumada alınır | Diğer işlem kilitte bekler | Örneğin SQL Server repeatable read izolasyonu |
| Snapshot tipi izolasyon | Yok | Kendi güncellemenizde serileştirme hatası | Veritabanının izolasyon seviyesi |
Concurrency token nasıl çalışır?
EF Core'da iyimser eşzamanlılık bir özelliği concurrency token olarak yapılandırarak kurulur. Token, varlık sorgulandığında diğer özellikler gibi yüklenir ve izlenir. SaveChanges sırasında bir güncelleme ya da silme yapılırken veritabanındaki token değeri, EF Core'un okuduğu orijinal değerle karşılaştırılır. Belgelerdeki SQL Server örneğinde Person varlığının özel bir Version özelliği vardır:
public class Person
{
public int PersonId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
[Timestamp]
public byte[] Version { get; set; }
}var person = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
await context.SaveChangesAsync();Kişi yüklenirken token da yüklenir ve izlenir. FirstName değiştirilip kayıt istendiğinde, token yapılandırıldığı için EF Core şu komutu gönderir:
-- Belgelerdeki örnek çıktı (SQL Server)
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;WHERE koşulunda PersonId'nin yanında Version da vardır; satır yalnızca sorgulandığından beri Version kolonu değişmediyse güncellenir. Normal durumda araya giren bir güncelleme olmaz, komut bir satırı etkiler ve veritabanı bunu EF Core'a bildirir. Araya bir güncelleme girdiyse komut eşleşen satır bulamaz ve sıfır satır etkilendiğini bildirir; EF Core da DbUpdateConcurrencyException fırlatır.
Belgeler kapsamı da netleştirir: aynı istisna, eşzamanlı olarak değiştirilmiş bir satırı silmeye çalışırken de fırlatılır. Ancak ekleme işlemlerinde genellikle hiç fırlatılmaz; aynı anahtarla satırlar eklenmeye çalışılırsa veritabanı benzersizlik kısıtı ihlali verebilir, ama bu DbUpdateConcurrencyException değil sağlayıcıya özgü bir istisna olarak gelir.
Veritabanının ürettiği token: rowversion
Yukarıdaki [Timestamp] özniteliği özelliği SQL Server'daki rowversion kolonuna eşler. rowversion satır her güncellendiğinde kendiliğinden değiştiği için, tüm satırı koruyan ve en az çaba isteyen token'dır. Aynı yapılandırma Fluent API ile şöyle yapılır:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Person>()
.Property(p => p.Version)
.IsRowVersion();
}Değer üretimi belgesi de aynı noktayı başka bir açıdan anlatır: eklemede ya da güncellemede üretilen olarak yapılandırılmış ve concurrency token olarak işaretlenmiş byte[] özellikler rowversion veri tipiyle kurulur, böylece değerler veritabanında otomatik üretilir. Ayrıntılar EF Core değer üretimi yazısında.
Uygulamanın yönettiği token
Token'ı veritabanına bırakmak yerine uygulama kodunda yönetebilirsiniz. Bu, SQLite gibi kendiliğinden güncellenen bir tipi olmayan veritabanlarında iyimser eşzamanlılık kullanmayı mümkün kılar. SQL Server'da bile hangi kolon değişikliklerinin token'ı yenileyeceği üzerinde ince denetim sağlar; örneğin önbelleğe alınmış ya da önemsiz bir değer tutan bir özelliğin değişmesinin çakışma doğurmasını istemeyebilirsiniz. Belgelerdeki örnek bir GUID özelliğini token yapar:
public class Person
{
public int PersonId { get; set; }
public string FirstName { get; set; }
[ConcurrencyCheck]
public Guid Version { get; set; }
}
// Fluent API karşılığı
modelBuilder.Entity<Person>()
.Property(p => p.Version)
.IsConcurrencyToken();Bu özellik veritabanında üretilmediği için her kayıtta değeri sizin atamanız gerekir:
var person = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
await context.SaveChangesAsync();Her kayıtta yeni bir GUID atanmasını istiyorsanız belgeler bunu bir SaveChanges interceptor'ı ile yapabileceğinizi söyler. Öte yandan token'ı elle yönetmenin bir avantajı, ne zaman yenileneceğini tam olarak sizin belirlemeniz ve böylece gereksiz çakışmalardan kaçınabilmenizdir. Interceptor'ların genel kullanımı EF Core SQL loglama ve interceptor yazısında.
Çakışmayı çözmek

Token nasıl kurulmuş olursa olsun iyimser eşzamanlılık için uygulamanızın DbUpdateConcurrencyException fırlatıldığı durumu doğru ele alması gerekir; buna çakışmayı çözme denir. Belgeler üç seçenek sayar. Birincisi kullanıcıya güncellemenin çakışan değişiklikler yüzünden başarısız olduğunu söylemektir; kullanıcı yeni veriyi yükleyip tekrar dener. İkincisi, otomatik bir güncelleme yapıyorsanız veriyi yeniden sorgulayıp döngüde hemen tekrar denemektir. Üçüncüsü, daha gelişmiş olanı, bekleyen değişiklikleri veritabanındaki yeni değerlerle birleştirmektir (merge); hangi değerlerin birleştirileceği uygulamaya bağlıdır ve süreç iki değer kümesinin de gösterildiği bir arayüzle kullanıcıya yaptırılabilir.
Çakışmayı çözerken elinizde üç değer kümesi vardır:
- Şimdiki değerler (current values): uygulamanın veritabanına yazmaya çalıştığı değerler.
- Orijinal değerler (original values): herhangi bir düzenleme yapılmadan önce veritabanından okunan değerler.
- Veritabanı değerleri (database values): şu anda veritabanında duran değerler.
Genel akış şöyledir: SaveChanges sırasında DbUpdateConcurrencyException yakalanır; DbUpdateConcurrencyException.Entries ile etkilenen varlıklar için yeni bir değişiklik kümesi hazırlanır; token'ın orijinal değerleri veritabanındaki güncel değerleri yansıtacak şekilde yenilenir; çakışma kalmayana kadar işlem tekrarlanır. Belgelerdeki örnekte FirstName ve LastName token olarak yapılandırılmıştır ve çakışma, kayıttan önce ham SQL ile adı değiştirerek bilerek oluşturulur:
using var context = new PersonContext();
var person = await context.People.SingleAsync(p => p.PersonId == 1);
person.PhoneNumber = "555-555-5555";
// Çakışmayı taklit etmek için veritabanındaki adı değiştir
await context.Database.ExecuteSqlRawAsync(
"UPDATE dbo.People SET FirstName = 'Jane' WHERE PersonId = 1");
var saved = false;
while (!saved)
{
try
{
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: hangi değerin yazılacağına karar verin
// proposedValues[property] = <yazılacak değer>;
}
// Bir sonraki denemede token kontrolünü geçmek için orijinal değerleri yenile
entry.OriginalValues.SetValues(databaseValues);
}
else
{
throw new NotSupportedException(
"Don't know how to handle concurrency conflicts for " + entry.Metadata.Name);
}
}
}
}Kodun kritik satırı entry.OriginalValues.SetValues(databaseValues)'tir. Orijinal değerler veritabanındaki güncel değerlerle yenilenmezse bir sonraki denemede WHERE koşulu yine eski token'ı arar ve döngü hiç bitmez. TODO yorumunun olduğu yer ise iş kuralınızın yeridir: önerilen değeri mi, veritabanındaki değeri mi, yoksa ikisinin birleşimini mi yazacağınıza orada karar verirsiniz. Önerilen değerleri değiştirmeden bırakırsanız sizin değişiklikleriniz yazılır; önerilen değerleri veritabanı değerleriyle değiştirirseniz veritabanındaki hâl korunur. Herhangi bir varlık tipi için ne yapacağınızı bilmiyorsanız örnekte olduğu gibi açıkça hata fırlatmak, sessizce yanlış veri yazmaktan iyidir.
Person dışındaki tipler için ayrı dallar ekleyebilir ya da tüm varlıklarınızın uyguladığı bir arayüz üzerinden ortak bir çözüm yazabilirsiniz. Çakışmanın bir silme işleminden kaynaklanabileceğini de unutmayın: belgelere göre aynı istisna, eşzamanlı olarak değiştirilmiş bir satırı silmeye çalışırken de fırlatılır. Belgelerdeki interceptor örneği bu türden bir silme çakışmasını ele alır.
Interceptor ile silme çakışmalarını bastırmak
ISaveChangesInterceptor arayüzünün ThrowingConcurrencyException ve ThrowingConcurrencyExceptionAsync metotları istisna fırlatılmadan hemen önce çağrılır ve istisnayı bastırmanıza izin verir. Belgelerdeki örnekte iki istek aynı varlığı neredeyse aynı anda silmeye çalışır; ikinci silme, satır artık olmadığı için başarısız olur. Sonuçta varlık zaten silinmiştir, bu yüzden interceptor çakışmadaki tüm girişler Deleted durumundaysa InterceptionResult.Suppress() döndürür:
public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
public InterceptionResult ThrowingConcurrencyException(
ConcurrencyExceptionEventData eventData,
InterceptionResult result)
{
if (eventData.Entries.All(e => e.State == EntityState.Deleted))
{
return InterceptionResult.Suppress();
}
return result;
}
public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
ConcurrencyExceptionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
=> new(ThrowingConcurrencyException(eventData, result));
}Belgeler, uygulama hem SaveChanges hem SaveChangesAsync kullanabiliyorsa iki metodu da uygulamanın önemli olduğunu not eder. Ölçüm tarafında EF Core'un metrikleri arasında iyimser eşzamanlılık hatalarının toplamını ve saniyedeki sayısını veren sayaçlar da vardır; bu sayaçlar DbUpdateConcurrencyException fırlatılmasına karşılık gelir ve üretimde çakışmaların ne sıklıkla yaşandığını görmenizi sağlar.
Bağlantısız senaryolarda token
Web uygulamalarında varlık bir istekte okunur, istemciye gider ve başka bir istekte yeni bir context ile kaydedilir. Bu durumda change tracker belgelerine göre Attach veya Update ile iliştirilen varlığın orijinal değerleri bilinmez ve şimdiki değerlerle aynı döner. Token karşılaştırması orijinal değer üzerinden yapıldığı için bunun anlamı şudur: istemcinin, varlığı okuduğu andaki token değerini geri göndermesi gerekir. Bu değer gelen nesnenin üzerinde duruyorsa ve varlığı Update ile iliştiriyorsanız orijinal değer olarak o kullanılır. Varlığı önce sorgulayıp SetValues ile güncelliyorsanız, sorgudan gelen orijinal token yerine istemcinin gönderdiğini PropertyEntry.OriginalValue ile atamanız gerekir; aksi hâlde kontrol, kullanıcının gördüğü sürümü değil sizin az önce okuduğunuz sürümü doğrular.
var person = await context.People.FindAsync(dto.PersonId);
context.Entry(person).CurrentValues.SetValues(dto);
context.Entry(person).Property(p => p.Version).OriginalValue = dto.Version; // istemcinin gördüğü sürüm
await context.SaveChangesAsync(); // arada değiştiyse DbUpdateConcurrencyExceptionBu kalıp belgelerdeki PropertyEntry.OriginalValue üyesinin (orijinal değeri okur ve ayarlar) ve concurrency token tanımının (veritabanındaki değer orijinal değerle karşılaştırılır) birleşimidir. Bağlantısız varlıkların genel olarak nasıl iliştirileceği disconnected entity yazısında anlatılıyor.
ExecuteUpdate ile elle denetim
ExecuteUpdate ve ExecuteDelete change tracker'ı kullanmadığı için token denetimini otomatik yapmaz. Bunun yerine Where koşuluna token'ı ekleyip dönen etkilenen satır sayısını kontrol edersiniz; sıfırsa token büyük olasılıkla eşzamanlı bir güncellemeyle değişmiştir. Örnek kod ExecuteUpdate ve ExecuteDelete yazısında.
Alternatif: izolasyon seviyeleri
Concurrency token'lar tutarlılığı korumanın tek yolu değildir. Belgeler repeatable read izolasyon seviyesini örnek verir: çoğu veritabanında bu seviye, transaction'ın veriyi başladığı andaki hâliyle görmesini ve sonraki eşzamanlı işlemlerden etkilenmemesini garanti eder. Veritabanı bunu iki yoldan biriyle yapar. Birincisinde satır sorgulandığında paylaşımlı kilit alınır ve satırı güncellemeye çalışan dış transaction bekler; bu bir kötümser kilitleme biçimidir ve SQL Server'ın repeatable read seviyesi böyle çalışır. İkincisinde kilit alınmaz, dış transaction satırı güncelleyebilir ama sizin transaction'ınız güncelleme yapmaya çalıştığında serileştirme hatası alır; bu EF'in token özelliğine benzeyen bir iyimser kilitleme biçimidir ve SQL Server'ın snapshot izolasyonu ile PostgreSQL'in repeatable read seviyesi böyle çalışır. Serializable seviye repeatable read'in garantilerini (ve fazlasını) verdiği için bu açıdan aynı şekilde işler.
Daha yüksek izolasyon seviyesi kullanmak daha basittir, token gerektirmez ve transaction içindeki sorguların hep aynı veriyi görmesi gibi ek avantajlar sağlar. Ama belgeler iki dezavantajı vurgular. Kilitleme kullanan uygulamalarda aynı satırı değiştirmek isteyen diğer transaction'lar sizin transaction'ınız boyunca bekler; bu eşzamanlı performansı olumsuz etkileyebilir, bu yüzden transaction'ları kısa tutmak gerekir. Daha önemlisi bu yaklaşım transaction'ın tüm işlemleri kapsamasını gerektirir: bir kişiyi kullanıcıya gösterip düzenlemesini beklerseniz transaction'ın uzun süre açık kalması gerekir ki bu çoğu durumda kaçınılması gereken bir şeydir. Bu yüzden izolasyon yaklaşımı, içindeki işlemlerin hemen çalıştığı ve dış girdiye bağlı olmadığı durumlar için uygundur; form tabanlı düzenlemelerde token yaklaşımı daha doğaldır.
İzolasyon seviyelerinin veritabanı tarafındaki anlamı transaction ve izolasyon seviyeleri yazısında, EF Core'da transaction açma ve savepoint kullanımı EF Core transaction yönetimi yazısında anlatılıyor. Transaction belgesi ilginç bir ayrıntı da verir: bir transaction açıkken SaveChanges çağrılırsa EF Core kayıttan önce otomatik bir savepoint oluşturur ve hata olursa transaction'ı bu noktaya geri alır; bu, özellikle iyimser eşzamanlılık sorunlarında düzeltip yeniden denemeyi mümkün kılar. Belgelere göre SQL Server'ın MARS özelliği açıksa savepoint oluşturulmaz.
Sık yapılan hatalar

- İstisnayı yakalayıp yutmak: çakışmayı çözmeden devam etmek kullanıcının değişikliğinin kaybolduğu anlamına gelir.
- Döngüde OriginalValues'u yenilememek: her deneme aynı token'la başarısız olur ve döngü bitmez.
- Bağlantısız senaryoda token'ı istemciden almamak: kontrol, kullanıcının gördüğü sürüm yerine sunucunun yeni okuduğu sürüme karşı yapılır ve çakışma yakalanmaz.
- Uygulama tarafı token'ı yenilemeyi unutmak:
[ConcurrencyCheck]ile işaretli GUID'e yeni değer atamazsanız sonraki yazarlar çakışmayı fark etmez. - SQLite'ta rowversion beklemek: SQLite veritabanı tarafından üretilen token desteklemez; uygulama tarafı token kullanın.
- ExecuteUpdate'in token'ı kontrol ettiğini sanmak: koşulu ve satır sayısı kontrolünü siz eklemelisiniz.
- Uzun açık transaction ile kilitlemek: kullanıcı girdisi bekleyen akışlarda izolasyon tabanlı kilitleme yerine token tercih edin.
Sık Sorulan Sorular
[Timestamp] ile [ConcurrencyCheck] arasındaki fark nedir?
[Timestamp] (Fluent API'de IsRowVersion()) SQL Server'da satır her güncellendiğinde veritabanının kendiliğinden değiştirdiği bir rowversion kolonu kurar. [ConcurrencyCheck] (Fluent API'de IsConcurrencyToken()) ise herhangi bir özelliği token yapar; değerini her kayıtta uygulamanın değiştirmesi gerekir.
DbUpdateConcurrencyException ekleme sırasında da fırlar mı?
Belgelere göre genellikle hayır. Aynı anahtarla eşzamanlı ekleme bir benzersizlik ihlali üretebilir, ancak bu sağlayıcıya özgü bir istisna olarak gelir.
Her tabloya token eklemeli miyim?
Belgeler bir kural koymaz; token, aynı satırın eşzamanlı değiştirilmesinin sorun olduğu varlıklar içindir. Bir alanın değişmesinin çakışma sayılmasını istemiyorsanız uygulama tarafı token ile hangi değişikliklerin token'ı yenileyeceğini kendiniz seçebilirsiniz.
Çakışmada kimin değişikliği kazanmalı?
Bu bir iş kararıdır. Belgelerdeki örnek, TODO satırında önerilen ve veritabanı değerlerini özellik özellik karşılaştırıp hangisinin yazılacağına karar vermenizi bekler; seçenekler kullanıcıya sormak, tekrar denemek ya da değerleri birleştirmektir.
Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.
İlgili içerikler: EF Core öğrenme rehberi · SaveChanges · Disconnected entity · Transaction yönetimi · ExecuteUpdate ve ExecuteDelete · İzolasyon seviyeleri