Jepsen ve Dağıtık Sistem Gerçekleri
Jepsen ve Dağıtık Sistem Gerçekleri
Dağıtık sistemler çoğu zaman kağıt üzerinde kusursuz görünür. Replikasyon vardır, quorum vardır, leader election vardır, retry vardır. Fakat gerçek dünyada ağ bölünür, disk yavaşlar, saatler kayar, node'lar yarım kapanır, eski leader geri döner ve sistem dokümantasyonda vadettiği garantileri her zaman sağlayamayabilir.
Jepsen bu iddiaları gerçek hata senaryoları altında sınayan bir test yaklaşımıdır. Özellikle veritabanları, kuyruklar, consensus sistemleri, distributed log yapıları ve koordinasyon servisleri için kullanılır.
Jepsen'in asıl değeri şudur: Sistemin "normalde çalıştığını" değil, hata koşullarında hangi garantileri gerçekten koruduğunu gösterir.
Jepsen neden önemli?
Bir sistem "strong consistency", "exactly-once delivery", "linearizable reads" veya "no data loss" dediğinde bu iddia sadece happy path için geçerli olmamalıdır.
Gerçek production ortamında şu olaylar aynı anda yaşanabilir:
- Client isteği timeout olur ama sunucu işlemi tamamlamıştır.
- Leader cevap verdikten sonra crash olur.
- İki node birbirini göremez ama client ikisine de ulaşabilir.
- Saatler birkaç saniye kayar.
- Disk yazmayı kabul eder ama fsync yapmadan node kapanır.
- Mesaj kuyruğu aynı mesajı iki kez teslim eder.
- Retry mekanizması aynı işi ikinci kez çalıştırır.
Jepsen bu tür durumları bilinçli üretir ve sonrasında sistemin geçmişini analiz eder: Hangi işlem başladı, hangisi başarı döndü, hangisi belirsiz kaldı, son veri durumu hangi garantileri ihlal ediyor?
Test edilen temel garantiler
Linearizability
Bir sistem linearizable ise, bütün işlemler sanki tek bir global sırada ve anlık gerçekleşmiş gibi görünmelidir. Kullanıcı A bir değeri yazdıktan sonra kullanıcı B daha sonra okuduğunda eski değeri görmemelidir.
Örneğin bir sayaç servisi düşünelim:
T1: write(counter = 1) -> success
T2: read(counter) -> 0
Eğer T2, T1 başarı döndükten sonra başladıysa ve hâlâ 0 okuyorsa sistem linearizability ihlali yapmış olabilir.
Serializability
Transaction'lar eşzamanlı çalışsa bile sonuç, sanki transaction'lar tek tek bir sırada çalışmış gibi olmalıdır.
Klasik hata örneği lost update'tir:
Başlangıç bakiyesi: 100
T1: bakiye oku -> 100
T2: bakiye oku -> 100
T1: +10 yaz -> 110
T2: +20 yaz -> 120
Beklenen: 130
Gerçek: 120
İki işlem de başarı döndüyse ama biri diğerinin sonucunu ezdiyse, sistem iddia ettiği izolasyon seviyesini sağlamıyor olabilir.
Read-your-writes
Bir client yazdığı veriyi hemen sonra okuyabilmelidir. Eventual consistency kullanan sistemlerde bu garanti her zaman verilmez, ama verilmediği açıkça bilinmelidir.
client-1: POST /profile {name: "Ayse"} -> 200 OK
client-1: GET /profile -> {name: "Eski Deger"}
Bu bazı sistemlerde kabul edilebilir, bazılarında kabul edilemez. Önemli olan garanti sınırının net olmasıdır.
Hata modeli olmadan test eksiktir
Dağıtık sistem testi sadece "servis cevap veriyor mu?" testi değildir. Önce hata modeli tanımlanmalıdır.
Örnek hata modeli:
- Node kill/restart
- Network partition
- Packet delay
- Clock skew
- Disk full
- Slow fsync
- Duplicate message
- Message reorder
- Partial dependency outage
- Leader election sırasında client trafiği
Bu modeli yazılı hale getirmek bile çoğu mimari problemi erkenden görünür kılar.
Kod örneği: Timeout koymayan servis
Aşağıdaki Node.js örneği kötü bir başlangıçtır. Dış servis yavaşladığında istek sonsuza yakın bekleyebilir ve connection pool dolabilir.
// Kotu: timeout yok, retry yok, hata sinifi yok.
async function reserveStock(productId, quantity) {
const response = await fetch("https://inventory.internal/reservations", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ productId, quantity })
});
return response.json();
}
Daha iyi sürümde timeout, idempotency key ve sınırlı retry birlikte düşünülür:
import crypto from "node:crypto";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function postJsonWithTimeout(url, body, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 1500);
try {
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": options.idempotencyKey
},
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok && response.status < 500) {
throw new Error(`Permanent inventory error: ${response.status}`);
}
if (!response.ok) {
throw new Error(`Retryable inventory error: ${response.status}`);
}
return response.json();
} finally {
clearTimeout(timeout);
}
}
export async function reserveStock(orderId, productId, quantity) {
const idempotencyKey = crypto
.createHash("sha256")
.update(`reserve-stock:${orderId}:${productId}`)
.digest("hex");
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await postJsonWithTimeout(
"https://inventory.internal/reservations",
{ orderId, productId, quantity },
{ timeoutMs: 1500, idempotencyKey }
);
} catch (error) {
if (attempt === 3) throw error;
await sleep(100 * attempt);
}
}
}
Bu kod Jepsen testi değildir, ama Jepsen'in öğrettiği zihniyete yakındır: Timeout olacak, retry olacak, retry duplicate yaratmayacak, başarı/belirsiz/hata durumları ayrı düşünülecek.
Kod örneği: Idempotency key
Retry olan her yerde idempotency düşünülmelidir. Ödeme, stok, rezervasyon ve event publish gibi işlemler için aynı komut iki kez gelirse sonuç değişmemelidir.
Basit bir PostgreSQL şeması:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
status TEXT NOT NULL CHECK (status IN ('processing', 'completed', 'failed')),
response JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE payments (
id UUID PRIMARY KEY,
order_id UUID NOT NULL UNIQUE,
amount_cents INTEGER NOT NULL,
status TEXT NOT NULL
);
Ödeme başlatırken:
BEGIN;
INSERT INTO idempotency_keys (key, status)
VALUES ($1, 'processing')
ON CONFLICT (key) DO NOTHING;
-- Eğer satır zaten varsa, yeni ödeme yaratma.
-- Var olan response'u dön veya processing durumunu client'a bildir.
INSERT INTO payments (id, order_id, amount_cents, status)
VALUES (gen_random_uuid(), $2, $3, 'authorized')
ON CONFLICT (order_id) DO NOTHING;
UPDATE idempotency_keys
SET status = 'completed',
response = jsonb_build_object('order_id', $2, 'status', 'authorized')
WHERE key = $1;
COMMIT;
Bu yapı tek başına mükemmel değildir; transaction isolation, lock stratejisi ve failure recovery gerekir. Ama retry sonrası çift ödeme alma riskini azaltmak için temel bir çizgi sağlar.
Kod örneği: Outbox pattern
Dağıtık sistemlerde en sık görülen hatalardan biri şudur: Veritabanına yazarsınız, sonra event yayınlarsınız. İkisinden biri başarılı diğeri başarısız olabilir.
Kötü örnek:
async function completePayment(db, broker, paymentId) {
await db.query("UPDATE payments SET status = 'completed' WHERE id = $1", [paymentId]);
// Burada process crash olursa DB guncellendi ama event kayboldu.
await broker.publish("payment_completed", { paymentId });
}
Daha güvenli yaklaşım: Aynı transaction içinde hem domain değişikliğini hem outbox kaydını yazmak.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic TEXT NOT NULL,
payload JSONB NOT NULL,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function completePayment(db, paymentId) {
await db.tx(async (tx) => {
await tx.query(
"UPDATE payments SET status = 'completed' WHERE id = $1",
[paymentId]
);
await tx.query(
`INSERT INTO outbox_events (topic, payload)
VALUES ($1, jsonb_build_object('paymentId', $2))`,
["payment_completed", paymentId]
);
});
}
Sonra ayrı bir worker outbox tablosunu yayınlar:
async function publishOutboxBatch(db, broker) {
const events = await db.query(`
SELECT id, topic, payload
FROM outbox_events
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED
`);
for (const event of events.rows) {
await broker.publish(event.topic, event.payload);
await db.query(
"UPDATE outbox_events SET published_at = now() WHERE id = $1",
[event.id]
);
}
}
Burada consumer tarafının da idempotent olması gerekir. Çünkü worker event'i yayınladıktan sonra published_at yazamadan crash olursa aynı event tekrar yayınlanabilir.
Kod örneği: Consumer deduplication
CREATE TABLE processed_events (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function handlePaymentCompleted(db, event) {
await db.tx(async (tx) => {
const inserted = await tx.query(
`INSERT INTO processed_events (event_id)
VALUES ($1)
ON CONFLICT DO NOTHING
RETURNING event_id`,
[event.id]
);
if (inserted.rowCount === 0) {
return;
}
await tx.query(
"UPDATE orders SET status = 'paid' WHERE payment_id = $1",
[event.payload.paymentId]
);
});
}
Bu örnek "exactly once" demekten daha sağlıklıdır. Çoğu sistem pratikte at-least-once teslimat verir; uygulama tarafı duplicate event'i güvenle tolere eder.
Jepsen tarzı invariant düşünmek
Jepsen testlerinde önemli kavramlardan biri invariant'tır: Sistem ne yaşarsa yaşasın asla bozulmaması gereken kural.
Örnek invariant'lar:
- Toplam para miktarı yoktan var olamaz.
- Bir sipariş hem
cancelledhemshippedolamaz. - Stok negatif olamaz.
- Başarı dönen ödeme kaybolamaz.
- Aynı idempotency key iki farklı sonuç üretemez.
Basit bir test fikri:
function assertInventoryInvariant(products) {
for (const product of products) {
if (product.available < 0) {
throw new Error(`Negative stock for product ${product.id}`);
}
const expected = product.initialStock - product.reserved - product.sold;
if (product.available !== expected) {
throw new Error(
`Stock mismatch for ${product.id}: expected ${expected}, got ${product.available}`
);
}
}
}
Bu kontrolü sadece unit testte değil, staging chaos testlerinden sonra veya production reconciliation job'larında da kullanabilirsiniz.
Production için gözlemlenebilirlik
Dağıtık sistemlerde sadece CPU, memory ve uptime izlemek yetmez. Tutarlılık ihlali sinyalleri de izlenmelidir.
Ölçülebilecek sinyaller:
- Duplicate idempotency key sayısı
- Retry attempt dağılımı
- Outbox backlog
- Event publish gecikmesi
- Consumer lag
- Reconciliation mismatch sayısı
- Leader election frekansı
- Clock skew alarmı
- Timeout oranı
- Unknown outcome sayısı
Örnek Prometheus metrik isimleri:
payment_idempotency_conflicts_total
outbox_unpublished_events
event_publish_latency_seconds
consumer_duplicate_events_total
order_reconciliation_mismatches_total
distributed_lock_clock_skew_seconds
Pratik kontrol listesi
- Her network çağrısında timeout var mı?
- Retry edilen her operasyon idempotent mi?
- Başarı dönen yazma işlemi hangi noktada gerçekten kalıcı sayılıyor?
- Client timeout olduğunda işlem sonucu nasıl öğreniliyor?
- Sistem ağ bölünmesi sırasında CP mi AP mi davranıyor?
- Leader değişimi sırasında eski leader yazmaya devam edebiliyor mu?
- Mesaj kuyruğunda duplicate, reorder ve delay senaryoları test edildi mi?
- Clock skew olduğunda lock, token ve lease davranışı bozuluyor mu?
- Veri tutarsızlığını sonradan bulacak reconciliation mekanizması var mı?
- Monitoring sadece uptime'ı mı ölçüyor, yoksa tutarlılık ihlallerini de görüyor mu?
Özet
Jepsen bize şunu hatırlatır: Dağıtık sistemlerde doğru çalışmak, sadece normal durumda cevap dönmek değildir. Doğru çalışmak, hata koşullarında bile hangi garantileri verdiğini bilmek ve bunu test edebilmektir.
Bir sistem için "network partition olursa ne olur?", "başarı döndükten sonra crash olursa ne olur?", "aynı mesaj iki kez gelirse ne olur?" sorularına net cevap veremiyorsanız, o sistemin gerçek davranışını henüz bilmiyorsunuz demektir.