Mert Özen Her Satırda Daha İleri
Veritabanı SQL Server Derinlemesine — #10

Isolation Level'lar ve Row Versioning: READ COMMITTED, RCSI, SNAPSHOT, REPEATABLE READ ve SERIALIZABLE

Mert Özen 8 Kas 2026 10 dk 3 görüntülenme
Isolation Level'lar ve Row Versioning: READ COMMITTED, RCSI, SNAPSHOT, REPEATABLE READ ve SERIALIZABLE

Isolation level'lar, eşzamanlı transaction'ların hangi anomaliyi görebileceğini locking ya da row versioning ile belirler: varsayılan READ COMMITTED okuyucuyu yazana bekletir, RCSI son commit edilmiş sürümü verir, SNAPSHOT transaction başındaki görüntüyü tutar ve çakışmada 3960 verir. İki oturumlu deneylerle dirty read, non-repeatable read, phantom, lost update, write skew, versioning'in 14 byte'lık bedeli ve .NET'ten IsolationLevel seçmek.

Sürüm notu: Bu yazıdaki T-SQL (Transact-SQL, Microsoft'un SQL (Structured Query Language) lehçesi) örnekleri SQL Server 2025 CU10 (17.0.5035.5, compatibility level 170) üzerinde, iki oturumlu deneyler .NET 10, Microsoft.Data.SqlClient 7.1.1 ve Dapper 2.1.89 ile yazılmış küçük bir test düzeneğiyle çalıştırılmıştır.

Önce Tahmin Et

1 numaralı kitabın stoğu 36. Oturum A bir envanter düzeltmesi başlatıyor ve stoğu sıfırlıyor, ama henüz commit etmedi. Tam o sırada oturum B, ürün sayfası için stoğu okuyor. Veritabanı SQL Server'ın varsayılan ayarlarında:

/* A */
BEGIN TRANSACTION;
UPDATE dbo.StockItems SET QuantityOnHand = 0 WHERE BookId = 1;

/* B */
SELECT QuantityOnHand FROM dbo.StockItems WHERE BookId = 1;

B ne görür: 36 mı, 0 mı, yoksa hiçbir şey mi? Bir de B aynı sorguyu WITH (NOLOCK) ile çalıştırsa, sonra da veritabanında RCSI (Read Committed Snapshot Isolation) açık olsa ne değişir? B'nin oturumunda SET LOCK_TIMEOUT 1000 var, yani en fazla bir saniye bekliyor:

/* READ COMMITTED, varsayılan (locking) */
  B: Msg 1222: Lock request time out period exceeded.
/* WITH (NOLOCK) */
  B: QuantityOnHand=0
/* READ COMMITTED, RCSI açık */
  B: QuantityOnHand=36
/* A commit ettikten sonra, RCSI açık */
  B: QuantityOnHand=0

Üç farklı cevap. Varsayılan modda B, A'nın exclusive lock'unu bekledi ve zaman aşımına uğradı. NOLOCK commit edilmemiş, belki hiç commit edilmeyecek bir değeri okudu (dirty read): A bu deneyde rollback yaptı, yani 0 hiçbir zaman gerçek olmadı. RCSI ile B beklemeden son commit edilmiş değeri, 36'yı gördü. Bu yazıda isolation level'ların hangi anomaliyi nasıl engellediğini, locking ile row versioning arasındaki farkı ve her birinin bedelini iki oturumlu deneylerle inceliyoruz.

Anomaliler ve Isolation Level'lar

ANSI (American National Standards Institute) ve ISO (International Organization for Standardization) SQL standardı isolation level'ları hangi anomaliye izin verdikleriyle tanımlar. SQL Server dört standart level'ı ve row versioning'e dayanan iki seçeneği destekler. Bu yazıdaki deneylerin özeti:

LevelMekanizmaDirty readNon-repeatable readPhantomUpdate conflict
READ UNCOMMITTED / NOLOCKOkurken lock yokVarVarVar-
READ COMMITTED (locking)Satır okunduğu an S lock, hemen bırakılırYokVarVar-
READ COMMITTED + RCSIİfade başındaki commit edilmiş sürümYokVarVar-
REPEATABLE READS lock'lar transaction sonuna kadarYokYokVar-
SNAPSHOTTransaction başındaki commit edilmiş sürümYokYokYokHata 3960
SERIALIZABLEKey-range lock'lar transaction sonuna kadarYokYokYok-

İki satırın altını çizmek gerekiyor. RCSI yeni bir isolation level değil, READ COMMITTED'ın versioning ile uygulanmış hâlidir; uygulama hiçbir şey değiştirmeden onu kullanır. SNAPSHOT ise ayrı bir level'dır ve standardın SERIALIZABLE'ından farklı olarak write skew'a izin verir; aşağıda göreceğiz.

Non-Repeatable Read ve REPEATABLE READ

B bir transaction içinde stoğu okuyor, A bir adet satıp commit ediyor, B stoğu tekrar okuyor ve sonra kendi satışını yapıyor. Aynı senaryoyu iki level'da çalıştırdım; A'nın oturumunda da bir saniyelik lock timeout var:

/* B */ SET TRANSACTION ISOLATION LEVEL READ COMMITTED;   /* sonra REPEATABLE READ */
        BEGIN TRANSACTION;
        SELECT QuantityOnHand FROM dbo.StockItems WHERE BookId = 1;
/* A */ UPDATE dbo.StockItems SET QuantityOnHand = QuantityOnHand - 1 WHERE BookId = 1;
/* B */ SELECT QuantityOnHand FROM dbo.StockItems WHERE BookId = 1;
        UPDATE dbo.StockItems SET QuantityOnHand = QuantityOnHand - 1 WHERE BookId = 1;
        COMMIT;
/* READ COMMITTED */
  B: QuantityOnHand=36
  B: QuantityOnHand=35
  final stock: 34
/* REPEATABLE READ */
  B: QuantityOnHand=36
  A: Msg 1222: Lock request time out period exceeded.
  B: QuantityOnHand=36
  final stock: 35

READ COMMITTED'da B aynı transaction içinde iki farklı değer gördü: non-repeatable read. Bu level satırı okurken kısa bir shared (S) lock alır ve okuma biter bitmez bırakır; arada A'nın değişikliği girebilir. REPEATABLE READ'de B'nin S lock'u transaction sonuna kadar kaldı, A'nın exclusive (X) lock isteği onunla uyuşmadı ve A bekledi. Deneyde A bir saniye sonra vazgeçti; gerçek bir uygulamada B commit edene kadar beklerdi. Tutarlılığın bedeli eşzamanlılık: okuyan, yazanı durdurur.

Phantom ve SERIALIZABLE

REPEATABLE READ okunan satırları kilitler, ama henüz var olmayan satırları kilitleyemez. B, müşteri 42'nin bekleyen siparişlerini sayıyor; A aynı müşteri için yeni bir bekleyen sipariş ekliyor:

/* B */ SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;   /* sonra SERIALIZABLE */
        BEGIN TRANSACTION;
        SELECT COUNT(*) AS pending FROM dbo.Orders WHERE CustomerId = 42 AND Status = 'Pending';
/* A */ INSERT dbo.Orders (OrderNumber, CustomerId, Status, CreatedAt, TotalAmount, Currency)
        VALUES ('PH-1', 42, 'Pending', '2026-10-21', 10, 'TRY');
/* B */ SELECT COUNT(*) AS pending FROM dbo.Orders WHERE CustomerId = 42 AND Status = 'Pending';
        COMMIT;
/* REPEATABLE READ */
  B: pending=1
  B: pending=2
/* SERIALIZABLE */
  B: pending=1
  A: Msg 1222: Lock request time out period exceeded.
  B: pending=1

REPEATABLE READ'de ikinci sayımda yeni bir satır belirdi: phantom. SERIALIZABLE'da B, okuduğu aralığı key-range lock'larla kilitledi; aralığa düşecek yeni satır eklenemedi. Key-range lock'lar index üzerinde alınır, bu yüzden SERIALIZABLE'ın maliyeti index tasarımına bağlıdır: uygun index yoksa "aralık" bütün tablo olur ve tek bir sorgu bütün insert'leri durdurabilir. Lock tiplerini ve uyumluluk matrisini bir sonraki yazıda ayrıntılı göreceğiz.

Lost Update: Uygulamada Oku, Hesapla, Yaz

En sık rastlanan eşzamanlılık hatası hiçbir isolation level ayarına takılmaz, çünkü uygulama kodundadır. İki istek stoğu okuyor, birer adet düşüp sonucu yazıyor:

int ra = await a.ExecuteScalarAsync<int>("SELECT QuantityOnHand FROM dbo.StockItems WHERE BookId = 1");
int rb = await b.ExecuteScalarAsync<int>("SELECT QuantityOnHand FROM dbo.StockItems WHERE BookId = 1");
await a.ExecuteAsync("UPDATE dbo.StockItems SET QuantityOnHand = @q WHERE BookId = 1", new { q = ra - 1 });
await b.ExecuteAsync("UPDATE dbo.StockItems SET QuantityOnHand = @q WHERE BookId = 1", new { q = rb - 1 });
two sales, stock 36 -> 35

İki satış yapıldı, stok bir azaldı. Varsayılan READ COMMITTED bunu engellemez: iki okuma da commit edilmiş değeri okudu, iki yazma da kendi zamanında geçerliydi. Üç çözüm var. En iyisi hesabı veritabanına bırakmak: SET QuantityOnHand = QuantityOnHand - 1 atomiktir ve önceki deneylerde doğru sonucu (34) verdi. İkincisi okumayı WITH (UPDLOCK) ile yapıp aynı transaction'da yazmak; ikinci okuyucu birincinin bitmesini bekler. Üçüncüsü optimistic concurrency: rowversion kolonu ve Entity Framework Core'un (EF Core) concurrency token'ı; ayrıntısı EF Core serisinin concurrency yazısında.

RCSI ve SNAPSHOT: Statement mı, Transaction mı?

İki versioning seçeneği aynı mekanizmayı kullanır ama farklı anı "görür". RCSI'da her ifade, kendi başladığı andaki commit edilmiş veriyi görür. SNAPSHOT'ta transaction'daki bütün ifadeler, transaction'ın ilk veri erişimi anındaki commit edilmiş veriyi görür. Önceki senaryoyu ikisiyle de çalıştırdım:

ALTER DATABASE sqls_10 SET ALLOW_SNAPSHOT_ISOLATION ON;
ALTER DATABASE sqls_10 SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
/* READ COMMITTED, RCSI açık */
  B: QuantityOnHand=36
  B: QuantityOnHand=35
  B: QuantityOnHand=34
  final stock: 34
/* SNAPSHOT */
  B: QuantityOnHand=36
  B: QuantityOnHand=36
  B: Msg 3960: Snapshot isolation transaction aborted due to update conflict. You cannot use snapshot isolation to access
     table 'dbo.StockItems' directly or indirectly in database 'sqls_10' to update, delete, or insert the row that has been
     modified or deleted by another transaction. Retry the transaction or change the isolation level for the update/delete statement.
  final stock: 35

10-isolation-timeline-tr.png

RCSI'da B ikinci okumada A'nın commit ettiği 35'i gördü ve kendi güncellemesini güncel değer üzerinde yaptı. SNAPSHOT'ta B hep 36 gördü; güncellemeye çalıştığında SQL Server, B'nin snapshot'ından sonra satırı başka bir transaction'ın değiştirdiğini fark edip transaction'ı 3960 ile durdurdu. Bu bir hata değil, lost update'e karşı koruma: B'nin "36'yı gördüm, 35 yazayım" kararı artık geçersiz. Uygulamanın bu hatayı yakalayıp transaction'ı baştan tekrar etmesi gerekir.

SNAPSHOT'ın koruyamadığı bir durum var: iki transaction farklı satırları değiştirip birbirinin okuduğu bir kuralı birlikte bozduğunda (write skew). Kargo ekibinde "en az bir kurye nöbette olmalı" kuralı var; Ada ve Alan nöbette, ikisi de aynı anda izin istiyor:

SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;
SELECT COUNT(*) AS on_duty FROM dbo.CourierShifts WHERE OnDuty = 1;
/* A: CourierId = 1, B: CourierId = 2 */
IF (SELECT COUNT(*) FROM dbo.CourierShifts WHERE OnDuty = 1) > 1
    UPDATE dbo.CourierShifts SET OnDuty = 0 WHERE CourierId = 1;
COMMIT;
  A: on_duty=2
  B: on_duty=2
  couriers on duty: 0

İki transaction da kendi snapshot'ında iki nöbetçi gördü, farklı satırları güncellediği için çakışma olmadı ve sonuçta hiç nöbetçi kalmadı. SERIALIZABLE bunu engeller (okunan aralık kilitlenir), ya da kuralı okuyan sorgu UPDLOCK ile iki satırı birden kilitler. Kural veritabanında bir constraint ile ifade edilebiliyorsa en sağlamı odur.

Row Versioning'in Bedeli ve .NET'ten Isolation Level

Versioning ücretsiz değil. Versioning açıkken değiştirilen her satır, sürüm zincirine işaret eden 14 byte'lık bir ek alır ve önceki sürüm version store'a yazılır. 40.000 satırlık StockItems'ı yeniden kurup, snapshot isolation açıkken bütün satırları bir kez güncelledim:

page_count avg_rec max_rec     /* rebuild sonrası */
134        25.0    25
page_count avg_rec max_rec     /* versioning açıkken tek bir UPDATE sonrası */
268        39.0    39
reserved_page_count reserved_space_kb   /* sys.dm_tran_version_store_space_usage */
440                 3520

Satır 25'ten 39 byte'a büyüdü ve page'ler dolu olduğu için tablo iki katına çıktı (page split). Önceki sürümler tempdb'de 3,5 MB yer tuttu. ADR (Accelerated Database Recovery) açık bir veritabanında ise sürümler tempdb yerine veritabanının kendi PVS'sinde (persistent version store) tutulur. Uzun süren bir SNAPSHOT transaction'ı, kendisinden sonraki bütün sürümlerin temizlenmesini engeller; tempdb ya da PVS büyür.

.NET'te isolation level transaction açılırken verilir. SNAPSHOT'ın veritabanında önce izin verilmiş olması gerekiyor:

await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync();
await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(System.Data.IsolationLevel.Snapshot);
int stock = await conn.ExecuteScalarAsync<int>(
    "SELECT QuantityOnHand FROM dbo.StockItems WHERE BookId = 1", transaction: tx);
short level = await conn.ExecuteScalarAsync<short>(
    "SELECT transaction_isolation_level FROM sys.dm_exec_sessions WHERE session_id = @@SPID", transaction: tx);
await tx.CommitAsync();
ALLOW_SNAPSHOT_ISOLATION=OFF: Msg 3952: Snapshot isolation transaction failed accessing database 'sqls_10' because snapshot isolation is not allowed in this database. Use ALTER DATABASE to allow snapshot isolation.
ALLOW_SNAPSHOT_ISOLATION=ON: stock 36, transaction_isolation_level 5

EF Core'da aynısı await db.Database.BeginTransactionAsync(IsolationLevel.Snapshot) ile yapılır. RCSI için ise kodda hiçbir şey değişmez; veritabanı ayarıdır. Azure SQL Database'de RCSI yeni veritabanlarında varsayılan olarak açıktır, SQL Server'da kapalıdır. Aynı uygulamanın iki ortamda farklı davranmasının sık bir sebebi budur.

SQL Server 2019/2022'de farkı: isolation level'lar, RCSI ve SNAPSHOT davranışı üç sürümde aynı. ADR (2019+) açıkken sürümlerin tempdb yerine PVS'de tutulması 2019 ile geldi. SQL Server 2025'in optimized locking özelliğinin "lock after qualification" kısmı RCSI'ın açık olmasını şart koşar; bu da RCSI'ı açmak için yeni bir sebep. Onu serinin ilerleyen yazılarında ayrıca ele alacağız.

Üretimde Dikkat

RCSI'ı açmadan önce okuma-sonra-yazma kodunu gözden geçir. RCSI okuyucuların yazanları beklemesini kaldırır, ama "oku, kontrol et, yaz" mantığı locking READ COMMITTED'da tesadüfen bloklanarak doğru çalışıyorsa, RCSI ile yanlış çalışmaya başlayabilir. Bu kodları atomik ifadelere ya da UPDLOCK'a çevir.

Version store'u izle. sys.dm_tran_version_store_space_usage, sys.dm_tran_persistent_version_store_stats ve en uzun süren aktif snapshot transaction'ı (sys.dm_tran_active_snapshot_database_transactions) düzenli kontrol edilmeli.

SNAPSHOT kullanıyorsan 3960'ı retry et. Hata deterministik bir çakışmadır; transaction'ı baştan, yeni bir snapshot'la tekrar etmek doğru tepkidir. Retry'ın idempotency şartını deadlock yazısında konuşacağız.

Sık Düşülen Tuzaklar

NOLOCK'u "performans hint'i" sanmak. Dirty read'in yanında, Microsoft'un locking ve row versioning rehberinin de belirttiği gibi, allocation order taramalarda aynı satırı iki kez okuyabilir ya da hiç okumayabilir. Okuyucu-yazıcı bloklaşmasının doğru çözümü RCSI'dır.

SNAPSHOT'ı SERIALIZABLE sanmak. SNAPSHOT phantom görmez, ama write skew'a izin verir; kuryeler deneyinde kural bozuldu.

Isolation level'ın oturuma bağlı bir ayar olduğunu unutmak. SET TRANSACTION ISOLATION LEVEL oturum ayarıdır; bir sonraki transaction'da da geçerlidir. Bunu açıkça yazmak, connection pool'dan gelen bağlantının durumuna güvenmekten iyidir.

REPEATABLE READ'i "tutarlı rapor" için açmak. Uzun bir raporun okuduğu her satırın S lock'u transaction sonuna kadar kalır ve yazanları durdurur; tutarlı bir rapor için SNAPSHOT çok daha ucuzdur.

Küçük Bir Deneme

★☆☆ RCSI açıkken açılıştaki deneyi WITH (READCOMMITTEDLOCK) hint'iyle tekrarla. B ne gördü? Bu hint ne zaman gerekir?

★★☆ Kuryeler senaryosunu SERIALIZABLE ile tekrarla (iki oturum, aynı sıra). Ne oldu: bloklama mı, deadlock mı? Hangi oturum kazandı?

★★★ Lost update deneyini EF Core ile yaz: StockItem'a bir rowversion kolonu ekle, iki DbContext ile aynı satırı oku ve kaydet. Hangi exception geldi, üretilen UPDATE ifadesinin WHERE koşulu neydi?

Bu yazıda lock'ları hep sonuçlarıyla gördük: 1222 timeout'ları, bekleyen oturumlar. Bir sonraki yazıda lock'ların kendisine bakıyoruz: lock mode'ları, granularity, lock escalation ve blocking zincirini bulmak.