Transaction'lar ve Transaction Log: ACID'in Motor Tarafı, XACT_ABORT, Savepoint ve Accelerated Database Recovery
SQL Server'da atomiklik kendiliğinden gelmez: XACT_ABORT OFF iken hatalı ifadeden sonraki COMMIT yarım işi kalıcı yapar, iç COMMIT hiçbir şeyi commit etmez, tek bir açık transaction SIMPLE recovery'de bile log'un truncate edilmesini durdurur. XACT_ABORT ve TRY/CATCH/THROW kalıbı, savepoint, LSN/VLF/recovery model, 200.000 satırlık rollback'in ADR ile 200.031'den 1 log record'a inişi ve TransactionScope'un Linux/macOS'ta distributed transaction hatası.
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, C# örnekleri .NET 10, Microsoft.Data.SqlClient 7.1.1 ve Dapper 2.1.89 ile denenmiştir.
Önce Tahmin Et
42 numaralı siparişin 4 kalemi var. Bir transaction açıp iki kalem ekliyoruz; ikinci kalemin BookId'si yok, foreign key hatası alacak. Sonra COMMIT yazıyoruz:
SET XACT_ABORT OFF; /* varsayılan */
BEGIN TRANSACTION;
INSERT dbo.OrderItems (OrderId, BookId, Quantity, UnitPrice) VALUES (42, 7, 1, 10.00);
INSERT dbo.OrderItems (OrderId, BookId, Quantity, UnitPrice) VALUES (42, 999999, 1, 10.00);
SELECT @@TRANCOUNT AS trancount, XACT_STATE() AS xact_state;
COMMIT;
"Transaction atomiktir, ya hepsi ya hiçbiri" diye öğrendik. Peki bu batch'ten sonra siparişin kaç kalemi olur: 4 mü, 5 mi? Gerçek çıktı (SqlClient'tan):
items before: 4
Msg 547, Level 16: The INSERT statement conflicted with the FOREIGN KEY constraint "FK_OrderItems_Books". ...
Msg 3621, Level 0: The statement has been terminated.
trancount=1, xact_state=1
items after: 5
5. Hata yalnızca hatalı ifadeyi geri aldı; transaction açık ve "commit edilebilir" durumda kaldı (XACT_STATE() = 1) ve COMMIT ilk kalemi kalıcı yaptı. Atomiklik garanti edilmez, senin kurduğun bir şeydir. Bu yazıda transaction'ın motor tarafını inceliyoruz: transaction modları, "nested transaction" efsanesi, XACT_ABORT ile TRY/CATCH kalıbı, savepoint'ler, transaction log'un içi (LSN (Log Sequence Number), VLF (Virtual Log File), recovery model), uzun transaction'ın log'u nasıl kilitlediği, ADR (Accelerated Database Recovery) ve .NET tarafında dikkat edilecekler.
Transaction Modları ve "Nested Transaction" Efsanesi
SQL Server'da üç mod var. Autocommit varsayılandır: her ifade kendi transaction'ıdır. Explicit modda transaction'ı BEGIN TRANSACTION ile sen açar, COMMIT ya da ROLLBACK ile kapatırsın. Implicit modda (SET IMPLICIT_TRANSACTIONS ON) ilk veri ifadesi transaction'ı kendiliğinden açar ama kapatmak yine sana kalır; Oracle'dan gelen alışkanlıkla açılıp unutulduğunda uzun transaction'ların klasik kaynağıdır. SqlClient varsayılan olarak autocommit çalışır; Entity Framework Core'un (EF Core) SaveChanges'ı ise kendi içinde explicit bir transaction açar.
İç içe BEGIN TRANSACTION yazmak mümkün, ama SQL Server'da gerçek anlamda iç içe transaction yoktur:
BEGIN TRANSACTION; /* dış */
INSERT dbo.AuditLog VALUES ('outer work');
BEGIN TRANSACTION; /* "iç" */
INSERT dbo.AuditLog VALUES ('inner work');
PRINT CONCAT('trancount inside inner: ', @@TRANCOUNT);
COMMIT; /* iç commit */
PRINT CONCAT('trancount after inner commit: ', @@TRANCOUNT);
ROLLBACK; /* dış rollback */
PRINT CONCAT('trancount after rollback: ', @@TRANCOUNT);
SELECT COUNT(*) AS audit_rows FROM dbo.AuditLog;
trancount inside inner: 2
trancount after inner commit: 1
trancount after rollback: 0
audit_rows
----------
0
İç COMMIT hiçbir şeyi kalıcı yapmadı; yalnızca @@TRANCOUNT'u 2'den 1'e indirdi. Kalıcılığı belirleyen tek şey en dıştaki COMMIT'tir. ROLLBACK ise tersine sayaca bakmaz, her zaman en dışa kadar her şeyi geri alır. Bu yüzden kendi transaction'ını açıp hata durumunda ROLLBACK yapan bir procedure, çağıranın transaction'ını da yok eder:
CREATE OR ALTER PROCEDURE dbo.InnerProc AS
BEGIN
BEGIN TRANSACTION;
INSERT dbo.AuditLog VALUES ('inner proc');
ROLLBACK;
END;
GO
BEGIN TRANSACTION;
EXEC dbo.InnerProc;
HResult 0x10A, Level 16, State 2
Transaction count after EXECUTE indicates a mismatching number of BEGIN and COMMIT statements. Previous count = 1, current count = 0.
0x10A, hata 266: procedure'e girerken bir transaction vardı, çıkarken yok. Çağıran kod hâlâ transaction içinde olduğunu sanıyor. Stored procedure yazısında bunun için kullanılan "transaction'ı yalnızca ben açtıysam commit/rollback yap" kalıbını göreceğiz.
XACT_ABORT ve TRY/CATCH/THROW Kalıbı
Açılıştaki sorunun çözümü SET XACT_ABORT ON. Açıkken çalışma anı hatalarının çoğu bütün transaction'ı geri alır ve batch'i durdurur:
SET XACT_ABORT ON;
BEGIN TRANSACTION;
INSERT dbo.OrderItems (OrderId, BookId, Quantity, UnitPrice) VALUES (42, 7, 1, 10.00);
INSERT dbo.OrderItems (OrderId, BookId, Quantity, UnitPrice) VALUES (42, 999999, 1, 10.00);
SELECT 'not reached' AS msg;
COMMIT;
Msg 547, Level 16: The INSERT statement conflicted with the FOREIGN KEY constraint "FK_OrderItems_Books". ...
trancount after batch: 0
items after: 4
İlk kalem de geri alındı, SELECT hiç çalışmadı, açık transaction kalmadı. XACT_ABORT'un uygulama tarafında ikinci ve daha az bilinen bir faydası var: istemci bir command timeout ya da iptal (attention) gönderdiğinde, XACT_ABORT OFF ise transaction sunucuda açık kalır ve bağlantı pool'da yeniden kullanılıp sıfırlanana kadar lock'larını tutmaya devam eder. ON iken iptal transaction'ı da geri alır.
Hata yönetimini TRY/CATCH ile birleştiren kalıp şöyle. Stok düşülüyor, sonra kalem ekleniyor; kalem eklenemezse stok da geri gelmeli:
CREATE OR ALTER PROCEDURE dbo.AddOrderItem @OrderId int, @BookId int, @Quantity smallint
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
UPDATE dbo.StockItems SET QuantityOnHand = QuantityOnHand - @Quantity WHERE BookId = @BookId;
INSERT dbo.OrderItems (OrderId, BookId, Quantity, UnitPrice)
SELECT @OrderId, @BookId, @Quantity, Price FROM dbo.Books WHERE BookId = @BookId;
COMMIT;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK;
THROW;
END CATCH;
END;
GO
EXEC dbo.AddOrderItem @OrderId = 2000000, @BookId = 1, @Quantity = 5; /* böyle bir sipariş yok */
stock of book 1 before: 36
Msg 547, Level 16: The INSERT statement conflicted with the FOREIGN KEY constraint "FK_OrderItems_Orders". ...
stock of book 1 after: 36, trancount: 0
Üç parça birlikte çalışıyor: XACT_ABORT ON hatayı transaction'ı "doomed" (XACT_STATE() = -1) hâle getirir, CATCH bloğu açık transaction'ı geri alır, THROW da orijinal hatayı numarası ve mesajıyla çağırana iletir. RAISERROR ile yeni bir mesaj üretmek ya da hatayı yutup RETURN -1 demek, uygulamanın hatanın ne olduğunu öğrenmesini engeller.
Savepoint: Transaction'ın Bir Kısmını Geri Almak
Bütün transaction'ı değil yalnızca bir bölümünü geri almak gerektiğinde savepoint kullanılır:
SET XACT_ABORT OFF;
BEGIN TRANSACTION;
UPDATE dbo.Orders SET Status = 'Shipped' WHERE OrderId = 999999;
SAVE TRANSACTION BeforeShipment;
INSERT dbo.Shipments (OrderId, Carrier, TrackingNumber, ShippedAt) VALUES (999999, 'Express', 'TRK-TEST', '2026-10-21');
ROLLBACK TRANSACTION BeforeShipment;
SELECT @@TRANCOUNT AS trancount,
(SELECT Status FROM dbo.Orders WHERE OrderId = 999999) AS status,
(SELECT COUNT(*) FROM dbo.Shipments WHERE TrackingNumber = 'TRK-TEST') AS test_shipments;
ROLLBACK;
trancount=1, status=Shipped, test_shipments=0
Kargo kaydı geri alındı, sipariş durumu değişikliği ve transaction duruyor. İki kısıt var: savepoint'e dönmek lock'ları bırakmaz, ve doomed bir transaction'da (XACT_STATE() = -1) savepoint'e dönülemez, yalnızca tamamen geri alınabilir. EF Core 5'ten beri SaveChanges açık bir transaction içinde çağrıldığında otomatik savepoint kullanır; ayrıntısı EF Core serisinin transaction yazısında.
Transaction Log'un İçi: LSN, VLF, Recovery Model ve Uzun Transaction
Birinci yazıda her değişikliğin önce log'a yazıldığını (WAL, write-ahead logging) ve her log record'un artan bir LSN (Log Sequence Number) taşıdığını gördük. Fiziksel log dosyası VLF (Virtual Log File) denen parçalara bölünür; log döngüsel kullanılır ve içindeki bütün kayıtlar artık gerekmeyen VLF'ler yeniden kullanılabilir hâle gelir (log truncation). Bir log kaydının ne zaman gereksiz olacağını recovery model belirler:
SIMPLE: kayıt, ait olduğu transaction bitip bir checkpoint geçince gereksizdir. Point-in-time restore yoktur.FULL: kayıt ayrıca bir log backup'ına alınana kadar tutulur; log backup almıyorsan log sonsuza kadar büyür.BULK_LOGGED:FULLgibi, ama bazı toplu işlemler minimal log'lanır.
Truncation'ı neyin beklettiğini sys.databases.log_reuse_wait_desc söyler. Bunu görmek için bir oturumda 25 saniye açık kalan bir transaction başlattım (tek bir stok satırını güncelleyip bekliyor) ve başka bir oturumda üç ayrı, commit edilen 200.000 satırlık güncelleme ve bir CHECKPOINT çalıştırdım:
/* oturum 1 */
BEGIN TRANSACTION;
UPDATE dbo.StockItems SET QuantityReserved = QuantityReserved + 1 WHERE BookId = 5;
WAITFOR DELAY '00:00:25';
ROLLBACK;
/* oturum 2 */
UPDATE dbo.Payments SET Amount = Amount + 0.01 WHERE PaymentId <= 200000; /* üç kez */
CHECKPOINT;
SELECT recovery_model_desc, log_reuse_wait_desc FROM sys.databases WHERE name = DB_NAME();
SELECT used_log_space_in_percent FROM sys.dm_db_log_space_usage;
SELECT COUNT(*) AS vlfs, SUM(CASE WHEN vlf_active = 1 THEN 1 ELSE 0 END) AS active_vlfs FROM sys.dm_db_log_info(DB_ID());
DBCC OPENTRAN WITH TABLERESULTS, NO_INFOMSGS;
| Ölçü | Önce | Oturum 1 açıkken, CHECKPOINT sonrası | Oturum 1 bittikten ve CHECKPOINT'ten sonra |
|---|---|---|---|
log_reuse_wait_desc | NOTHING | ACTIVE_TRANSACTION | NOTHING |
used_log_space_in_percent | 1.5 | 20.2 | 3.2 |
| Aktif VLF (toplam 24) | 1 | 8 | 1 |
OLDACT_SPID 68
OLDACT_NAME user_transaction
OLDACT_LSN (65:8360:1)
OLDACT_STARTTIME Nov 8 2026 12:34:22:750PM
Veritabanı SIMPLE recovery'de ve oturum 2'nin bütün güncellemeleri commit edildi; yine de log truncate edilemedi, çünkü log ancak en eski açık transaction'ın başladığı LSN'e kadar kesilebilir. Tek bir satırı güncelleyip bekleyen oturum 1, ondan sonra yazılan her şeyi log'da tutuyor. DBCC OPENTRAN suçluyu oturum numarası ve başlangıç zamanıyla gösteriyor. Production'da bu oturum, kullanıcı onay penceresinde beklerken transaction'ı açık tutan bir uygulama, bir debug breakpoint'i ya da IMPLICIT_TRANSACTIONS ile açılıp unutulmuş bir SSMS (SQL Server Management Studio) penceresi olur ve log diski dolana kadar fark edilmez.
Accelerated Database Recovery
Klasik recovery'de büyük bir transaction'ı geri almak, yapılan her değişikliği log'dan okuyup tersini uygulamak demektir; ileriye ne kadar sürdüyse geriye de o kadar sürer. SQL Server 2019 ile gelen ADR bunu değiştirdi: satırların önceki sürümlerini veritabanının içindeki PVS'de (persistent version store) ya da satırın içinde tutar ve rollback'i bu sürümlere dönen mantıksal bir işleme çevirir. Aynı 200.000 satırlık güncellemeyi ADR kapalı ve açıkken geri aldım; sys.fn_dblog ile güncellemenin ve rollback'in kaç log record ürettiğini saydım:
CHECKPOINT;
BEGIN TRANSACTION;
UPDATE dbo.Payments SET Amount = Amount + 1 WHERE PaymentId <= 200000;
/* sys.dm_tran_database_transactions ile transaction'ın log kullanımı */
ROLLBACK;
ALTER DATABASE sqls_09 SET ACCELERATED_DATABASE_RECOVERY = ON WITH ROLLBACK IMMEDIATE;
/* aynı deney */
| ADR | UPDATE'in log record'u | UPDATE'in log byte'ı | Rollback için ayrılan | ROLLBACK'in log record'u |
|---|---|---|---|---|
| Kapalı | 200.002 | 20.899.448 | 40.230.936 | 200.031 |
| Açık | 228.367 | 43.810.828 | 29.043.044 | 1 |
ADR kapalıyken rollback, her satır için bir telafi (compensation) kaydı yazarak 200.031 log record üretti; ADR açıkken tek bir kayıt. Rollback anlık, çünkü satırlar zaten önceki sürümlerine işaret ediyor ve eski sürüm arka planda temizleniyor. Bunun bedeli de tabloda: güncelleme sırasında sürüm bilgisi de yazıldığı için log iki katına çıktı. ADR'nin asıl kazancı recovery süresi: sunucu uzun bir transaction'ın ortasında çökse bile açılışta undo aşaması o transaction'ın boyutundan bağımsız olarak hızlı biter ve log, açık transaction yüzünden truncate edilememe sorununa da (yukarıdaki senaryo) daha az takılır. Edition tablosuna göre ADR Enterprise ve Standard'da var, Express'te yok. SQL Server 2025 ADR'yi tempdb'ye de getirdi; bu server seviyesinde bir ayar olduğu için paylaşılan container'da açmadım. ADR, SQL Server 2025'in optimized locking özelliğinin de ön koşulu; onu ilerideki locking yazılarında göreceğiz.
SQL Server 2019/2022'de farkı: transaction semantiği, XACT_ABORT, savepoint ve log mimarisi üç sürümde aynı. ADR 2019'da geldi ve 2022'de PVS temizliği ve çok sayıda veritabanında ölçeklenme iyileştirildi; tempdb için ADR ve ADR'ye dayanan optimized locking yalnızca 2025'te.
.NET Tarafı: SaveChanges, Dapper ve TransactionScope
EF Core'da SaveChanges zaten tek bir transaction'dır; birden çok SaveChanges'ı ya da EF Core ile Dapper'ı aynı transaction'da birleştirmek için BeginTransaction kullanılır (EF Core serisinin transaction yazısı ve Dapper serisinin 7. yazısı). Aynı işi TransactionScope ile yaparken dikkat: scope içinde ikinci bir connection açılırsa, ikisi aynı veritabanına gitse bile transaction distributed transaction'a yükseltilmek istenir:
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
await using var c1 = new SqlConnection(connectionString);
await c1.OpenAsync();
await c1.ExecuteAsync("UPDATE dbo.StockItems SET QuantityReserved = QuantityReserved + 1 WHERE BookId = 1");
await using var c2 = new SqlConnection(connectionString);
await c2.OpenAsync(); /* aynı scope'ta ikinci connection */
await c2.ExecuteAsync("UPDATE dbo.StockItems SET QuantityReserved = QuantityReserved + 1 WHERE BookId = 2");
scope.Complete();
PlatformNotSupportedException: This platform does not support distributed transactions.
Bu çıktı macOS'tan. .NET 7'den beri distributed transaction yalnızca Windows'ta ve TransactionManager.ImplicitDistributedTransactions = true ile açıkça izin verildiğinde mümkün; Linux container'larda ve macOS'ta bu kod hep hata verir. Windows'ta ise sessizce MSDTC'ye (Microsoft Distributed Transaction Coordinator) yükselir; bu da ayrı bir altyapı ve ayrı bir performans maliyeti. Tek veritabanında tek bir connection ve BeginTransaction kullan; birden çok sistemi tutarlı tutman gerekiyorsa outbox gibi Messaging ve Event-Driven Architecture serisinde anlatılan yaklaşımlara bak.
Üretimde Dikkat
Transaction'ı kısa tut. Bir transaction'ın içinde HTTP (Hypertext Transfer Protocol) çağrısı, kullanıcı onayı, e-posta gönderimi ya da büyük bir hesaplama olmamalı. Açık kaldığı her saniye lock'ları tutar ve log'un truncate edilmesini engeller.
log_reuse_wait_desc'i izle. ACTIVE_TRANSACTION, LOG_BACKUP, AVAILABILITY_REPLICA gibi değerler log'un neden büyüdüğünü doğrudan söyler. FULL recovery'deki bir veritabanında düzenli log backup'ı yoksa log dosyası er ya da geç diski doldurur.
VLF sayısını makul tut. Log dosyasını çok küçük adımlarla büyütmek binlerce VLF üretir ve recovery'yi yavaşlatır. Log için baştan gerçekçi bir boyut ve sabit MB cinsinden büyüme adımı belirle.
Sık Düşülen Tuzaklar
XACT_ABORT OFF ile "hata olursa zaten rollback olur" sanmak. Açılıştaki örnek gibi, hatadan sonraki COMMIT yarım işi kalıcı yapar.
İç COMMIT'in bir şey commit ettiğini sanmak. Yalnızca sayaç azalır; kalıcılık en dıştaki COMMIT'tedir ve herhangi bir seviyedeki ROLLBACK her şeyi geri alır.
SIMPLE recovery'de log'un büyümeyeceğini sanmak. Uzun bir açık transaction SIMPLE'da da truncation'ı durdurur; deneyde log kullanımı %1,5'ten %20,2'ye çıktı.
Testi Windows'ta yapıp TransactionScope'un Linux'ta da çalışacağını varsaymak. Distributed transaction'a yükselen kod Linux container'da PlatformNotSupportedException verir.
Küçük Bir Deneme
★☆☆ Açılıştaki batch'i XACT_ABORT OFF ile ama TRY/CATCH içinde yaz. CATCH bloğunda XACT_STATE() hangi değeri dönüyor? Commit etmek mümkün mü?
★★☆ sys.dm_db_log_info ile log'un VLF sayısını ve boyutlarını incele. Log dosyasını 1 MB'lık adımlarla 100 MB büyütürsen VLF sayısı ne olur? Ölç, sonra sabit bir büyüme adımıyla karşılaştır.
★★★ ADR açıkken 200.000 satırlık güncellemeyi commit etmeden bırak ve başka bir oturumdan sys.dm_tran_persistent_version_store_stats'ı izle. PVS ne zaman büyüyor, rollback sonrası ne zaman temizleniyor?
Transaction'ın tek başına nasıl davrandığını gördük. Asıl zorluk aynı veriye aynı anda iki transaction dokunduğunda başlıyor. Bir sonraki yazıda isolation level'ları, dirty read'den write skew'a kadar anomalileri ve row versioning'i iki oturumlu deneylerle inceleyeceğiz.