Geliştirme ortamımızda durumu birebir canlı test edebilmek için bir docker-compose.yml ve opsiyonel Dockerfile hazırlayalım. Sistemimizde PostgreSQL 18 ve kolay SQL koşturabilmek/izleyebilmek için Adminer kullanacağız.
version: '3.8'
services:
postgres:
image: postgres:18-alpine
container_name: pg18_write_skew_demo
environment:
POSTGRES_USER: app_user
POSTGRES_PASSWORD: app_password
POSTGRES_DB: clinic_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
adminer:
image: adminer:latest
container_name: adminer_demo
ports:
- "8080:8080"
depends_on:
- postgres
volumes:
pgdata: 1. Sahne Açılıyor: Nöbetçi Doktorlar Problemine Hoş Geldiniz
Veritabanı sistemlerinde eşzamanlılık (concurrency) denildiğinde akla ilk gelen felaketler genelde Dirty Read veya Lost Update olur. Ancak hepsinden daha kurnaz, fark edilmesi zor ve uygulamanızın mantıksal tutarlılığını (invariant) sessizce yıkan bir kavram var: Write Skew (Yazma Eğilmesi / Sapması).
Bu rehberde, teorik tanımlara boğulmak yerine birebir ellerimizi kirleteceğiz (hands-on). Senaryomuzu adım adım bir PostgreSQL veritabanı üzerinde koşturacak ve iki farklı transaction’ın veritabanını nasıl tutarsızlığa sürüklediğini gözlerimizle göreceğiz.
1.1 Hastanenin Altın Kuralı
Hayal edin: Büyük bir hastanenin acil servisini yönetiyorsunuz. Hastanenin değişmez bir kuralı var (Invariant):
“Hastanede her an en az BİR doktor nöbetçi (on-call) olmak zorundadır.”
Sistemde kaç doktor olursa olsun, nöbetten çıkmak isteyen biri olursa, sistem önce kaç kişinin aktif nöbetçi olduğuna bakar. Eğer nöbetçi sayısı $≥ 2$ ise ilgili doktorun nöbetten çıkmasına izin verilir. Eğer $1$ ise nöbetten çıkamaz.
1.2 Kahramanlarımız: Ayşe ve Ali
O gece acilde nöbetçi olan sadece iki doktor var: Dr. Ayşe ve Dr. Ali.
İkisi de kendisini kötü hissediyor ve aynı anda nöbetten ayrılmak için hastane yönetim sistemine giriyor. Sistem Repeatable Read (Snapshot Isolation) seviyesinde çalışıyor. Bakalım neler olacak…
2. Hands-On Lab: Kendi Veritabanımızda Fırtınayı Kopartıyoruz
Bu bölümü okurken isterseniz bilgisayarınızda iki farklı terminal sekmesi açıp PostgreSQL üzerinde komutları sırasıyla çalıştırabilirsiniz.
2.1 Veritabanı Şemasını Hazırlama ve Veri Ekleme
Öncelikle doktorlarımızı tutacak basit bir tablo oluşturalım ve Ayşe ile Ali’u nöbetçi olarak ekleyelim:
-- Tablo Oluşturma
CREATE TABLE doctors (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
on_call BOOLEAN NOT NULL
);
-- Başlangıç Verilerini Ekleme
INSERT INTO doctors (name, on_call) VALUES ('Ayşe', true);
INSERT INTO doctors (name, on_call) VALUES ('Ali', true);
-- Mevcut Durumu Kontrol Etme
SELECT * FROM doctors; Şu anki tablo görünümümüz:
| id | name | on_call |
|---|---|---|
| 1 | Ayşe | true |
| 2 | Ali | true |
2.2 İki Ayrı Terminal, İki Ayrı Dünya (Transaction 1 & Transaction 2)
Şimdi 2 ayrı terminal penceremiz olduğunu varsayalım:
- Terminal 1: Dr. Ayşe adına işlem yapacak (T1)
- Terminal 2: Dr. Ali adına işlem yapacak (T2)
Her iki terminalde de isolation seviyesini REPEATABLE READ olarak başlatıyoruz.
-- Terminal 1 (Ayşe)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- Terminal 2 (Ali)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; 2.3 Adım Adım Felakete Gidiş: Zamanda Eşzamanlı Yürüyüş
Adım 1: Ayşe ve Ali aynı anda nöbetçi sayısını sorgular
Terminal 1 (Ayşe):
SELECT count(*) FROM doctors WHERE on_call = true;
-- Dönen Sonuç: 2 Ayşe düşünüyor: “İçeride 2 nöbetçi var. Ben ayrılırsam 1 kalır, kural ihlal edilmez.”
Terminal 2 (Ali):
SELECT count(*) FROM doctors WHERE on_call = true;
-- Dönen Sonuç: 2 Ali düşünüyor: “İçeride 2 nöbetçi var. Ben ayrılırsam 1 kalır, kural ihlal edilmez.”
Adım 2: Ayşe ve Ali kendi durumlarını false yapar
Terminal 1 (Ayşe):
UPDATE doctors SET on_call = false WHERE name = 'Ayşe';
-- UPDATE 1 (Başarılı) Terminal 2 (Ali):
UPDATE doctors SET on_call = false WHERE name = 'Ali';
-- UPDATE 1 (Başarılı - Çünkü farklı satırları güncelliyorlar!) Buraya dikkat! Hiçbir transaction bir diğerinin satırına dokunmadığı için Lock Wait (Kilit Beklemesi) yaşanmadı! PostgreSQL her iki UPDATE işlemine de anında onay verdi.
Adım 3: Commit Aşaması
Terminal 1 (Ayşe):
COMMIT;
-- COMMIT Başarılı! Terminal 2 (Ali):
COMMIT;
-- COMMIT Başarılı! Adım 4: Felaketle Yüzleşme
Şimdi nöbetçi listesini kontrol edelim:
SELECT * FROM doctors; Çıktı:
| id | name | on_call |
|---|---|---|
| 1 | Ayşe | false |
| 2 | Ali | false |
Sonuç: Hastanede 0 nöbetçi kalmıştır. Hastane kuralı (Invariant) yerle bir olmuş, sistem tutarsız bir duruma geçmiştir!
3. Kod Üzerinde Ne Oldu? Write Skew Anatomi Analizi
3.1 Neden Dirty Read veya Non-Repeatable Read Değil?
Bu senaryoda geleneksel veritabanı anormalliklerinin hiçbiri yaşanmadı:
- Dirty Read olmadı: Hiçbir transaction diğerinin henüz commit edilmemiş verisini okumadı.
- Non-Repeatable Read olmadı: İki transaction da kendi içinde okuma yaptığında veriler değişmedi.
- Lost Update olmadı: Ayşe ‘Ayşe’ satırını, Ali ise ‘Ali’ satırını güncelledi. Birbirlerinin güncellemesini ezmediler.
3.2 Snapshot Isolation (Repeatable Read) Neden Çaresiz Kaldı?
Snapshot Isolation mekanizmasında her transaction başladığı andaki veritabanı “fotoğrafını” (snapshot) görür.
- T1 ve T2 aynı snapshot üzerinden okuma yaptı.
- T1, T2’nin yazdığı veriyi görmedi; T2 de T1’inkini görmedi.
- Her iki transaction da overlapping premise (ortak varsayım) üzerine yazma yaptı: “En az 2 nöbetçi var.”
- Ancak yazdıkları veriler farklı kümelere (farklı satırlara) gittiği için çakışma (write-write conflict) algılanmadı.
İşte bu duruma Write Skew denir: İki veya daha fazla transaction’ın çakışmayan satırları yazarak veritabanının genel mantıksal bütünlüğünü bozması durumu.
4. PostgreSQL Dokümantasyonundan Gerçekler: Isolation Seviyeleri
PostgreSQL resmi dokümantasyonuna bakıldığında isolation seviyeleri ve anormallikler şu şekilde özetlenir:
4.1 PostgreSQL’de Repeatable Read Sınırları
PostgreSQL dokümantasyonu (Section 13.2.2. Repeatable Read Isolation Level) şu uyarıyı açıkça yapar:
“The Repeatable Read isolation level only provides a snapshot of the database at the start of the transaction. It does not prevent concurrent transactions from making modifications that conflict with each other’s assumptions.”
Yani PostgreSQL’in REPEATABLE READ seviyesi Dirty Read, Non-Repeatable Read ve standart Phantom Read durumlarını engeller; ancak Write Skew önlenemez!
4.2 Serializable Seviyesi ve SSI (Serializable Snapshot Isolation)
PostgreSQL dokümantasyonuna göre (Section 13.2.3. Serializable Isolation Level), Write Skew anormalliğini tespit etmek ve engellemek için SSI (Serializable Snapshot Isolation) algoritması kullanılır.
SERIALIZABLE isolation seviyesi aktif edildiğinde, PostgreSQL transaction’lar arasındaki bağımlılık grafiklerini (SIREAD locks) izler. Eğer bir transaction’ın okuduğu veri bir başkası tarafından değiştirilmişse ve bu durum bir döngü/tutarsızlık yaratıyorsa, transaction’lardan biri şu hatayla iptal edilir:
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried. 5. Çözüm Yolları: Felaketten Nasıl Korunuruz?
Hands-on olarak gördüğümüz bu problemi gerçek projelerimizde nasıl çözeriz? 3 ana yaklaşımımız var:
5.1 Çözüm 1: Açık Kilitleme (Explicit Locking - SELECT FOR UPDATE)
Geleneksel ve en yaygın yöntemdir. Okuma yaparken ilgili satırları kilitleriz.
-- Terminal 1
BEGIN;
SELECT * FROM doctors WHERE on_call = true FOR UPDATE;
-- Bu komut on_call = true olan tüm satırları (Ayşe ve Ali) kilitler.
-- Terminal 2
BEGIN;
SELECT * FROM doctors WHERE on_call = true FOR UPDATE;
-- Terminal 2 BEKLEMEYE GEÇER (Lock Wait)! Terminal 1 commit/rollback yapana kadar ilerleyemez. Dezavantajı: Unutulabilir, performans kısıtları doğurabilir veya dinamik koşullarda (predicate lock gerektiren durumlarda) eksik kalabilir.
5.2 Çözüm 2: Gerçek Seri Hale Getirme (SERIALIZABLE Isolation)
Veritabanına işi bırakıp isolation seviyesini yükseltmek:
-- Terminal 1
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call = true;
UPDATE doctors SET on_call = false WHERE name = 'Ayşe';
COMMIT; -- Başarılı!
-- Terminal 2
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call = true;
UPDATE doctors SET on_call = false WHERE name = 'Ali';
COMMIT;
-- ERROR: could not serialize access due to read/write dependencies among transactions Dezavantajı: Uygulama katmanında hata alan transaction’ları tekrar deneme (retry logic) mekanizması yazmanız gerekir.
5.3 Çözüm 3: Materialized Conflict (Çakışmayı Somutlaştırma)
Write Skew, farklı satırların güncellenmesi yüzünden oluşur. Eğer durum kuralını tek bir satıra bağlarsak problem çözülür.
Örneğin bir hospital_status tablosunda active_on_call_count tutulursa ve iki işlem de bu tek satırı güncellerse, PostgreSQL standart UPDATE kilidi sayesinde Write Skew’ü otomatik engeller.
6. Özet ve Son Söz
Write Skew, izolasyon seviyelerinin derinliklerinde saklanan en şaşırtıcı veritabanı davranışlarından biridir.
- Neden Tehlikeli? Çünkü veritabanı seviyesinde bir hata fırlatmaz, sessizce iş mantığınızı bozar.
- Nasıl Anlaşılır? Okunan veriye göre karar verilip, başka bir veri güncelleniyorsa oranla ortaya çıkar.
- Nasıl Çözülür? Explicit locking (
FOR UPDATE),SERIALIZABLEisolation seviyesi veya retry-oriented mimariler ile.
Siz de kendi projelerinizde Repeatable Read kullanırken mantıksal invariant’larınıza dikkat edin ve Dr. Ayşe ile Dr. Ali’un acili boş bırakmasına izin vermeyin!