~/ blog

Veritabanlarının Sessiz Katili: Write Skew ve Nöbetçi Doktorlar Hikayesi

Aynı anda çalışan iki transaction bir iş kuralını bozabilir mi? PostgreSQL üzerinde hands-on komutlarla Write Skew anomalisini ve çözümlerini inceliyoruz.

# postgresql # database # transaction
BEGIN
ROLLBACK
COMMIT
UPDATE
DELETE
SELECT

Mantıksal tutarlılık üzerine: Bir hastane, iki doktor.

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 Çarpıklığı).

Bu yazıda, teoriyi değil, doğrudan bir senaryoyu çalıştırarak ilerleyeceğiz. Önce küçük bir PostgreSQL ortamı kurup, ardından iki farklı transaction’ın aynı anda nasıl yanlış bir sonuca ulaştığını adım adım göreceğiz. Hands-on başlamadan önce problemi iyice anlayalım.

Hastanenin altın kuralı

Şimdi biraz hayal edelim. Büyük bir hastanenin acil servisini yönetiyorsunuz. Bu Hastanenin de değişmez bir kuralı var:

“Hastanede her an en az 1 doktor nöbetçi olmalıdı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 tek nöbetçi doktor olduğundan bir başka nöbetçi doktor gelene kadar nöbetine devam eder. Kulağa gayet mantıklı geliyor değil mi? ne gibi bir sorunla karşılaşabiliriz dediğini duyar gibiyim. Yavaştan başlayalım.

İlk olarak kurgusal karakterlerimizi tanıyalım ve olay örgümüzü görelim.

Kahramanlarımız: Dr. Ayşe ve Dr. Ali

O gece acilde nöbetçi olan sadece iki doktor var: Dr. Ayşe ve Dr. Ali.

İkisi de kendisini iyi hissetmiyor ve aynı anda nöbetten ayrılmak için hastanenin yönetim sistemine giriyor. Sistem arka planda nöbetçi sayısını sorguluyor ve her ikisine de anlık durumu gösteriyor: 2 nöbetçi var.

— Bu olaydaki kritik noktamız hastane sisteminin yaptığı varsayımdır ilgili süreci iki farklı ekrandan gören doktorlarımız ise aşağıdaki gibi düşünür.

  • Dr. Ayşe’nin gözünden: “Şu an 2 nöbetçi var. Ben nöbetten çıkarsam geriye 1 nöbetçi (Dr. Ali) kalır. Nöbetçi sayısı ≥ 1 kuralı bozulmaz, nöbetimi güvenle sonlandırabilirim.”

  • Dr. Ali’nin gözünden: “Şu an 2 nöbetçi var. Ben nöbetten çıkarsam geriye 1 nöbetçi (Dr. Ayşe) kalır. Nöbetçi sayısı ≥ 1 kuralı bozulmaz, nöbetimi güvenle sonlandırabilirim.”

Her iki doktor da aynı doğru veriden (count = 2) yola çıkarak fakat birbirlerinin aynı anda işlem yaptığından habersiz şekilde taleplerini onaylıyor.

Sistem her iki isteği de kabul ediyor çünkü Dr. Ayşe sadece kendi durumunu (Ayşe -> on_call: false), Dr. Ali ise sadece kendi durumunu (Ali -> on_call: false) güncelliyor. Yani veritabanı seviyesinde aynı satıra müdahale edilmediği için görünürde hiçbir çakışma (Lock / Conflict) yaşanmıyor.

Ancak her iki işlem de tamamlandığında acilde 0 nöbetçi doktor kalıyor! Hastanenin en temel altın kuralı, iki yasal ve hatasız işlemin eşzamanlı çalışmasıyla sessizce çiğnenmiş oluyor.

BEGIN_TRANSACTION
01 / 04
Ayşe
BEGIN
SELECT
UPDATE
COMMIT
Ali
BEGIN
SELECT
UPDATE
COMMIT

Adım adım tanık olduğumuz bu senaryoyu şimdi teoriden çıkartıp gerçek bir veritabanı motoru üzerinde hands-on olarak gözlemleyelim.

İki ayrı terminal penceresinde eşzamanlı SQL transaction’ları çalıştırarak PostgreSQL’in bu durumu nasıl yönettiğini adım adım birlikte simüle edeceğiz.

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. Önce lokal ortamımızı hazırlayalım; böylece senaryoyu gerçek bir veritabanında tekrarlayabiliriz.

Ortamı hazırlama: PostgreSQL container’ını ayağa kaldırma

İşlemlere başlamadan önce PostgreSQL 18’i tek bir Docker container olarak başlatalım. Bu adımda sadece bir container çalıştıracağız; daha sonra bu container içinden doğrudan psql terminaline bağlanacağız.

start-postgres.sh
BASH
docker run --name pg18_write_skew_demo \
  -e POSTGRES_USER=app_user \
  -e POSTGRES_PASSWORD=app_password \
  -e POSTGRES_DB=clinic_db \
  -p 5432:5432 \
  -d postgres:18-alpine

Container çalıştıktan sonra veritabanına bağlanmak için aşağıdaki komutu kullanabilirsiniz.

enter-postgres.sh
BASH
docker exec -it pg18_write_skew_demo psql -U app_user -d clinic_db

Veritabanı tablosunu hazırlayalım

Öncelikle hangi doktorlarımızın nöbette olduğunun kaydını tutacak basit bir tablo oluşturalım ve Dr.Ayşe ile Dr.Ali’yi nöbetçi doktor olarak ekleyelim.

schema.sql
SQL
-- Tabloyu oluşturalım.
CREATE TABLE doctors (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    on_call BOOLEAN NOT NULL
);

-- Başlangıç verilerini ekleyelim.
INSERT INTO doctors (name, on_call) VALUES ('Ayşe', true);
INSERT INTO doctors (name, on_call) VALUES ('Ali', true);

-- En son bir gözatalım.
SELECT * FROM doctors;

/*

  Sonuç:

  -----------------------
  | id | name | on_call |
  -----------------------
  | 1  | Ayşe | true    |
  | 2  | Ali  | true    |
  -----------------------

*/

İki ayrı terminal, iki ayrı dünya

Şimdi transaction’ları iki ayrı terminal üzerinden ilerletelim. Her iki terminalde de ayrı ayrı bir şekilde transaction’ları başlatıyoruz.

  • Terminal 1: Dr. Ayşe adına işlem yapacağız. (T1)
  • Terminal 2: Dr. Ali adına işlem yapacağız. (T2)
transactions.sql
SQL
-- Terminal 1 (Ayşe)
BEGIN;

-- Terminal 2 (Ali)
BEGIN;

Adım adım felakete doğru: 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):

ayse-count.sql
SQL
SELECT count(*) FROM doctors WHERE on_call = true;
-- Dönen Sonuç: 2

Ayşe’nin gözünden: “İçeride 2 nöbetçi var. Ben ayrılırsam 1 kalır, kural ihlal edilmez.”

Terminal 2 (Ali):

ali-count.sql
SQL
SELECT count(*) FROM doctors WHERE on_call = true;
-- Dönen Sonuç: 2

Ali’nin gözünden: “İçeride 2 nöbetçi var. Ben ayrılırsam 1 kalır, kural ihlal edilmez.”

— Adım 2: Ayşe ve Ali on_call’ı false yaparak, nöbeti bırakırlar.

Terminal 1 (Ayşe):

ayse-update.sql
SQL
UPDATE doctors SET on_call = false WHERE name = 'Ayşe';
-- UPDATE 1 (Başarılı)

Terminal 2 (Ali):

ali-update.sql
SQL
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 yaşanmadı! PostgreSQL her iki UPDATE işlemine de anında onay verdi.

— Adım 3: Commit Aşaması

Terminal 1 (Ayşe):

ayse-commit.sql
SQL
COMMIT;
-- COMMIT Başarılı!

Terminal 2 (Ali):

ali-commit.sql
SQL
COMMIT;
-- COMMIT Başarılı!

— Adım 4: Felaketle Yüzleşme

Şimdi nöbetçi listesini kontrol edelim:

doctor-check.sql
SQL
SELECT * FROM doctors;

/*

  Sonuç:

  -----------------------
  | id | name | on_call |
  -----------------------
  | 1  | Ayşe | false   |
  | 2  | Ali  | false   |
  -----------------------

*/

Sonuç olarak hastanede hiç nöbetçi doktor kalmamış, hastane kuralı (Invariant) yerle bir olmuş, sistem tutarsız bir duruma geçmiştir!


Veritabanı üzerinde ne yaşandı? Write skew anomali analizi

Neden dirty read veya non-repeatable read değil?

Bu senaryoda geleneksel veritabanı anomaliliklerinin 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.

Snapshot isolation (repeatable read) neden çaresiz kaldı?

Snapshot Isolation mekanizmasında her transaction başladığı andaki veritabanı “fotoğrafını” (snapshot) görür.

  1. T1 ve T2 aynı snapshot üzerinden okuma yaptı.
  2. T1, T2’nin yazdığı veriyi görmedi; T2 de T1’in yazdığı veriyi görmedi.
  3. Her iki transaction da overlapping premise (ortak varsayım) üzerine yazma yaptı: “En az 2 nöbetçi var.”
  4. 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 (Yazma çarpıklığı) denir: İki veya daha fazla transaction’ın çakışmayan satırları yazarak veritabanının genel mantıksal bütünlüğünü bozması durumu olarak tanımlanır.


PostgreSQL dokümantasyonundan gerçekler: Isolation seviyeleri

PostgreSQL resmi dokümantasyonuna bakıldığında isolation seviyeleri ve gözlemlenen anomaliler şu şekilde özetlenir:

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!

Serializable seviyesi ve SSI (serializable snapshot isolation)

PostgreSQL dokümantasyonuna göre (Section 13.2.3. Serializable Isolation Level), Write Skew anomaliliğ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:

serializable-error.txt
TEXT
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.

Çö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:

Çözüm 1: Açık kilitleme (Explicit Locking - SELECT FOR UPDATE)

Geleneksel ve en yaygın yöntemdir. Okuma yaparken ilgili satırları kilitleriz.

select-for-update.sql
SQL
-- 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.

Çözüm 2: Gerçek seri hale getirme (SERIALIZABLE isolation)

İşi veritabanına bırakıp isolation seviyesini serializable olarak tanımlamak:

serializable.sql
SQL
-- 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.

Çö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.


Kaynaklar ve ileri okuma

İlişkisel veritabanlarında izolasyon seviyesini REPEATABLE READ seçmek, uygulamanın tüm eşzamanlılık (concurrency) senaryolarından tamamen korunduğu anlamına gelmez. Veritabanı motoru veri bütünlüğünü ve fiziksel çakışmaları kontrol ederken, uygulamanın iş kurallarını (business logic) korumak mimari kararlarımıza bağlıdır. Eşzamanlı çalışacak kritik süreçler tasarlanırken Write Skew ihtimali mutlaka göz önünde bulundurulmalıdır.

Bu konu ve veritabanı izolasyon seviyeleri hakkında daha detaylı bilgi edinmek için aşağıdaki kaynakları inceleyebilirsiniz:

Designing Data-Intensive Applications (Martin Kleppmann)Chapter 7: Transactions bölümünde Snapshot Isolation ve Write Skew konuları detaylıca ele alınmaktadır.

PostgreSQL Official DocumentationSection 13.2: Transaction Isolation başlığında PostgreSQL’in izolasyon seviyeleri ve SSI mimarisi açıklanmaktadır.

A Critique of ANSI SQL Isolation Levels (Berenson et al., 1995) – ANSI SQL standartlarındaki izolasyon seviyelerini inceleyen ve Write Skew kavramını literatüre kazandıran temel makale.

PostgreSQL Wiki — SSI – PostgreSQL’in Serializable Snapshot Isolation uygulamasının teknik detayları ve SIREAD kilit mekanizması.


İletişim & Geri Bildirim

Okuduğunuz için teşekkür ederim. Yazıyla ilgili görüşlerinizi, sorularınızı veya geri bildirimlerinizi [email protected] e-posta adresi üzerinden ya da LinkedIn üzerinden doğrudan iletebilirsiniz.