Software · Apr 3, 2024 · 11 dk okuma
Transaction Nedir? ACID'i Banka Transferi Üzerinden Anlamak
Database transaction, birden fazla işlemi güvenilir bir bütün olarak yönetir. ACID, commit, rollback ve isolation levels kavramlarını banka transferi üzerinden, gerçek sistemlerdeki sınırlarıyla birlikte açıklıyoruz.

Bir database transaction, birden fazla veri işlemini tek bir mantıksal iş birimi olarak yönetir. Bu birim ya başarıyla tamamlanır ya da yaptığı değişiklikler geri alınır. ACID olarak bilinen Atomicity, Consistency, Isolation ve Durability özellikleri, transaction'ın hangi garantileri sağlaması gerektiğini tanımlar. Ancak bu garantilerin gerçek etkisi, kullanılan database engine'e, isolation level'a ve uygulamanın transaction sınırlarını nasıl çizdiğine bağlıdır.
Bu ayrım özellikle para transferi gibi işlemlerde belirleyicidir. Bir hesaptan para düşüp diğer hesaba eklenmediğinde sorun yalnızca başarısız bir SQL sorgusu değildir; sistemin temsil ettiği ekonomik gerçeklikle kayıtları birbirinden ayrılmıştır. Aynı anda gelen transferler, ağ bağlantısının kopması, sunucunun çökmesi veya iki transaction'ın birbirini beklemesi de tabloya eklenir. Güvenilir bir sistem tasarlamak, ACID'in açılımını bilmekten çok bu durumlarda hangi garantinin nerede başladığını anlamayı gerektirir.
Transaction sınırı işin bütünlüğünü belirler
Bir banka transferinin en basit biçimini düşünelim. A hesabında 1.000 TL, B hesabında 500 TL bulunuyor. A'dan B'ye 200 TL gönderildiğinde beklenen sonuç, A'nın 800 TL'ye düşmesi ve B'nin 700 TL'ye çıkmasıdır. İki UPDATE sorgusu birbirinden bağımsız çalıştırılırsa ilk sorgu başarılı, ikinci sorgu başarısız olabilir.
Transaction, bu iki değişikliği aynı kararın parçası yapar. SQL tarafında temel yapı şöyledir:
BEGIN;
UPDATE accounts
SET balance = balance - 200
WHERE id = 1;
UPDATE accounts
SET balance = balance + 200
WHERE id = 2;
COMMIT;BEGIN transaction'ı başlatır. COMMIT değişikliklerin kalıcı olarak kabul edilmesini ister. İşlem tamamlanamayacaksa ROLLBACK kullanılarak transaction'ın yaptığı değişiklikler geri alınır.
Buradaki önemli ayrıntı, BEGIN ile COMMIT arasına yazılan sorguların kendiliğinden doğru bir transfer oluşturmadığıdır. A hesabında yeterli bakiye bulunup bulunmadığı kontrol edilmelidir. Hesapların geçerli olduğu doğrulanmalı, transfer tutarı negatif olmamalı ve aynı transfer isteğinin iki kez işlenmesi engellenmelidir.
Örnekteki ilk UPDATE, A hesabında yalnızca 100 TL bulunsa bile bakiyeyi -100 TL'ye indirebilir. Database transaction bunu kendiliğinden yasaklamaz. İş kuralı uygulama koduyla veya database constraint ile korunmalıdır.
Tek transaction içinde iki UPDATE çalıştırmak da yeterli değildir; her sorgunun gerçekten beklenen satırı değiştirdiği kontrol edilmelidir. WHERE koşulu hiçbir satırla eşleşmezse SQL komutu hata üretmeden sıfır satır güncelleyebilir. Uygulama bunu fark etmeden COMMIT gönderirse teknik olarak başarılı fakat iş açısından hatalı bir transfer kaydedilebilir.
Bu yüzden transaction sınırı yalnızca SQL sorgularının etrafına çizilen bir parantez değildir. Birlikte başarılı olması gereken değişikliklerin ve doğrulamaların aynı karar altında toplanmasıdır.
ACID dört farklı garantiyi ifade eder
Atomicity, transaction içindeki değişikliklerin parçalı biçimde kabul edilmemesidir. Transferin ilk UPDATE sorgusu çalışıp ikinci sorgusu başarısız olduğunda transaction ROLLBACK ile geri alınabilir. İlk UPDATE'in etkisi de ortadan kalkar.
Ancak Atomicity, uygulamanın her koşulda ROLLBACK komutunu çalıştırabileceği anlamına gelmez. Database bağlantısı kopabilir veya process kapanabilir. Transaction henüz COMMIT edilmediyse database, recovery sırasında tamamlanmamış değişiklikleri geri almalıdır. Bunun nasıl gerçekleştiği kullanılan engine'in transaction ve recovery mekanizmasına bağlıdır.
Consistency, transaction öncesinde geçerli olan database kurallarının transaction sonrasında da korunmasını ifade eder. Primary key, foreign key, UNIQUE ve CHECK constraint gibi kurallar bunun somut parçalarıdır. Örneğin CHECK (balance >= 0) constraint'i negatif bakiyeyi engelleyebilir.
Fakat Consistency çoğu anlatımda olduğundan daha geniş yorumlanır. Database, kendisine tanımlanmamış bir iş kuralını bilemez. İki hesap arasındaki transferin muhasebe açısından dengeli olması gerekiyorsa bu kuralın transaction içindeki işlemlerle, uygun constraint'lerle veya doğrulanabilir bir ledger modeliyle uygulanması gerekir.
Isolation, aynı anda çalışan transaction'ların birbirlerinin ara durumlarıyla nasıl etkileşeceğini belirler. İki transfer aynı hesaptan para çekmeye çalıştığında her ikisinin de başlangıç bakiyesini okuyup bağımsız karar vermesi sorun yaratabilir. Isolation bu etkileşimlerin görünürlüğünü ve sıralanmasını yönetir.
Isolation'ın her seviyede tam bir seri çalışma garantisi verdiğini düşünmek yanlıştır. Daha düşük isolation levels bazı eşzamanlılık anomalilerine izin verir. Bu nedenle Isolation'ı anlamak için yalnızca ACID tanımı yeterli değildir.
Durability, başarıyla COMMIT edilmiş bir transaction'ın database'in vaat ettiği arıza modelinde kalıcı olmasıdır. Sunucu process'i kapandığında veya sistem yeniden başladığında commit edilmiş transferin kaybolmaması beklenir.
Database sistemleri bu garanti için genellikle Write-Ahead Logging, yani WAL gibi mekanizmalar kullanır. WAL kullanan bir engine, veri sayfalarının tamamını disk üzerindeki asıl konumlarına yazmadan önce gerekli değişiklik bilgisini log'a kaydedebilir. Recovery sırasında bu kayıtlar kullanılarak commit edilmiş değişiklikler yeniden oluşturulur.
Burada ince bir sınır vardır: Bir COMMIT cevabının hangi noktada dayanıklı kabul edildiği, log'un kalıcı depolamaya yazılma politikasına ve database yapılandırmasına bağlıdır. Asenkron replication kullanan bir sistemde primary üzerinde commit edilmiş bir transaction'ın replica'ya henüz ulaşmamış olması mümkündür. Primary kaybedilip gerideki replica devreye alınırsa beklenen veri kaybı sınırı ayrıca değerlendirilmelidir.
ACID, tek başına bütün altyapının sıfır veri kaybıyla çalışacağını söylemez.
Isolation seviyeleri aynı güvenceyi vermez
Transaction hakkında en yaygın eksik açıklama, işlemlerin birbirinden tamamen bağımsız çalıştığını söylemektir. Gerçekte database, concurrency yönetimi için lock, MVCC veya bunların birleşiminden yararlanır. MVCC, Multi-Version Concurrency Control ifadesinin kısaltmasıdır; okuyucuların belirli kurallar altında satırların farklı sürümlerini görmesini sağlar.
Bu mekanizmaların amacı, her transaction'ı sırayla çalıştırmadan güvenli concurrency sağlamaktır. Fakat seçilen isolation level, hangi ara durumların görülebileceğini ve hangi anomalilerin ortaya çıkabileceğini değiştirir.
Dirty read, bir transaction'ın başka bir transaction tarafından değiştirilmiş fakat henüz COMMIT edilmemiş veriyi okumasıdır. A hesabının bakiyesi 1.000 TL iken T1 bunu 800 TL'ye indirir. T2, T1 COMMIT etmeden 800 TL okursa dirty read gerçekleşir. T1 sonradan ROLLBACK yaparsa T2 hiç kalıcı olmamış bir değere dayanarak karar vermiştir.
Non-repeatable read, aynı transaction içinde aynı satırın iki kez okunması ve arada başka bir transaction COMMIT ettiği için farklı değerler görülmesidir. T1 bakiye sorguladığında 1.000 TL bulur. T2 bakiyeyi 900 TL yapıp COMMIT eder. T1 aynı satırı tekrar okuduğunda 900 TL görüyorsa non-repeatable read oluşmuştur.
Phantom read ise aynı koşulla yapılan iki sorgunun farklı satır kümeleri döndürmesidir. T1, status = 'PENDING' koşuluyla transfer kayıtlarını listeler. T2 bu koşula uyan yeni bir kayıt ekleyip COMMIT eder. T1 sorguyu tekrarladığında yeni satırı görürse phantom read gerçekleşir.
SQL standardında yaygın biçimde kullanılan dört isolation level bulunur:
| Isolation level | Temel davranış |
|---|---|
| READ UNCOMMITTED | Dirty read'e izin verebilir. |
| READ COMMITTED | Dirty read'i engeller; non-repeatable read ve phantom read görülebilir. |
| REPEATABLE READ | Dirty read ve non-repeatable read'i engeller; standardın izin verdiği phantom davranışı engine'e göre değişebilir. |
| SERIALIZABLE | Başarıyla tamamlanan transaction'ların sonucu, belirli bir seri yürütmeyle eşdeğer olmalıdır. |
Bu tablo SQL standardının genel çerçevesidir. PostgreSQL ve MySQL gibi sistemlerde aynı isolation level adı farklı uygulama ayrıntılarına sahip olabilir. Örneğin PostgreSQL'in REPEATABLE READ uygulaması, SQL standardının bu seviye için izin verdiği bazı anomalileri engeller.
Bir başka önemli anomali write skew'dur. İki transaction farklı satırları güncellerken aynı iş kuralını birlikte ihlal edebilir. Her transaction diğerinin henüz gerçekleşmemiş değişikliğini görmeden geçerli bir karar verdiğini düşünür. Snapshot tabanlı bazı isolation seviyelerinde bu durum mümkündür.
Dolayısıyla REPEATABLE READ altında aynı satırı tekrar okuduğunuzda aynı değeri görmek, bütün iş kurallarının korunacağı anlamına gelmez.
SERIALIZABLE en güçlü standart isolation level'dır. Ancak bu, database'in bütün transaction'ları fiziksel olarak tek tek çalıştıracağı anlamına gelmez. Engine eşzamanlı çalışmaya izin verebilir; çakışma algılandığında transaction'lardan birini iptal ederek seri yürütmeyle eşdeğer sonucu koruyabilir.
Uygulama açısından bunun karşılığı nettir: SERIALIZABLE kullanıyorsanız belirli transaction hatalarında retry yapabilecek bir tasarımınız olmalıdır.
Gerçek transferde kritik karar SQL'den önce başlar
Bir backend geliştiricisinin para transferi endpoint'i üzerinde çalıştığını düşünelim. İstek gövdesinde kaynak hesap, hedef hesap, tutar ve idempotency key bulunuyor. Idempotency key, aynı mantıksal isteğin tekrar gönderildiğinde ikinci kez uygulanmasını engellemek için kullanılan benzersiz anahtardır.
Kaynak hesabın bakiyesi 1.000 TL, hedef hesabın bakiyesi 500 TL olsun. İstek 200 TL transfer edilmesini istiyor. Geliştiricinin çözmesi gereken mesele yalnızca iki bakiyeyi güncellemek değildir; aynı istek tekrar geldiğinde, aynı hesaba başka transfer yapıldığında ve COMMIT cevabı kaybolduğunda sistemin ne yapacağı da belirlenmelidir.
İlk olarak transfer isteğinin kimliği database üzerinde UNIQUE constraint ile korunabilir. Böylece aynı idempotency key için iki ayrı başarılı transfer kaydı oluşturulması engellenir. Anahtarın aynı kaynak, hedef ve tutarla ilişkilendirilmesi de gerekir; aksi halde aynı anahtarla farklı içerikli bir istek geldiğinde hangi sonucun döndürüleceği belirsizleşir.
İkinci olarak hesap satırları transaction içinde kilitlenebilir. PostgreSQL benzeri bir sistemde SELECT ... FOR UPDATE, seçilen satırlara başka transaction'ların çakışan güncellemelerini bekletecek bir lock alır.
İki hesabın lock'larını tutarlı sırayla almak önemlidir. Bir transfer önce A sonra B hesabını, ters yöndeki başka bir transfer önce B sonra A hesabını kilitlerse deadlock oluşabilir.
Deadlock, iki veya daha fazla transaction'ın birbirlerinin tuttuğu lock'ları beklediği ve hiçbirinin ilerleyemediği durumdur. Database genellikle bu döngüyü algılar ve transaction'lardan birini iptal eder. İptal edilen transaction uygun koşullarda baştan denenmelidir.
Deadlock'u tamamen ortadan kaldırmak her zaman mümkün değildir. Ancak hesapları hesap ID'sine göre artan sırayla kilitlemek, aynı işlem ailesindeki döngüsel bekleme olasılığını azaltır.
Alternatif olarak bakiye kontrolü ve azaltma tek bir koşullu UPDATE içinde yapılabilir:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1
AND balance >= 200;Bu sorgu beklenen bir satırı değiştirmezse transfer reddedilmelidir. Tek SQL statement içinde kontrol ve güncellemenin birleştirilmesi, ayrı SELECT ve UPDATE arasındaki yarış koşulunu önlemeye yardımcı olur.
Hedef hesabın güncellenmesi, transfer kaydının oluşturulması ve gerekiyorsa ledger kayıtlarının eklenmesi aynı transaction içinde tamamlanır. Ardından COMMIT gönderilir.
Buradaki en tehlikeli durumlardan biri COMMIT sırasında network bağlantısının kopmasıdır. Uygulama cevap alamadığı için transaction'ın başarısız olduğunu varsayamaz. Database COMMIT işlemini tamamlamış, fakat cevap istemciye ulaşmamış olabilir.
Bu nedenle belirsiz COMMIT sonucunda körlemesine yeni transfer oluşturmak yerine kalıcı transfer kimliği veya idempotency key üzerinden işlem durumu sorgulanmalıdır. İlk transaction başarıyla tamamlandıysa önceki sonuç döndürülür. Tamamlanmadıysa kontrollü retry yapılır.
Finansal kayıtlar için ayrı bir ledger tutmak da çoğu ciddi tasarımda daha sağlam bir yaklaşımdır. Ledger, para hareketlerini kayıt altına alan muhasebe defteridir. Bakiye bir hesap üzerindeki güncel değer olarak saklanabilir; ancak hareketlerin denetlenebilir kaydı transferin iki tarafını da temsil etmelidir.
Örneğin 200 TL transferi için kaynak hesaba -200 TL, hedef hesaba +200 TL etkisi yazılır. Bu iki hareketin toplamı sıfırdır. Bu özellik, basitçe iki bakiye alanını güncellemekten daha açık bir muhasebe kontrolü sağlar.
Yine de toplamın sıfır olması tek başına yeterli değildir. Para birimi, transfer kimliği, hesap sahipliği ve kayıtların değiştirilemezliği gibi kurallar ayrıca korunmalıdır.
Transaction garantisi database sınırında biter
Tek database içindeki transfer ile iki bağımsız sistem arasındaki transfer aynı problem değildir. Para bir PostgreSQL database'inden düşülüp başka bir servisin yönettiği database'e ekleniyorsa yerel transaction iki sistemi birlikte atomik hale getiremez.
Bu durum distributed transaction problemidir. Birden fazla bağımsız katılımcının aynı mantıksal işin sonucunda anlaşması gerekir.
Klasik çözümlerden biri Two-Phase Commit, yani 2PC protokolüdür. Coordinator önce katılımcılardan commit için hazır olup olmadıklarını sorar. Hazırlık başarılıysa commit kararını bildirir; değilse abort kararı verir.
2PC belirli koşullarda katılımcılar arasında atomic commit sağlayabilir. Ancak coordinator arızaları, hazırlık aşamasında tutulan lock'lar ve erişilebilirlik maliyetleri nedeniyle her mimari için uygun değildir. Katılımcıların protokolü desteklemesi de gerekir.
Microservice mimarilerinde sık karşılaşılan alternatif Saga'dır. Saga, birden fazla yerel transaction'ı bir iş akışı olarak yönetir. Bir adım başarısız olursa önceki adımlar için compensating action çalıştırılabilir.
Compensating action, ROLLBACK ile aynı şey değildir. Örneğin gönderilmiş bir ödemenin iadesi, ilk ödemenin hiç gerçekleşmemiş olması anlamına gelmez. İade ayrı bir finansal olaydır ve kendi hata durumlarına sahiptir.
Benzer sınır, database güncellemesiyle message broker'a event gönderme arasında da bulunur. Database COMMIT başarılı olup event yayını başarısız olabilir. Event önce gönderilirse bu kez database işlemi başarısız olduğu halde dış sistem mesajı alabilir.
Transactional outbox bu boşluğu azaltmak için kullanılır. İş verisi ve gönderilecek event, aynı database transaction içinde ayrı kayıtlar olarak saklanır. Ardından bağımsız bir worker outbox kayıtlarını broker'a iletir.
Ancak outbox tek başına exactly-once delivery sağlamaz. Worker mesajı gönderdikten sonra gönderildi bilgisini kaydedemeden çökerse aynı mesaj yeniden gönderilebilir. Consumer tarafında idempotency veya deduplication gerekir.
Bu mekanizma distributed transaction ihtiyacını tamamen ortadan kaldırmaz; farklı bir garanti modeli kurar. Yerel database değişikliğiyle event kaydını atomik hale getirir, dış sisteme teslimatı ise tekrar denenebilir bir sürece dönüştürür.
Önce invariant'ı koruyun, sonra isolation seçin
Para transferi tasarlarken başlangıç tercihim, aynı database içindeki hesap hareketlerini tek ve kısa bir transaction altında toplamak olur. Kritik iş kurallarını yalnızca uygulama seviyesindeki SELECT kontrollerine bırakmam. UNIQUE ve CHECK constraint'leri, koşullu UPDATE'leri, gerektiğinde row-level lock'ları ve açık bir idempotency modelini birlikte kullanırım.
Burada invariant, başarılı işlemlerden sonra mutlaka doğru kalması gereken koşuldur. Örneğin sistem negatif bakiyeye izin vermiyorsa balance >= 0 bir invariant'tır. Aynı transferin yalnızca bir kez muhasebeleştirilmesi de başka bir invariant'tır.
Isolation level seçimini bu invariant'ların nasıl ihlal edilebileceğine göre yaparım. READ COMMITTED, uygun lock ve koşullu UPDATE kullanımıyla birçok basit bakiye güncellemesi için yeterli olabilir. Ancak karar birden fazla satırın veya değişen bir kayıt kümesinin birlikte okunmasına dayanıyorsa SERIALIZABLE ya da açık bir concurrency kontrol stratejisi gerekebilir.
SERIALIZABLE seçmek güvenlik açısından güçlü bir başlangıçtır, fakat bedelsiz değildir. Çakışmalı transaction'larda abort ve retry sayısı artabilir. Retry sırasında bütün transaction yeniden çalıştırılmalı; dış sisteme ödeme emri göndermek gibi geri alınamayan yan etkiler transaction gövdesine gelişigüzel yerleştirilmemelidir.
Transaction'ları kısa tutmak bu yüzden önemlidir. Transaction açıkken uzak API çağrısı yapmak, uzun hesaplamalar yürütmek veya kullanıcı cevabı beklemek lock süresini uzatabilir. Bu da contention, timeout ve deadlock olasılığını artırır.
Üretim ortamında yalnızca başarılı COMMIT sayısını izlemek yeterli değildir. Transaction süresi, lock wait, deadlock, serialization failure ve retry oranları da izlenmelidir. Bu ölçümler, seçilen isolation politikasının gerçek yük altında uygulanabilir olup olmadığını gösterir.
Önerimin sınırı, işin tek database transaction'ı içinde tamamlanamamasıdır. Harici ödeme sağlayıcısı, başka database veya message broker devreye girdiğinde yerel ACID garantilerini uçtan uca atomicity olarak yorumlamam. İşlemi kalıcı bir state machine olarak modeller, her adım için idempotency ve recovery davranışı tanımlarım.
Son karar şu olmalıdır: Önce hangi kayıtların birlikte değişmesi gerektiğini ve hangi invariant'ın asla bozulamayacağını belirleyin. Ardından bunları koruyan en dar transaction sınırını kurun; concurrency testleriyle gerçekten korunduğunu doğrulayın. Daha yüksek isolation level'ı otomatik güvenlik anahtarı gibi kullanmak yerine, ispatlayabildiğiniz garantiyi seçin.
yorumlar
0Henüz yorum yok. İlk notu sen bırak.
Yorum yazmak için giriş yap.
Gmail ile saniyeler içinde, ya da e-posta ile kaydol.