İLERİ
Concurrency Tokens
Aynı kaydı iki kullanıcı aynı anda güncellemeye çalıştığında veri kaybını önleyen mekanizma. EF Core "optimistic concurrency" kullanır — kayıt okunduğundaki versiyonu saklar, UPDATE sırasında versiyon değişmişse hata fırlatır.
21.1 Optimistic vs Pessimistic Concurrency
| Strateji | Açıklama | EF Core Desteği |
|---|---|---|
| Optimistic | Çakışma olursa kaydetme anında yakala | Varsayılan |
| Pessimistic | Satırı kilitle, başkası okumasın | EF desteklemez (Raw SQL ile mümkün) |
EF Core optimistic concurrency kullanır: Okuma sırasında kilit almaz, yazma anında "bu kayıt okunduğundan beri değişmiş mi?" kontrolü yapar.
21.2 RowVersion (Timestamp) Token
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
[Timestamp] // Data Annotation
public byte[] RowVersion { get; set; }
}
// Fluent API (EntityTypeConfiguration)
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.Property(p => p.RowVersion)
.IsRowVersion(); // byte[] → SQL Server ROWVERSION
}
}
Üretilen SQL:
CREATE TABLE [Products] (
[Id] INT IDENTITY(1,1) NOT NULL,
[Name] NVARCHAR(200) NOT NULL,
[Price] DECIMAL(18,2) NOT NULL,
[Stock] INT NOT NULL,
[RowVersion] ROWVERSION NOT NULL, -- SQL Server her UPDATE'te otomatik artırır
CONSTRAINT [PK_Products] PRIMARY KEY ([Id])
);
CREATE TABLE products (
id INTEGER GENERATED ALWAYS AS IDENTITY,
name VARCHAR(200) NOT NULL,
price NUMERIC(18,2) NOT NULL,
stock INTEGER NOT NULL,
-- PostgreSQL'de ROWVERSION yok → xmin system column kullanılır
-- xmin: her satırın oluşturulduğu/güncellendiği transaction ID'si (otomatik, gizli)
CONSTRAINT pk_products PRIMARY KEY (id)
);
-- xmin'e erişim:
SELECT xmin, id, name, price FROM products;
-- EF Core: builder.UseXminAsConcurrencyToken();
ROWVERSION(eski adıylaTIMESTAMP) 8-byte binary değerdir. Her UPDATE'te SQL Server otomatik artırır — uygulama kodu bu değeri hiç set etmez. PostgreSQL'de isexminsystem column aynı rolü üstlenir (gizlidir, CREATE TABLE'da görünmez).
21.3 Property-Level Concurrency Token
public class BankAccount
{
public int Id { get; set; }
public string Owner { get; set; }
public decimal Balance { get; set; } // Bu alanı korumak istiyoruz
public DateTime LastModified { get; set; }
}
public class BankAccountConfiguration : IEntityTypeConfiguration<BankAccount>
{
public void Configure(EntityTypeBuilder<BankAccount> builder)
{
// Sadece Balance alanı concurrency token
builder.Property(b => b.Balance)
.IsConcurrencyToken();
// Veya LastModified ile kontrol
builder.Property(b => b.LastModified)
.IsConcurrencyToken();
}
}
Üretilen UPDATE:
-- Balance concurrency token olduğunda:
UPDATE [BankAccounts]
SET [Balance] = @p0, [LastModified] = @p1
WHERE [Id] = @p2 AND [Balance] = @p3;
-- ^^^^^^^^^^^^^^^^ orijinal değer eşleşmezse 0 satır!
SELECT @@ROWCOUNT; -- 0 → DbUpdateConcurrencyException
-- Balance concurrency token olduğunda:
UPDATE bank_accounts
SET balance = @p0, last_modified = @p1
WHERE id = @p2 AND balance = @p3;
-- ^^^^^^^^^^^^^^^^ orijinal değer eşleşmezse 0 satır!
-- Etkilenen satır sayısı 0 ise → DbUpdateConcurrencyException
IsConcurrencyToken()kullanılırsa değer otomatik artmaz — uygulamanın kendisi yeni değer atamalı.IsRowVersion()ise SQL Server tarafından otomatik yönetilir.
21.4 SQL'de Tam Çalışma Mekanizması
-- EF'in SaveChanges() ile ürettiği sorgu (RowVersion):
UPDATE [Products]
SET [Name] = @p0, [Price] = @p1, [Stock] = @p2
WHERE [Id] = @p3 AND [RowVersion] = @p4;
SELECT [RowVersion] -- Yeni RowVersion'ı geri al
FROM [Products]
WHERE @@ROWCOUNT = 1 AND [Id] = @p3;
-- @@ROWCOUNT = 0 ise → kimse etkilenmedi → DbUpdateConcurrencyException!
-- EF'in SaveChanges() ile ürettiği sorgu (xmin concurrency token):
UPDATE products
SET name = @p0, price = @p1, stock = @p2
WHERE id = @p3 AND xmin = @p4
RETURNING xmin; -- Yeni xmin değerini geri al
-- Etkilenen satır 0 ise → DbUpdateConcurrencyException!
-- PostgreSQL'de xmin, her UPDATE'te otomatik değişen transaction ID'dir
Zaman Çizelgesi Senaryosu:
Zaman Kullanıcı A Kullanıcı B
───── ────────────────────────── ──────────────────────────
T1 Product oku (RowVersion=0x01)
T2 Product oku (RowVersion=0x01)
T3 Price=100→120, SaveChanges()
WHERE RowVersion=0x01 → 1 satır ✅
(RowVersion artık 0x02)
T4 Price=100→150, SaveChanges()
WHERE RowVersion=0x01 → 0 satır ❌
→ DbUpdateConcurrencyException!
21.5 Conflict Resolution (Çakışma Çözümleme)
Strateji karşılaştırma:
| Strateji | Ne zaman? | Veri kaybı riski |
|---|---|---|
| Client Wins | Admin paneli, son güncelleme geçerli | Orta — önceki değişiklik ezilir |
| Database Wins | Okuma ağırlıklı, conflict nadir | Düşük — kullanıcıya bilgi verilir |
| Merge | Farklı alanlar güncelleniyorsa | En düşük — alan bazlı birleştirme |
Strateji 1: Client Wins (Son yazan kazanır)
// Strateji 1: Client Wins (Son yazan kazanır — force overwrite)
public async Task UpdateProductClientWins(int productId, decimal newPrice)
{
var product = await _context.Products.FindAsync(productId);
product.Price = newPrice;
bool saved = false;
while (!saved)
{
try
{
await _context.SaveChangesAsync();
saved = true;
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
// DB'deki güncel değerleri al
var dbValues = await entry.GetDatabaseValuesAsync();
if (dbValues == null)
throw new InvalidOperationException("Kayıt silinmiş!");
// Original values'ı DB'deki ile değiştir → bir sonraki WHERE geçer
entry.OriginalValues.SetValues(dbValues);
}
}
}
}
Strateji 2: Database Wins (DB'deki değer korunur)
// Strateji 2: Database Wins (DB'deki değer korunur — discard changes)
public async Task UpdateProductDatabaseWins(int productId, decimal newPrice)
{
var product = await _context.Products.FindAsync(productId);
product.Price = newPrice;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
// DB'deki değerleri hem Original hem Current'a yaz → değişiklik iptal
await entry.ReloadAsync();
}
// İsterseniz kullanıcıya "Başkası güncelledi, tekrar deneyin" mesajı gösterin
}
}
Strateji 3: Merge (Alan bazlı birleştirme)
// Strateji 3: Merge (Alan bazlı birleştirme — en gelişmiş)
public async Task UpdateProductMerge(int productId, decimal newPrice, string newName)
{
var product = await _context.Products.FindAsync(productId);
product.Price = newPrice;
product.Name = newName;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var proposedValues = entry.CurrentValues; // Bizim yazmak istediğimiz
var originalValues = entry.OriginalValues; // İlk okuduğumuz
var dbValues = await entry.GetDatabaseValuesAsync(); // DB'deki güncel
foreach (var property in proposedValues.Properties)
{
var proposed = proposedValues[property];
var original = originalValues[property];
var database = dbValues[property];
// Karar mantığı: Hangi değer geçerli?
if (original != database && original == proposed)
{
// Biz değiştirmedik ama DB'de değişmiş → DB'deki kalsın
proposedValues[property] = database;
}
// else: Biz değiştirdik → bizimki geçerli (proposedValues zaten set)
}
// Original'ı güncelle ki WHERE doğru çalışsın
entry.OriginalValues.SetValues(dbValues);
}
await _context.SaveChangesAsync(); // Tekrar dene (merge edilmiş değerlerle)
}
}
21.6 Retry Pattern (Tekrar Deneme)
public async Task<bool> UpdateWithRetry(int productId, decimal newPrice, int maxRetries = 3)
{
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
// Her denemede TAZE veri oku (yeni DbContext veya reload)
var product = await _context.Products.FindAsync(productId);
if (product == null) return false;
// Değişikliği uygula
product.Price = newPrice;
await _context.SaveChangesAsync();
return true;
}
catch (DbUpdateConcurrencyException)
{
// Son deneme ise fırlat
if (attempt == maxRetries - 1) throw;
// Entry'leri detach et ve tekrar dene
foreach (var entry in _context.ChangeTracker.Entries())
entry.State = EntityState.Detached;
}
}
return false;
}
21.7 Disconnected Scenario (Web API)
Web API'de okuma ve güncelleme farklı request'lerde gerçekleşir. RowVersion değerini client'a gönderip geri alman gerekir.
// DTO
public class ProductUpdateDto
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public byte[] RowVersion { get; set; } // Client bunu geri gönderir
}
// Controller
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(int id, ProductUpdateDto dto)
{
var product = await _context.Products.FindAsync(id);
if (product == null) return NotFound();
// Değerleri güncelle
product.Name = dto.Name;
product.Price = dto.Price;
// Client'ın bildiği RowVersion'ı OriginalValue olarak set et
_context.Entry(product).Property(p => p.RowVersion)
.OriginalValue = dto.RowVersion;
try
{
await _context.SaveChangesAsync();
return Ok(new { product.Id, product.RowVersion }); // Yeni RowVersion'ı döndür
}
catch (DbUpdateConcurrencyException)
{
return Conflict(new
{
Message = "Kayıt başka biri tarafından güncellenmiş.",
CurrentRowVersion = (await _context.Products.FindAsync(id))?.RowVersion
});
}
}
HTTP Akışı:
GET /api/products/1
← { id:1, name:"Laptop", price:100, rowVersion:"AAAAAA==" }
PUT /api/products/1
→ { id:1, name:"Laptop", price:120, rowVersion:"AAAAAA==" }
Başarılı: ← 200 { id:1, rowVersion:"AAAAAB==" }
Çakışma: ← 409 { message:"Kayıt başka biri tarafından güncellenmiş." }
21.8 PostgreSQL & Diğer Provider'larda Concurrency
// PostgreSQL: xmin system column (satır sürümü)
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
// PostgreSQL'de xmin kullanımı (Npgsql provider)
builder.UseXminAsConcurrencyToken();
// Property olarak tanımlamaya gerek yok — xmin gizli sütundur
}
}
// SQLite: RowVersion yok — manuel GUID/DateTime token kullanılır
public class SqliteProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.Property(p => p.ConcurrencyStamp)
.IsConcurrencyToken()
.HasDefaultValueSql("hex(randomblob(8))"); // Benzersiz değer
// SaveChanges override ile her güncellemede yeni stamp:
}
}
Provider karşılaştırma:
| Provider | Yöntem | Otomatik? | Not |
|---|---|---|---|
| SQL Server | ROWVERSION / TIMESTAMP |
8-byte, her UPDATE'te artar | |
| PostgreSQL | xmin system column |
UseXminAsConcurrencyToken() |
|
| SQLite | Manuel (GUID/DateTime) | Uygulama yönetir | |
| MySQL | Manuel (TIMESTAMP column) |
ON UPDATE CURRENT_TIMESTAMP |
21.9 Concurrency Token + Owned Types & İlişkiler
// Owned Type içinde concurrency token OLMAZ — parent entity'de tanımlanır
public class Order
{
public int Id { get; set; }
public Address ShippingAddress { get; set; } // Owned
public byte[] RowVersion { get; set; } // Token burada!
}
// İlişkili entity'lerde: HER entity kendi token'ına sahip olmalı
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
public decimal UnitPrice { get; set; }
public byte[] RowVersion { get; set; } // OrderItem'ın kendi token'ı
}
Navigation property üzerinden güncelleme yapılırsa (ör.
order.Items[0].UnitPrice = 50), EF ilgili entity'nin RowVersion'ını kontrol eder — parent'ın değil.
21.10 Sık Yapılan Hatalar
| Hata | Sonuç | Çözüm |
|---|---|---|
RowVersion'ı [NotMapped] yapmak |
Concurrency kontrolü çalışmaz | Property'yi map'le |
AsNoTracking() ile okuyup güncelleme |
OriginalValues kaybolur | Tracked oku veya OriginalValue'yu manuel set et |
| RowVersion'ı DTO'da göndermemek | Disconnected'da kontrol çalışmaz | DTO'ya ekle, client geri göndersin |
try/catch olmadan SaveChanges |
Uygulama çöker | Her zaman exception handle et |
| Tek DbContext'te sonsuz retry | Stale entry'ler birikir | Her retry'da Detach veya yeni context |