Dağıtık Sistemler · Bölüm 13

Saga, Transactional Outbox ve Idempotency: servisler arası tutarlılık

Servis başına veritabanı olan bir sistemde 2PC olmadan tutarlılığı Saga, Transactional Outbox, idempotent consumer ve idempotency key ile nasıl sağlayacağını, flash sale örnek projesi üzerinden öğretir.

~50 dk okumaQuiz: q14, q18
Cevaplar:

Bu bölümde

Her servisin kendi veritabanı olduğu bir mimaride "sipariş oluştu, ödeme alındı, stok düştü" adımlarını tek bir ACID transaction'a sokamazsın. Bu bölümde bunun yerine kullanılan araçları öğreneceksin: Saga ile iş akışını lokal transaction'lara bölmek, Transactional Outbox ile DB yazısı ve event'i aynı kadere bağlamak, idempotent consumer ve idempotency key ile tekrarlara dayanıklı olmak.

Mülakatta bu konu mini tasarım sorusu ("ödeme alındı ama stok yok, ne yaparsın"), kod okuma (@Transactional içinde kafkaTemplate.send) ve flash sale gibi bir system design sorusunun alt başlığı olarak gelir. Bölümün sonunda flash-sale-outbox-demo projesinin her sınıfını bu kavramlarla açıklayabileceksin.

Konu anlatımı

Problem: servis başına veritabanı

Monolitte sipariş akışı tek bir transaction'dır: stok düş, sipariş yaz, ödeme kaydı oluştur; biri fail ederse hepsi rollback. Mikroservislerde Order, Payment, Inventory ve Shipment servislerinin her biri kendi veritabanına sahiptir (database per service). Bu bilinçli bir seçimdir: her servis şemasını bağımsız değiştirir, bağımsız deploy edilir ve ölçeklenir.

Bedeli, servisler arası atomikliğin kaybolmasıdır. Ödeme servisi müşteriden parayı çekip kendi DB'sine commit etti; ardından Inventory servisi stok ayıramadı. Ödemeyi "rollback" edemezsin; çünkü o transaction çoktan commit oldu, üstelik para harici bir ödeme sağlayıcısında hareket etti.

İki temel yaklaşım vardır: dağıtık transaction protokolü (2PC) ile atomikliği zorlamak ya da atomikliği bırakıp nihai tutarlılık (eventual consistency) ile her adımı telafi edilebilir yapmak (Saga).

2PC: nasıl çalışır, neden mikroservislerde tercih edilmez

Two-phase commit bir coordinator ve birden fazla participant ile çalışır:

  1. Prepare fazı: Coordinator her katılımcıya "commit edebilir misin?" diye sorar. Katılımcı işi yapar, değişikliği ve kilitleri kalıcı olarak tutar (prepare kaydını diske yazar) ve "evet" ya da "hayır" oyu verir. "Evet" diyen katılımcı artık tek başına karar veremez.
  2. Commit fazı: Herkes "evet" dediyse coordinator kararı kendi log'una yazar ve COMMIT gönderir; biri "hayır" dediyse ABORT.

Sorun, iki faz arasındaki boşluktadır. Katılımcılar "evet" oyu verdikten sonra coordinator çökerse katılımcılar in-doubt durumda kalır: commit mi abort mu olacağını bilmezler ve coordinator geri gelene kadar kilitleri bırakamazlar. Bu yüzden 2PC bloklayan bir protokoldür.

Mikroservislerde tercih edilmemesinin sebepleri:

  • Availability çarpılır. Bir işlemin başarısı tüm katılımcıların ve coordinator'ın aynı anda ayakta olmasına bağlıdır. %99,9 erişilebilir dört katılımcı birlikte yaklaşık %99,6 eder.
  • Latency ve kilit süresi uzar. En az iki tur ağ gidiş-dönüşü ve her katılımcıda kalıcı log yazımı. Kilitler en yavaş katılımcı cevap verene kadar tutulur; yük altında contention büyür.
  • Teknoloji desteği yoktur. Kafka, Redis, çoğu NoSQL ve harici ödeme sağlayıcılarının API'leri XA katılımcısı olamaz. PostgreSQL PREPARE TRANSACTION'ı destekler ama varsayılan olarak kapalıdır (max_prepared_transactions = 0).
  • Servis özerkliği bozulur. Bir servisin DB kilitleri başka bir servisin coordinator'ının sağlığına bağlanır.

2PC'nin yeri yok değildir: aynı ekibin yönettiği, aynı veri merkezindeki iki veritabanı arasında, düşük hacimli ve sıkı tutarlılık gereken işlerde hâlâ kullanılır. Mülakatta "asla" yerine "bu ölçek ve bu bağımsızlık ihtiyacında değil" demek daha doğrudur.

Saga: lokal transaction'lar ve telafi

Saga (Garcia-Molina ve Salem, 1987), uzun süren bir iş akışını her biri kendi veritabanında atomik olan lokal transaction'lar dizisine böler. Her adım commit olduktan sonra bir event veya komutla bir sonraki adımı tetikler. Bir adım fail ederse, daha önce commit olmuş adımlar telafi transaction'ları (compensating transaction) ile tersine çevrilir.

Sipariş akışı için tipik bir saga:

AdımLokal transactionTelafi
1Order: siparişi PENDING oluşturSiparişi CANCELLED yap
2Inventory: stoğu rezerve etRezervasyonu serbest bırak
3Payment: ödemeyi alÖdemeyi iade et
4Order: siparişi CONFIRMED yap(pivot sonrası)
5Shipment: kargo kaydı oluşturRetry edilir, telafi edilmez

Orchestration ve choreography

Saga iki şekilde koordine edilir.

Choreography: Merkez yoktur. Her servis bir event dinler, işini yapar ve yeni bir event yayınlar. OrderCreated → Inventory stok ayırır → StockReserved → Payment ödemeyi alır → PaymentCompleted → Order siparişi onaylar.

Orchestration: Bir orchestrator (ör. Order servisindeki OrderSaga) her adımda ilgili servise komut gönderir (ReserveStock, ChargePayment), cevabı (StockReserved, PaymentFailed) dinler ve bir sonraki adıma ya da telafiye karar verir. Saga'nın durumu orchestrator'ın DB'sinde bir state machine olarak tutulur.

ChoreographyOrchestration
Akışın görünürlüğüDağınık; akışı anlamak için tüm servislerin kodunu okumak gerekirTek yerde, state machine olarak
BağımlılıkServisler birbirinin event'lerini bilir; döngüsel bağımlılık riskiServisler sadece orchestrator'ın komutlarını bilir
Telafi mantığıHer servise dağılmışMerkezi, test edilebilir
Tek hata noktasıYokOrchestrator (ama kendisi de durumu DB'de tutan, yatay ölçeklenen bir servis)
RiskAdım sayısı arttıkça "kim neyi tetikliyor" kaybolurOrchestrator'da iş mantığı birikir ("god service")
Uygun olduğu durum2-3 adım, basit, telafisi az akışlar; bildirim, analitik gibi yan etkiler4+ adım, telafili, iş açısından kritik akışlar

Pratikte ikisi birlikte kullanılır: çekirdek sipariş akışı orchestration ile yönetilir, "sipariş onaylandı" event'ini dinleyen bildirim ve analitik servisleri choreography ile bağlanır.

Basit bir orchestrator state machine'i:

java
public enum OrderSagaState {
    STOCK_PENDING, PAYMENT_PENDING, CONFIRMED,
    COMPENSATING_STOCK, CANCELLED
}

@Service
public class OrderSagaOrchestrator {

    private final OrderSagaRepository sagaRepository;
    private final OutboxWriter outbox;   // komutları outbox'a yazar (aynı transaction)

    public OrderSagaOrchestrator(OrderSagaRepository sagaRepository, OutboxWriter outbox) {
        this.sagaRepository = sagaRepository;
        this.outbox = outbox;
    }

    @Transactional
    public void on(SagaReply reply) {
        OrderSaga saga = sagaRepository.findByOrderIdForUpdate(reply.orderId());
        // Geç gelen veya tekrar eden cevaplar: beklenen durumda değilsek yoksay (idempotency).
        if (!saga.expects(reply)) {
            return;
        }
        OrderSagaState next = switch (reply) {
            case StockReserved r -> {
                outbox.send(new ChargePayment(r.orderId(), saga.amount(), saga.paymentKey()));
                yield OrderSagaState.PAYMENT_PENDING;
            }
            case StockRejected r -> OrderSagaState.CANCELLED;
            case PaymentCompleted r -> OrderSagaState.CONFIRMED;
            case PaymentFailed r -> {
                outbox.send(new ReleaseStock(r.orderId(), saga.reservationId()));
                yield OrderSagaState.COMPENSATING_STOCK;
            }
            case StockReleased r -> OrderSagaState.CANCELLED;
        };
        saga.transitionTo(next);
    }
}

SagaReply burada sealed interface olarak tanımlanmış bir record hiyerarşisidir; switch pattern matching (Java 21+) yeni bir cevap tipi eklendiğinde derleme hatası vererek eksik dalı yakalar. Orchestrator komutları doğrudan Kafka'ya değil outbox'a yazar; böylece "durum değişti ama komut gitmedi" durumu oluşmaz.

Telafi transaction'ının özellikleri

Telafi, veritabanı rollback'i değildir; semantik ters işlemdir. Ödeme silinmez, iade edilir; muhasebede iki kayıt (çekim ve iade) kalır. Müşteri kartında da iki hareket görür. Bu yüzden telafiler iş birimiyle birlikte tasarlanır.

Bir telafi transaction'ının taşıması gereken özellikler:

  • Idempotent: "İade et" komutu iki kez gelirse iki kez iade yapılmamalı. Ödeme sağlayıcısına iade isteği orijinal ödemeden türetilen sabit bir idempotency key ile gider.
  • Retry edilebilir ve sonunda başarılı: Telafinin fail etmesi seçenek değildir; yoksa sistem tutarsız kalır. Backoff ile tekrar denenir, uzun süre başarısız kalırsa alarm üretir ve manuel müdahale kuyruğuna düşer.
  • Sırasız gelişe dayanıklı: "Stoğu serbest bırak" komutu, "stoğu rezerve et" komutundan önce ulaşabilir (ör. rezervasyon timeout'a düştü, telafi başladı, sonra geç kalan rezervasyon geldi). Inventory servisi rezervasyon id'si bazında "iptal edildi" işaretini tutup sonradan gelen rezervasyonu reddetmelidir.
  • İzlenebilir: Her telafi bir durum geçişi ve log/metrik üretir.

Adım tipleri: compensatable, pivot, retriable

  • Compensatable: Telafi edilebilen adımlar (stok rezervasyonu, ödeme ön provizyonu).
  • Pivot: Geri dönüşü olmayan nokta. Pivot commit olursa saga ileri doğru tamamlanmak zorundadır; fail ederse geri sarılır. Örnek: ödemenin capture edilmesi veya siparişin kargoya teslim edilmesi.
  • Retriable: Pivot'tan sonra gelen, telafisi olmayan ama mutlaka başarılı olacak şekilde retry edilen adımlar (kargo kaydı oluşturma, fatura isteği). Bunlar idempotent olmalıdır.

Tasarım kuralı: ucuz telafisi olan adımları öne, pahalı veya telafisiz adımları sona koy. Stoğu ödemeden önce rezerve etmek, q14'teki "ödeme alındı ama stok yok" senaryosunu büyük ölçüde ortadan kaldırır; stok rezervasyonunu bırakmak ödeme iadesinden çok daha ucuzdur.

İzolasyon eksikliği ve karşı önlemler

Saga ACID'deki I'yı (izolasyon) sağlamaz: bir saga'nın ara durumları diğer saga'lara ve kullanıcılara görünür. Tipik anomaliler:

  • Dirty read: Saga A stoğu rezerve etti, Saga B "kalan stok 0" görüp siparişi reddetti, sonra A telafiye girdi ve stok geri geldi. B haksız yere reddedildi.
  • Lost update: Saga A siparişi güncellerken müşterinin iptal isteği araya girdi; A'nın sonraki adımı iptali ezdi.

Karşı önlemler (countermeasures):

  • Semantic lock: Kaydı uygulama seviyesinde "işlemde" diye işaretlemek. Sipariş PAYMENT_PENDING iken iptal veya adres değişikliği isteği doğrudan uygulanmaz; reddedilir, kuyruğa alınır ya da orchestrator'a "iptal istendi" olarak iletilir.
  • Commutative updates: Sıradan bağımsız sonuç veren güncellemeler (bakiyeye +x ve -y uygulamak, "bakiyeyi z yap" yerine).
  • Pessimistic view: Adımları dirty read'in etkisini azaltacak sırada dizmek (riskli adımı sona almak).
  • Reread value: Güncellemeden önce kaydı tekrar okuyup değişmediğini doğrulamak (optimistic locking, @Version).
  • Version file: Kayda gelen işlemleri sırasıyla kaydedip sırasız gelenleri doğru sıraya koymak.

Dual write problemi

Saga'nın her adımı "DB'ye yaz ve bir event/komut yayınla" demektir. Bunun en sık yapılan yanlış hali:

java
@Transactional
public UUID placeOrder(PlaceOrderCommand command) {
    stockRepository.decrease(command.productId(), command.quantity());
    Order order = orderRepository.save(Order.create(command, clock.instant()));
    kafkaTemplate.send("order-created", order.getId().toString(), toJson(order)); // DUAL WRITE
    return order.getId();
}

DB ve Kafka iki ayrı sistemdir ve aralarında ortak bir transaction yoktur. İki başarısızlık senaryosu:

  1. Phantom event: send() metodun ortasında çalışır; mesaj Kafka'ya gider. Sonra commit fail olur (constraint ihlali, deadlock, DB bağlantısı koptu). Kargo servisi var olmayan bir sipariş için kargo kaydı oluşturur. En tehlikeli senaryodur, çünkü geri alınamayan yan etkiler başlatır.
  2. Lost event: Send'i commit'ten sonraya taşırsan ters problem çıkar: DB commit oldu, send() fail oldu ya da tam o anda pod öldü. Sipariş var ama hiçbir servisin haberi yok; sonsuza kadar CREATED durumunda kalır.

Ayrıca kafkaTemplate.send() asenkron'dur ve bir CompletableFuture döner. Sonucu kontrol edilmezse broker'a ulaşmayan mesajın hatası hiç görülmez.

@TransactionalEventListener(phase = AFTER_COMMIT) ile commit'ten sonra Kafka'ya göndermek phantom event'i çözer, lost event'i çözmez. Commit ile listener arasında pod ölürse event kaybolur. Bu hâlâ bir dual write'tır.

Transactional Outbox

Çözüm, iki yazmayı tek bir sisteme indirmektir. Event Kafka'ya değil, iş verisiyle aynı DB transaction'ında bir outbox tablosuna yazılır. Ayrı bir süreç (relay) outbox'taki kayıtları okuyup Kafka'ya taşır.

diyagram yükleniyor…
sequenceDiagram
    participant API as OrderService
    participant DB as PostgreSQL
    participant R as Relay (polling veya CDC)
    participant K as Kafka
    participant C as Consumer
    API->>DB: BEGIN
    API->>DB: UPDATE stock / INSERT orders / INSERT outbox_event
    API->>DB: COMMIT
    R->>DB: Gönderilmemiş outbox kayıtlarını al
    R->>K: send(key = aggregate_id) ve ack bekle
    R->>DB: Kaydı gönderildi işaretle (veya sil)
    K->>C: Event (1..n kez)
    C->>C: Idempotent işle

Garantiler:

  • Sipariş commit olduysa event de outbox'tadır; lost event yoktur.
  • Sipariş rollback olduysa event de rollback olur; phantom event yoktur.
  • Relay event'i gönderip "gönderildi" işaretini commit edemeden ölebilir; event tekrar gönderilir. Teslimat at-least-once'tır, bu yüzden consumer'lar idempotent olmak zorundadır.

Tablo tasarımı

sql
CREATE TABLE outbox_event (
    id             UUID         PRIMARY KEY,   -- consumer'ın idempotency anahtarı
    aggregate_type VARCHAR(64)  NOT NULL,      -- topic seçimi için
    aggregate_id   VARCHAR(64)  NOT NULL,      -- Kafka key'i: sıra bu varlık bazında korunur
    event_type     VARCHAR(128) NOT NULL,
    payload        JSONB        NOT NULL,
    created_at     TIMESTAMPTZ  NOT NULL,
    published_at   TIMESTAMPTZ                 -- NULL = gönderilmedi
);

CREATE INDEX idx_outbox_unpublished
    ON outbox_event (created_at)
    WHERE published_at IS NULL;

Tasarım kararları:

  • id UUID ve payload'da da var: Consumer duplicate'leri bu id ile ayıklar. orderId yerine ayrı bir event id kullanılır, çünkü bir siparişin birden fazla event'i olur.
  • aggregate_id Kafka key'i olur: Aynı siparişin event'leri aynı partition'a düşer.
  • Partial index: Sadece gönderilmemiş satırlar index'tedir. Tablo milyonlarca gönderilmiş satır içerse bile relay'in taradığı index küçük kalır.
  • Gerekirse trace_id / correlation_id (dağıtık izleme), schema_version (event şeması evrimi) ve headers JSONB kolonları eklenir.

Polling publisher

Relay periyodik olarak gönderilmemiş kayıtları alır:

sql
SELECT *
  FROM outbox_event
 WHERE published_at IS NULL
 ORDER BY created_at
 LIMIT 100
   FOR UPDATE SKIP LOCKED;
  • FOR UPDATE seçilen satırları kilitler; aynı satırı iki relay instance'ı aynı anda alamaz.
  • SKIP LOCKED başka bir instance'ın kilitlediği satırları beklemeden atlar; birden fazla relay paralel çalışır.

Polling basittir ve ek altyapı istemez. Bedelleri: sorgu yükü (boş tabloda bile her 500 ms'de bir sorgu), poll aralığı kadar ek gecikme ve sıralama zorlukları.

CDC: Debezium

Change Data Capture yaklaşımında relay tabloyu sorgulamaz; veritabanının transaction log'unu (PostgreSQL'de WAL, logical replication ile) okur. Debezium, Kafka Connect üzerinde çalışan bir CDC aracıdır ve outbox için hazır bir dönüşüm sunar (Outbox Event Router SMT): varsayılan olarak aggregatetype kolonuna göre topic'i (outbox.event.<aggregatetype>), aggregateid kolonuna göre Kafka key'ini seçer ve id'yi header olarak ekler.

CDC'nin avantajları:

  • Polling yükü yok, gecikme çok düşük.
  • Sıra commit sırasıdır, çünkü WAL commit sırasıyla okunur.
  • Tablo büyümez: Gunnar Morling'in Debezium blogunda anlattığı teknikle outbox satırı aynı transaction içinde insert edilip hemen silinebilir; Debezium WAL'daki INSERT'i yayınlar, DELETE'i yok sayar.

Bedeli operasyoneldir: Kafka Connect kümesi, connector yönetimi, şema değişikliklerinin takibi. PostgreSQL'e özgü bir risk de vardır: Debezium bir replication slot kullanır; connector uzun süre durursa PostgreSQL slot'un okumadığı WAL'ı silemez ve disk dolabilir. PostgreSQL 13+ max_slot_wal_keep_size ile bu birikime sınır konabilir; slot lag'i mutlaka izlenmelidir.

Sıralama tuzakları

Outbox'ta sıra, ilk bakışta göründüğünden zordur:

  1. created_at commit sırası değildir. T1 10:00:00.001'de insert eder ama 10:00:00.050'de commit olur; T2 10:00:00.010'da insert edip 10:00:00.020'de commit olur. Relay 10:00:00.030'da çalışırsa T2'yi görür, T1'i görmez; T1 bir sonraki turda gider. published_at IS NULL filtresi sayesinde kayıp olmaz, ama sıra değişir.
  2. "Son gönderilen id'den büyükleri al" yaklaşımı kayıp yaratır. Sequence değerleri insert anında verilir, commit anında değil. Relay id > 1000 diye ilerlerken 999 numaralı kayıt henüz commit olmamış olabilir; commit olduğunda relay onu bir daha hiç görmez. High-water-mark ile polling yapılacaksa bu boşluk bilinçli yönetilmelidir.
  3. Birden fazla relay instance'ı aynı siparişin event'lerini karıştırabilir. Instance A, X siparişinin OrderCreated'ını içeren batch'i, B aynı siparişin OrderCancelled'ını içeren sonraki batch'i kilitler; B daha hızlıysa iptal önce yayınlanır.

Çözümler: sıra kritikse tek aktif relay (ShedLock veya leader election ile), relay instance'larını hash(aggregate_id) % N ile shard'lamak ya da CDC. Her durumda consumer tarafında event versiyonu ile savunma eklemek iyi bir sigortadır.

Temizlik

UPDATE ... SET published_at her güncellemede PostgreSQL'de ölü bir satır versiyonu (dead tuple) üretir; yüksek hacimde outbox tablosu hızla şişer (bloat) ve autovacuum'a yük biner. Seçenekler:

  • Gönderilen kayıtları periyodik olarak küçük batch'lerle silen bir job.
  • Tabloyu zamana göre partition'lamak ve eski partition'ları DROP etmek (en ucuz silme).
  • Göndermeden hemen sonra satırı silmek (UPDATE yerine DELETE) veya CDC ile insert+delete.

Inbox ve idempotent consumer

At-least-once teslimatın kaçınılmaz sonucu, aynı mesajın birden fazla kez gelmesidir:

  • Relay gönderdi, işaretlemeyi commit edemeden öldü.
  • Consumer işi yaptı, offset commit'inden önce öldü.
  • Rebalance oldu, commit edilmemiş offset'ler başka bir consumer'a gitti.

Idempotent consumer, aynı mesajın etkisini bir kez üretir. Standart uygulama:

sql
CREATE TABLE processed_event (
    event_id     UUID        PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
java
@Transactional
public void handle(OrderCreatedEvent event) {
    // Tek bir atomik ifade: kayıt yoksa ekler (1), varsa hiçbir şey yapmaz (0).
    if (processedEventRepository.insertIfAbsent(event.eventId()) == 0) {
        return; // duplicate
    }
    shipmentService.createShipmentFor(event); // aynı transaction
}

Üç kritik nokta:

  1. Duplicate kaydı ile iş yazısı aynı transaction'da olmalı. Ayrı olursa "kargo oluştu ama işlendi işareti yok" (tekrar gelince ikinci kargo) ya da "işlendi işareti var ama kargo yok" (mesaj kayboldu) durumları oluşur.
  2. "Önce kontrol et, sonra yaz" yazılmamalı. existsById + save bir check-then-act yarışıdır: iki thread aynı anda "yok" görür, ikisi de işi yapar. INSERT ... ON CONFLICT DO NOTHING ise primary key'in unique garantisine dayanır. İki transaction aynı event_id'yi aynı anda eklemeye çalışırsa ikincisi unique index üzerinde birincinin sonucunu bekler; birinci commit olursa ikincisi 0 alır, rollback olursa ikincisi 1 alıp işi yapar.
  3. İkinci savunma hattı: doğal unique constraint. shipment.order_id UNIQUE, idempotency kodu bir gün yanlış refactor edilse bile ikinci kargo kaydını engeller.

Inbox pattern bunun bir varyantıdır: Consumer mesajı önce olduğu gibi bir inbox tablosuna (unique mesaj id ile) yazar ve offset'i commit eder; asıl işleme ayrı bir worker tarafından inbox'tan yapılır. Avantajı, Kafka tüketiminin iş mantığının hızından ayrılmasıdır; dezavantajı ek bir tablo ve worker.

Doğal idempotency bazen tabloya hiç gerek bırakmaz: upsert (INSERT ... ON CONFLICT (order_id) DO UPDATE), mutlak değer atama ("durumu SHIPPED yap" idempotenttir, "stoğu 1 azalt" değildir) veya versiyon kontrolü (UPDATE ... WHERE id = ? AND version < ?).

Tablonun temizliği: processed_event sonsuza kadar büyür. Saklama süresi, aynı mesajın tekrar gelebileceği en uzun pencereden (topic retention, DLT replay politikası) uzun seçilir; daha eski kayıtlar periyodik silinir.

DB'ye yazamayan yan etkiler: E-posta, SMS, harici API çağrısı DB transaction'ına girmez. Bunlarda PENDING durumlu bir kayıt + karşı sistemin desteklediği idempotency key kullanılır; karşı sistem idempotency desteklemiyorsa "en az bir kez" ile "en fazla bir kez" arasında bilinçli seçim yapılır (bir bildirimin iki kez gitmesi genelde kabul edilebilir, bir ödemenin iki kez çekilmesi edilemez).

API seviyesinde idempotency key

HTTP tarafında da aynı problem vardır: mobil istemci POST /orders gönderdi, cevap gelmeden bağlantı koptu. İstek sunucuda başarılı olmuş olabilir. İstemci tekrar denerse ikinci sipariş oluşur. POST idempotent bir metot değildir.

Çözüm Stripe'ın popülerleştirdiği idempotency key'dir: İstemci her mantıksal işlem için benzersiz bir anahtar (UUID v4) üretir ve Idempotency-Key header'ında gönderir. Retry'larda aynı anahtarı kullanır. Sunucu ilk isteğin sonucunu bu anahtarla saklar ve tekrar gelen isteğe aynı cevabı döner.

Stripe'ın davranışı (API v1) iyi bir referanstır: anahtarlar 255 karaktere kadar olabilir, en az 24 saat saklanır, aynı anahtar farklı parametrelerle gelirse hata döner, endpoint çalışmaya başladıktan sonraki sonuçlar (500 dahil) saklanır; validasyon hatası veya eşzamanlı çakışma durumunda sonuç saklanmaz ve istek tekrar denenebilir.

IETF'nin Idempotency-Key header taslağı (draft-ietf-httpapi-idempotency-key-header; şu an aktif olmayan bir Internet-Draft) şu durum kodlarını önerir:

DurumÖnerilen cevap
Key zorunlu ama gönderilmemiş400 Bad Request
Aynı key farklı payload ile422 Unprocessable Content
Aynı key ile ilk istek hâlâ işleniyor409 Conflict

"Farklı payload" kontrolü için isteğin kanonik halinin hash'i (fingerprint) key ile birlikte saklanır.

Tablo:

sql
CREATE TABLE idempotency_key (
    client_id       BIGINT       NOT NULL,
    idem_key        VARCHAR(255) NOT NULL,
    request_hash    CHAR(64)     NOT NULL,   -- SHA-256(kanonik request body)
    status          VARCHAR(16)  NOT NULL,   -- IN_PROGRESS | COMPLETED
    response_status INT,
    response_body   JSONB,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
    PRIMARY KEY (client_id, idem_key)        -- key istemci bazında benzersiz
);

Key'in istemci (kullanıcı, API client) bazında benzersiz tutulması önemlidir; aksi halde iki farklı müşterinin aynı UUID'yi üretmesi (pratikte çok düşük ihtimal) veya kötü niyetli bir istemcinin başkasının key'ini tahmin etmesi başkasının cevabını görmesine yol açar.

Tasarım A: tek transaction. Key satırı, iş yazısı ve cevap aynı DB transaction'ında yazılır.

java
@Service
public class IdempotentOrderService {

    private final IdempotencyKeyRepository keys;
    private final OrderService orderService;

    public IdempotentOrderService(IdempotencyKeyRepository keys, OrderService orderService) {
        this.keys = keys;
        this.orderService = orderService;
    }

    @Transactional
    public StoredResponse placeOrder(long clientId, String idemKey, PlaceOrderCommand command) {
        String hash = RequestHash.sha256(command);

        // Aynı key ile eşzamanlı ikinci istek bu INSERT'te, birincinin commit/rollback'ini bekler.
        if (keys.insertIfAbsent(clientId, idemKey, hash) == 0) {
            IdempotencyKey existing = keys.find(clientId, idemKey);
            if (!existing.requestHash().equals(hash)) {
                throw new IdempotencyKeyReusedException(idemKey);   // -> 422
            }
            return existing.storedResponse();                       // ilk cevabı aynen dön
        }

        UUID orderId = orderService.placeOrder(command);            // REQUIRED: aynı transaction
        StoredResponse response = StoredResponse.created(orderId);
        keys.complete(clientId, idemKey, response);
        return response;
    }
}

Bu tasarımda eşzamanlı ikinci istek kendiliğinden doğru davranır: unique index üzerinde bekler, birinci commit olunca 0 alır ve tamamlanmış cevabı okur. Birinci rollback olursa (ör. stok yetersiz) key satırı da geri alınır ve ikinci istek işi baştan yapar. Bedeli: ikinci istek birincinin süresi kadar bekler ve iş mantığında uzun süren bir harici çağrı varsa DB transaction'ı uzar.

Tasarım B: fazlara bölünmüş. Key satırı önce IN_PROGRESS olarak ayrı bir transaction'da commit edilir. Eşzamanlı ikinci istek satırı IN_PROGRESS görür ve hemen 409 + Retry-After alır. İş bitince satır COMPLETED yapılır. Harici çağrılar (ödeme sağlayıcısı) içeren akışlar için uygundur; ama "IN_PROGRESS'te kalıp ölen" istekler için bir kurtarma mekanizması (lease süresi, locked_at ile yeniden sahiplenme, ara kurtarma noktaları) gerekir. Brandur Leach'in Stripe tarzı idempotency key yazısı bu tasarımı PostgreSQL ile ayrıntılı anlatır.

Karar verilmesi gereken sorular:

  • Hangi sonuçlar saklanır? Kalıcı iş sonucu (201, 409 stok yok) saklanabilir; geçici altyapı hataları (503) saklanırsa istemci retry ile asla başarıya ulaşamaz. Stripe v1 bir uç noktayı çalışmaya başlattıktan sonra 500'ü de saklar; kendi sisteminde bunu bilinçli seçmelisin.
  • Saklama süresi (ör. 24 saat) ve temizlik job'ı.
  • Key nerede saklanır? İş verisiyle aynı DB'de saklamak tek transaction avantajı verir. Redis'te SET key value NX EX 86400 hızlıdır ama iş yazısıyla atomik değildir; sadece ön filtre olarak kullanılmalıdır.

Flash sale'de stok düşme seçenekleri

Flash sale'in özü: çok az stok (ör. 100 adet), çok fazla eşzamanlı istek (ör. 50.000), çok sayıda pod. Gereksinim: asla fazla satmamak (overselling), stok bitince hızlı reddetmek, DB'yi çökertmemek.

1. DB'de koşullu UPDATE.

sql
UPDATE stock
   SET quantity = quantity - :qty
 WHERE product_id = :productId
   AND quantity >= :qty;

Etkilenen satır sayısı 1 ise stok düştü, 0 ise yetersiz. Neden güvenli: PostgreSQL güncelleyeceği satırı kilitler. Aynı satırı güncellemek isteyen ikinci transaction birincinin bitmesini bekler; READ COMMITTED'da birinci commit olunca satırın güncel versiyonunu alıp WHERE koşulunu yeniden değerlendirir. Stok yetmiyorsa 0 satır günceller. "SELECT ile oku, Java'da kontrol et, UPDATE ile yaz" ise iki ayrı adım olduğu için yarış içerir. REPEATABLE READ veya SERIALIZABLE'da ikinci transaction beklemenin sonunda serialization hatası alır ve uygulamanın retry etmesi gerekir. Son savunma hattı olarak CHECK (quantity >= 0) constraint'i eklenir.

Bedeli hot row'dur: tüm istekler aynı satırın kilidi için sıraya girer. Throughput, kilidin tutulduğu süreyle sınırlıdır: kilit transaction sonuna kadar tutulur; transaction 2 ms sürüyorsa o ürün için teorik tavan saniyede yaklaşık 500 başarılı güncellemedir. Bekleyen istekler DB connection'larını da tutar; havuz tükenince diğer ürünlerin siparişleri de etkilenir.

İyileştirmeler:

  • Kilidi geç al: Stok UPDATE'ini transaction'ın sonuna yakın yap (önce sipariş ve outbox insert'leri, en son stok düşümü). Kilit, insert'ler ve iş mantığı süresince tutulmaz. Stok yetersizse insert'ler zaten rollback olur.
  • Stok bucket'ları: 100 stoğu 10 satıra 10'ar böl; istek rastgele bir bucket'tan düşer, boşsa diğerlerini dener. Contention 10'a bölünür; "tükendi" tespiti ve kalan stoğun gösterimi karmaşıklaşır.
  • Kısa transaction'lar ve uygun lock_timeout: Kilit beklemesi sonsuz olmasın.

2. Redis + Lua ile atomik rezervasyon, asenkron kalıcılaştırma.

Redis komutları tek thread'de işler; bir Lua script'i çalışırken araya başka komut giremez. "Oku, kontrol et, düş" üçlüsü tek script'te atomiktir:

lua
local current = tonumber(redis.call('GET', KEYS[1]) or '-1')
local requested = tonumber(ARGV[1])
if current < requested then
  return 0
end
redis.call('DECRBY', KEYS[1], requested)
return 1

Bellek içi bu işlem DB satır kilidinden çok daha yüksek throughput verir. Ama Redis'in kalıcılığı DB kadar güçlü değildir: AOF everysec ile son bir saniyelik yazılar kaybolabilir; replikasyon asenkron olduğu için failover'da yeni master son düşümleri görmemiş olabilir ve fazla satış mümkün olur. Ayrıca Redis'te stok düşüp ardından DB'ye sipariş yazımı fail ederse Redis'e stok iade edilmelidir (telafi).

Bu yüzden Redis tek başına doğruluk kaynağı yapılmaz. Sağlam tasarım katmanlıdır:

  1. Kampanya başlamadan stok Redis'e yüklenir.
  2. İstek önce Redis'te Lua ile düşülür; 0 dönerse istek DB'ye hiç gitmeden reddedilir. 50.000 isteğin 49.900'ü burada eler.
  3. Geçen istekler DB'de koşullu UPDATE + sipariş + outbox ile kalıcılaşır; DB son hakem olarak kalır.
  4. DB yazımı fail ederse Redis'e stok geri eklenir; periyodik bir reconciliation job Redis ile DB'yi karşılaştırır.

Redlock gibi dağıtık kilitlere gerek yoktur: kilit değil, atomik bir "kontrol et ve düş" işlemi gerekir. Redlock'un saat kayması ve GC duraklamalarına karşı doğruluk garantisi olmadığı (Martin Kleppmann'ın eleştirisi) da ayrı bir sebeptir.

3. Kuyruk veya token.

  • Token havuzu: Stok kadar token önceden üretilir (Redis listesi, LPOP). Token alan istek siparişe devam eder, alamayan reddedilir. Token'ın süresi (TTL) dolarsa, ör. ödeme 10 dakikada tamamlanmazsa, token havuza geri döner.
  • Tek yazar kuyruğu: İstekler ürün id'si key'iyle bir Kafka topic'ine yazılır, kullanıcıya 202 Accepted dönülür. O ürünün partition'ını tek bir consumer sırayla işler; stok düşümünde contention yoktur (single-writer). Sonuç kullanıcıya polling, WebSocket veya push bildirimi ile iletilir.
  • Sanal bekleme odası: Kullanıcılar ön tarafta sıraya alınır ve sisteme kontrollü hızda alınır.

Karşılaştırma:

SeçenekDoğrulukThroughputKarmaşıklıkKullanıcı deneyimi
DB koşullu UPDATEÇok güçlü (ACID)Hot row ile sınırlıDüşükSenkron, net cevap
Redis Lua + DBRedis ön filtre, DB hakem; reconciliation gerekirÇok yüksekOrtaSenkron, net cevap
Kuyruk / tek yazarGüçlü (sıralı işleme)Yüksek, sabit hızdaYüksekAsenkron, "işleniyor" ekranı
Token / bekleme odasıGüçlüYüksekOrtaAdil sıralama, bekleme

Gerçek sistemlerde stok çoğu zaman doğrudan düşülmez, rezerve edilir: sipariş anında rezervasyon oluşturulur, ödeme belli bir süre içinde gelmezse rezervasyon serbest bırakılır. Bu, Saga'nın telafi adımının stok tarafındaki karşılığıdır.

Örnek proje: flash-sale-outbox-demo

Proje (Spring Boot 3.5, Java 21, PostgreSQL 16, Kafka 3.9 KRaft, Redis 7) yukarıdaki kavramları uçtan uca, testlerle kanıtlanmış şekilde uygular. Akış:

diyagram yükleniyor…
sequenceDiagram
    participant C as OrderController
    participant OS as OrderService
    participant DB as PostgreSQL
    participant R as OutboxRelay
    participant K as Kafka
    participant OC as OrderCreatedConsumer
    participant H as OrderCreatedHandler
    C->>OS: placeOrder(command)
    Note over OS,DB: Tek transaction
    OS->>DB: UPDATE stock ... WHERE quantity >= :qty
    OS->>DB: INSERT orders
    OS->>DB: INSERT outbox_event
    OS-->>C: 201 + orderId
    loop her 500 ms
        R->>DB: SELECT ... FOR UPDATE SKIP LOCKED
        R->>K: send(key = orderId).get()
        R->>DB: published_at = now (commit)
    end
    K->>OC: OrderCreated (1..n kez)
    OC->>H: handle(event) (ayrı bean, proxy üzerinden)
    H->>DB: INSERT processed_event ON CONFLICT DO NOTHING
    H->>DB: INSERT shipment (sadece ilk seferde)

Sipariş tarafı (yazma yolu):

  • OrderController: HTTP DTO'larını (PlaceOrderRequest, @Positive validasyonu) domain komutuna (PlaceOrderCommand) çevirir, 201 Created döner. Yorumunda production için Idempotency-Key ihtiyacı not edilmiş; projede API idempotency'si bilinçli olarak yok.
  • PlaceOrderCommand: Compact constructor'da invariant kontrolü yapan bir record. Controller dışından (consumer, batch) çağrılsa bile geçersiz komut oluşamaz.
  • OrderService.placeOrder: Çözümün kalbi. @Transactional giriş metodunda; içinde sırasıyla stockReservationService.reserve(...), orderRepository.save(...) ve outboxEventRepository.save(...). Kafka'ya hiç dokunmaz. Üç yazma birlikte commit ya da birlikte rollback olur. toJson, checked JsonProcessingException'ı IllegalStateException'a sarar; çünkü Spring varsayılan olarak checked exception'da commit eder ve bu durumda sipariş yazılır ama event yazılmazdı.
  • StockReservationService.reserve: @Transactional(propagation = MANDATORY). Transaction dışından çağrılırsa IllegalTransactionStateException fırlatır; stok düşümünün tek başına commit olup siparişten kopması engellenir. Güncellenen satır 0 ise InsufficientStockException (RuntimeException, dolayısıyla tam rollback).
  • StockRepository.decrease: Koşullu JPQL UPDATE. @Modifying(flushAutomatically = true, clearAutomatically = true): bekleyen değişiklikleri UPDATE'ten önce yazar, sonra persistence context'i temizler; bulk UPDATE first-level cache'i atladığı için eski Stock nesnesinin okunması engellenir.
  • V1__init.sql: CHECK (quantity >= 0) son savunma hattı; outbox_event partial index; processed_event primary key; shipment.order_id UNIQUE.
  • GlobalExceptionHandler: InsufficientStockException → 409 Conflict, RFC 9457 ProblemDetail formatında.
  • AssignedIdEntity: UUID'yi uygulama ürettiği için Spring Data save() her seferinde merge (önce SELECT) yapardı; Persistable.isNew() ile yeni nesneler doğrudan persist edilir, fazladan bir round-trip kalkar.
  • OutboxEvent: payload Java'da String, DB'de JSONB; @ColumnTransformer(write = "?::jsonb") cast'i ekler. markPublished dirty checking ile UPDATE'e dönüşür.

Relay:

  • OutboxEventRepository.lockNextBatch: FOR UPDATE SKIP LOCKED ile partial index'i kullanan native sorgu.
  • OutboxRelay.publishPending: @Scheduled(fixedDelay) + @Transactional. fixedDelay sayesinde aynı JVM'de çalışmalar üst üste binmez. Her event kafkaTemplate.send(topic, aggregateId, payload).get(5 sn) ile gönderilir; broker onayı beklenmeden published_at yazılsaydı başarısız bir gönderim "gönderildi" sayılır ve event kaybolurdu. Herhangi bir hata OutboxPublishException ile transaction'ı rollback eder; batch'in tamamı bir sonraki turda tekrar gönderilir (at-least-once).

Consumer tarafı:

  • KafkaConfig: order-created topic'i 3 partition; DefaultErrorHandler + DeadLetterPublishingRecoverer + FixedBackOff(1000, 3) (toplam 4 deneme), IllegalArgumentException retry edilmez.
  • OrderCreatedConsumer: Sadece JSON'u parse eder ve OrderCreatedHandler'a devreder. Parse hatasını IllegalArgumentException'a sarar ki retry edilmeden DLT'ye gitsin.
  • OrderCreatedHandler.handle: @Transactional. processedEventRepository.insertIfAbsent(eventId) 0 dönerse duplicate'tir, atlanır; değilse ShipmentService.createShipmentFor (MANDATORY) aynı transaction'da kargo kaydı oluşturur.

Consumer'daki self-invocation düzeltmesi. Neden iki ayrı sınıf? İlk akla gelen tek sınıflı hal şudur:

java
@Component
public class OrderCreatedConsumer {

    @KafkaListener(topics = "order-created", groupId = "shipment-service")
    public void onMessage(String payload) {
        handle(parse(payload));          // aslında this.handle(...)
    }

    @Transactional
    public void handle(OrderCreatedEvent event) {
        if (processedEventRepository.insertIfAbsent(event.eventId()) == 0) {
            return;
        }
        shipmentService.createShipmentFor(event);
    }
}

Spring @Transactional'ı bir proxy ile uygular; transaction, çağrı proxy'den geçtiğinde açılır. onMessage içinden handle(...) çağrısı this.handle(...)'dır ve proxy'yi atlar: @Transactional sessizce etkisiz kalır. Sonuçlar:

  • insertIfAbsent ve kargo kaydı ayrı ayrı (her repository çağrısı kendi kısa transaction'ında) commit olur; "ya hep ya hiç" garantisi kaybolur. Kargo yazımı fail ederse processed_event kaydı kalır; mesaj tekrar geldiğinde duplicate sanılıp atlanır ve kargo hiç oluşmaz.
  • Bu projede ShipmentService.createShipmentFor MANDATORY olduğu için hata sessiz kalmaz: transaction olmadığından IllegalTransactionStateException fırlar. MANDATORY burada yanlış yapılandırmayı erken yakalayan bir sigortadır.

Düzeltme: iş mantığını ayrı bir bean'e (OrderCreatedHandler) taşımak. Consumer handler'ı constructor injection ile alır, çağrı proxy'den geçer ve transaction gerçekten açılır. Alternatifler (@Lazy self-injection, TransactionTemplate, AspectJ weaving) çalışır ama ayrı bean en okunur olanıdır.

Redis yolu: RedisStockService + decrease_stock.lua hot SKU alternatifini gösterir ama bilerek OrderService'e bağlanmamıştır. RedisScript ilk çağrıda EVALSHA dener, script yoksa EVAL ile gönderir; KEYS ve ARGV'nin ayrı geçilmesi Redis Cluster'da doğru node'un seçilmesi için gereklidir.

Testler:

  • OrderServiceIntegrationTest (Testcontainers + PostgreSQL): stok yetersizken stok, sipariş ve outbox'un hiç değişmediğini; 16 thread'den gelen 50 eşzamanlı istekte 10 stoktan tam 10 sipariş ve 10 outbox kaydı oluştuğunu; reserve'ün transaction dışından çağrılınca reddedildiğini kanıtlar.
  • OrderCreatedHandlerIntegrationTest: aynı event'in 3 kez ardışık ve 20 kez eşzamanlı gelmesinde tek kargo kaydı oluştuğunu kanıtlar.
  • RedisStockServiceIntegrationTest: 500 eşzamanlı istek, 50 stok, tam 50 başarı; kilit yok.

Projede geliştirilebilecek noktalar (mülakatta "production'da ne değiştirirdin?" cevabı):

  1. DLT adı. KafkaConfig'teki yorum DLT'yi order-created.DLT olarak anıyor. Proje Spring Boot 3.5 (Spring Kafka 3.3.x) kullandığı için varsayılan ad order-created-dlt'dir. DLT adını resolver ile açıkça vermek ve DLT topic'ini kaynakla en az aynı partition sayısıyla NewTopic olarak tanımlamak gerekir.
  2. Geniş not-retryable sınıfı. IllegalArgumentException'ı retry dışı bırakmak, kodun derinliklerinden gelen ilgisiz bir IllegalArgumentException'ı da doğrudan DLT'ye yollar. Daha iyisi özel bir MalformedEventException veya JSON'u ErrorHandlingDeserializer + JsonDeserializer'a bırakmak (deserialization hataları zaten fatal sayılır).
  3. Relay'in transaction süresi. Kafka gönderimi açık bir DB transaction'ı içinde yapılıyor. En kötü durumda 100 event × 5 sn timeout = 500 sn boyunca bir connection ve satır kilitleri tutulur. Ayrıca Kafka erişilemezken send() çağrısının kendisi metadata için max.block.ms (varsayılan 60 sn) kadar bloklayabilir; 5 saniyelik get timeout'u bunu sınırlamaz. Relay producer'ında max.block.ms ve delivery.timeout.ms düşük tutulmalı.
  4. Relay throughput'u. Her event için get() ile tek tek beklemek throughput'u ağ gidiş-dönüşüne bağlar. Batch'teki tüm send()'ler önce yapılıp sonra future'lar birlikte beklenebilir; idempotent producer ve max.in.flight <= 5 ile partition içi sıra korunur.
  5. Çoklu relay ve sıra. Birden fazla pod'da relay çalışırsa aynı siparişin event'leri farklı sırayla yayınlanabilir (README'de de not edilmiş). Sıra kritikse tek aktif relay veya CDC.
  6. Kilit süresi. placeOrder stok UPDATE'ini ilk adımda yapıyor; satır kilidi iki insert ve commit boyunca tutuluyor. Flash sale'de UPDATE'i sona almak kilit süresini kısaltır.
  7. Lua script'in -1 davranışı. Key yoksa current = -1 olur ve istek "stok yetersiz" ile aynı sonucu (0) alır. Kampanya öncesi stok Redis'e yüklenmezse tüm siparişler sessizce reddedilir. Ayrı bir dönüş kodu (ör. -1) ile "stok yüklenmemiş" durumunu ayırt etmek ve alarm üretmek gerekir.
  8. Temizlik ve gözlemlenebilirlik. Outbox ve processed_event için temizlik job'ı; outbox lag (en eski gönderilmemiş kaydın yaşı), DLT sayısı ve duplicate oranı metrikleri.

Quiz'deki sorular: çözüm ve nedenleri

q14: Ödeme alındı, stok yok: dağıtık transaction

Senaryo. Order → Payment → Inventory → Shipment; her servisin kendi DB'si var, iletişim Kafka üzerinden. Ödeme başarıyla alındı ama Inventory stok ayıramadı. Soru dört parçalı: neden 2PC değil, choreography mi orchestration mı, telafi adımları ve özellikleri, güvenilir yayın ve tekrarlara dayanıklılık için hangi pattern'ler.

1) Neden 2PC değil? 2PC prepare fazında tüm katılımcıların kilitlerini tutar ve coordinator kararını bekletir; coordinator çökerse katılımcılar in-doubt kalır (bloklayan protokol). Availability katılımcıların çarpımına düşer, latency ve kilit süreleri en yavaş katılımcıya bağlanır. Kafka ve harici ödeme sağlayıcıları XA katılımcısı olamaz. Servislerin DB'lerini birbirine kilitlemek servis özerkliğini bozar.

2) Choreography mi orchestration mı? Dört adımlı, telafili ve para içeren bir akış için orchestration. Akış ve telafi mantığı tek bir state machine'de görünür ve test edilir; "ödeme alındı ama stok yok" gibi bir durumda kimin ne yapacağı tek yerde yazılıdır. Choreography'de bu bilgi dört servise dağılır. Bildirim ve analitik gibi yan etkiler choreography ile kalabilir.

3) Telafi akışı:

  1. Inventory StockReservationFailed yayınlar.
  2. Orchestrator saga'yı COMPENSATING_PAYMENT durumuna alır ve Payment'a RefundPayment komutu gönderir.
  3. Payment, sağlayıcıda iadeyi orijinal ödemeden türetilmiş sabit bir idempotency key ile yapar ve PaymentRefunded yayınlar.
  4. Orchestrator siparişi CANCELLED yapar; müşteriye bildirim gider.

Telafilerin özellikleri: semantik ters işlem (silme değil iade), idempotent, sonunda başarılı olana kadar retry edilebilir (uzun süre başarısızsa alarm ve manuel kuyruk), sırasız gelişe dayanıklı. Ara durumlar (PAYMENT_PENDING, STOCK_PENDING) semantic lock görevi görür: sipariş yarım haldeyken müşterinin iptal isteği doğrudan uygulanmaz. Her adıma timeout konur; cevap gelmeyen saga'lar periyodik bir job ile bulunup telafiye alınır.

4) Güvenilirlik: Her servis event ve komutlarını transactional outbox ile yayınlar (dual write yok). Consumer'lar idempotent'tir (processed_event + unique constraint). Event'ler key = orderId ile aynı partition'a gider. Saga durumu, takılı saga sayısı, DLT ve outbox lag'i izlenir.

Daha iyi tasarım: Asıl iyileştirme sıralamadadır. Stok ödemeden önce rezerve edilirse (Reserve stock → Charge payment → Confirm) "ödeme alındı ama stok yok" durumu neredeyse ortadan kalkar; ödeme fail ederse telafi sadece rezervasyonu bırakmaktır, ki bu ödeme iadesinden çok daha ucuz ve müşteriye görünmezdir. Bunu söylemek, soruyu "nasıl telafi ederim"den "telafiye nasıl daha az ihtiyaç duyarım"a taşır.

Production karşılığı: Saga motoru olarak basit durumlarda DB'deki bir state machine yeterlidir. Uzun süren, çok adımlı, insan onayı içeren akışlarda Temporal veya Camunda gibi workflow motorları durum yönetimini, timeout'ları ve retry'ları hazır sunar.

q18: Stok yetersizken ne sipariş ne event yazılmalı

Senaryo. OrderService.placeOrder sırasıyla stockReservationService.reserve(...), orderRepository.save(...) ve outboxEventRepository.save(...) çağırıyor. reserve void döner ve stok yetmezse InsufficientStockException fırlatır. İstenen: (1) JUnit 5 + Mockito ile "stok yetersizse exception fırlamalı, ne sipariş ne outbox kaydı yapılmalı" unit testi, (2) hangi davranışın Testcontainers ile entegrasyon testi olarak yazılması gerektiği.

Bu davranışın arkasındaki tasarım. "Stok yoksa ne sipariş ne event" garantisi iki katmandan gelir:

  1. Kontrol akışı: reserve exception fırlattığında sonraki satırlar hiç çalışmaz.
  2. Transaction: Stok UPDATE'i, sipariş INSERT'i ve outbox INSERT'i aynı DB transaction'ındadır; InsufficientStockException bir RuntimeException olduğu için transaction tamamen rollback olur. Stok düşümü koşullu UPDATE ile atomik yapılır; reserve MANDATORY olduğu için tek başına commit olamaz.

Kontrol akışı unit test ile, transaction ve atomik UPDATE ise sadece gerçek bir veritabanıyla doğrulanabilir.

1) Unit test:

java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock StockReservationService stockReservationService;
    @Mock OrderRepository orderRepository;
    @Mock OutboxEventRepository outboxEventRepository;

    private OrderService orderService;

    @BeforeEach
    void setUp() {
        var clock = Clock.fixed(Instant.parse("2026-10-01T10:00:00Z"), ZoneOffset.UTC);
        orderService = new OrderService(stockReservationService, orderRepository,
                outboxEventRepository, new ObjectMapper(), clock);
    }

    @Test
    void insufficientStock_persistsNeitherOrderNorEvent() {
        // reserve void döndüğü için when(...).thenThrow(...) derlenmez; doThrow kullanılır.
        doThrow(new InsufficientStockException(42L, 2))
                .when(stockReservationService).reserve(42L, 2);

        assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderCommand(42L, 2)))
                .isInstanceOf(InsufficientStockException.class);

        verifyNoInteractions(orderRepository, outboxEventRepository);
    }
}

Noktalar: void metotta stub için doThrow(...).when(mock).method(...); exception'ı assertThatThrownBy (AssertJ) veya assertThrows (JUnit) ile doğrulamak; "hiçbir şey yazılmadı" iddiasını verifyNoInteractions (veya verify(repo, never()).save(any())) ile kurmak; Clock.fixed ile zamanı deterministik yapmak. OrderService constructor injection kullandığı için @InjectMocks'a gerek yoktur; nesneyi elle kurmak hangi bağımlılığın verildiğini açık yapar.

Bu testin sınırı da bilinmelidir: Test, çağrıların sırasına bağlıdır. Biri kodu "önce sipariş kaydet, sonra stok düş" diye değiştirirse test kırılır; oysa transaction sayesinde davranış hâlâ doğrudur. Tersine, biri @Transactional'ı silerse bu test geçmeye devam eder ama sistem bozulur. Yani unit test kontrol akışını korur, asıl garantiyi korumaz.

2) Entegrasyon testine kalanlar:

  • Rollback: Stok yetersizken DB'de stok değişmedi, orders ve outbox_event boş. Bu, @Transactional proxy'sinin gerçekten devrede olduğunu ve exception'ın rollback'e yol açtığını kanıtlar.
  • Eşzamanlılık: 10 stok ve 50 eşzamanlı istek ile tam 10 sipariş ve 10 outbox kaydı; quantity 0. Bu, koşullu UPDATE'in PostgreSQL'in satır kilidi ve READ COMMITTED yeniden değerlendirmesiyle gerçekten atomik olduğunu kanıtlar.
  • Propagation: reserve'ün transaction dışından çağrılınca IllegalTransactionStateException fırlattığı.
  • PostgreSQL'e özgü SQL: FOR UPDATE SKIP LOCKED, ON CONFLICT DO NOTHING, JSONB cast.

Bunlar için H2 yeterli değildir; kilitleme ve PostgreSQL'e özgü sözdizimi farklı çalışır veya hiç desteklenmez. Testcontainers ile gerçek PostgreSQL kullanılır (Spring Boot 3.1+ @ServiceConnection; singleton container ile tüm test sınıfları tek container paylaşır). Projedeki OrderServiceIntegrationTest bu üç testi içerir.

Üç seviyede cevap

Soru: "Sipariş oluşunca kargo servisine Kafka ile haber vermek istiyorsun. Nasıl yaparsın?"

OrderService'te siparişi kaydettikten sonra kafkaTemplate.send ile OrderCreated event'ini gönderirim. Metodu @Transactional yaparım, hata olursa her şey geri alınır.

@Transactional sadece DB'yi kapsar; Kafka gönderimi transaction'ın parçası değildir. Send commit'ten önce yapılırsa ve commit fail olursa var olmayan bir sipariş için event gider; commit'ten sonra yapılırsa ve gönderim fail olursa event kaybolur. Bu dual write problemi. Transactional Outbox kullanırım: event'i siparişle aynı transaction'da outbox_event tablosuna yazarım, bir scheduler bu tabloyu okuyup Kafka'ya gönderir ve gönderildi diye işaretler. Teslimat at-least-once olduğu için kargo servisi idempotent olmalı.

Dual write'ı outbox ile çözerim; asıl iş, outbox'ın ayrıntılarındadır:

  • Relay seçimi: Başlangıçta FOR UPDATE SKIP LOCKED ile polling yeterli ve ek altyapı istemez. Hacim büyüyünce veya sıra kritikleşince Debezium CDC: WAL'dan commit sırasıyla okur, polling yükü yok. Bedeli Kafka Connect işletmek ve replication slot'u izlemek; slot takılırsa PostgreSQL WAL biriktirir ve disk dolabilir.
  • Sıra: Event'leri aggregate_id key'iyle gönderirim. Polling'de birden fazla relay instance'ı aynı siparişin event'lerini karıştırabilir; tek aktif relay (ShedLock) veya shard'lama. "Son id'den büyükleri al" yaklaşımını kullanmam; sequence'lar commit sırasıyla gelmediği için event kaçırır.
  • Relay'in kendisi: Broker onayını bekleyip öyle işaretlerim. Kafka gönderimini uzun bir DB transaction'ı içinde yapmam ya da batch'i küçük, max.block.ms'i kısa tutarım.
  • Tüketici: processed_event + iş yazısı aynı transaction'da, ON CONFLICT DO NOTHING; ek olarak shipment.order_id UNIQUE.
  • İşletim: Outbox lag'i (en eski gönderilmemiş kaydın yaşı) alarmlı bir metrik; gönderilen kayıtlar için temizlik veya zaman bazlı partition.

Ne zaman outbox kullanmam: Event kaybının iş açısından önemsiz olduğu, best-effort bildirimlerde AFTER_COMMIT listener yeterli olabilir; ama bunu bilerek seçerim.

Junior @Transactional'ın Kafka'yı kapsadığını sanıyor; en yaygın ve en tehlikeli yanlış. Mid dual write'ı adlandırıyor ve standart çözümü veriyor. Senior outbox'ı bir ürün gibi ele alıyor: relay seçiminin trade-off'u, sıralamanın üç ayrı kırılma noktası, replication slot riski, relay'in transaction süresi, temizlik ve izleme. "Ne zaman kullanmam" cümlesi de olgunluk göstergesi.

Takip sorusu: Outbox relay'i üç pod'da çalışıyor ve aynı siparişin OrderCreated ve OrderCancelled event'leri ters sırayla yayınlandı. Bu nasıl oldu, nasıl düzeltirsin?

Soru: "Saga'yı orchestration ile mi choreography ile mi kurarsın?"

Choreography daha iyi, çünkü merkezi bir servis yok ve servisler gevşek bağlı oluyor. Her servis event dinleyip kendi işini yapıyor.

Duruma göre. Choreography'de her servis event dinler ve yayınlar; basit ama adım sayısı artınca akışı takip etmek zorlaşır. Orchestration'da bir orchestrator komut gönderir ve cevapları dinler; akış ve telafi mantığı tek yerdedir ama orchestrator'a mantık birikebilir. Sipariş gibi çok adımlı ve telafili bir akışta orchestration, basit akışlarda choreography seçerim.

Karar kriterlerim: adım sayısı, telafi karmaşıklığı, akışın iş açısından kritikliği ve sahiplik. Sipariş-ödeme-stok-kargo için orchestration seçerim; çünkü "ödeme alındı stok yok" gibi bir durumda doğru telafiyi tek bir state machine'de yazmak, test etmek ve izlemek istiyorum. Choreography'de bu mantık dört servisin event handler'larına dağılır ve kimse uçtan uca akışın sahibi olmaz.

Orchestrator'ı şöyle kurarım: durum DB'de bir state machine; komutlar outbox ile gider; cevaplar idempotent işlenir (beklenmeyen durumda gelen cevap yok sayılır); her durumun bir timeout'u vardır ve takılan saga'ları bir job bulup telafiye alır. Orchestrator'ın "god service" olmaması için iş kuralları ilgili servislerde kalır; orchestrator sadece sıralama ve telafi kararı verir.

Choreography'yi de kullanırım: OrderConfirmed event'ini dinleyen bildirim, puan, analitik servisleri için. Akış uzun süren ve insan onayı içeriyorsa (iade onayı gibi) DB state machine yerine Temporal gibi bir workflow motorunu değerlendiririm. Takip için her saga'ya bir correlation id verip tracing ile uçtan uca izlerim.

Junior choreography'yi koşulsuz "daha iyi" ilan ediyor; gevşek bağlılığın akış görünürlüğü bedelini görmüyor. Mid doğru trade-off'u söylüyor. Senior seçim kriterlerini açıkça veriyor, orchestrator'ın nasıl sağlam kurulacağını (outbox, idempotent cevap işleme, timeout) anlatıyor, iki yaklaşımı birlikte kullanıyor ve workflow motoru alternatifini biliyor.

Takip sorusu: Orchestrator ChargePayment komutunu gönderdi, 5 dakikadır cevap yok. Ödeme alındı mı bilmiyorsun. Ne yaparsın?

Soru: "Flash sale: 100 stok, 10 pod, saniyede binlerce istek. Overselling'i nasıl önlersin?"

Stok kontrolünü synchronized bir metotta yaparım, böylece aynı anda tek thread stok düşer. Ya da Redis ile distributed lock alırım.

synchronized sadece tek JVM'de çalışır, 10 pod'da işe yaramaz. DB'de atomik bir koşullu UPDATE kullanırım: UPDATE stock SET quantity = quantity - 1 WHERE product_id = ? AND quantity >= 1. Etkilenen satır 0 ise stok yoktur. PostgreSQL satırı kilitlediği için iki istek aynı stoğu düşemez. Ayrıca CHECK (quantity >= 0) koyarım. Distributed lock'a gerek yok.

Doğruluk için DB'deki koşullu UPDATE'i son hakem olarak tutarım: READ COMMITTED'da bekleyen transaction satırın güncel halini yeniden değerlendirir, CHECK constraint'i son savunma. Sipariş ve outbox aynı transaction'da.

Ama flash sale'de asıl sorun throughput: tek satır hot row olur, tüm istekler kilit için sıraya girer ve bekleyen istekler connection havuzunu tüketip diğer ürünleri de etkiler. Bu yüzden katmanlı kurarım:

  1. Redis + Lua ön filtresi: Stok kampanya öncesi Redis'e yüklenir, istek Lua ile atomik düşülür; 0 dönen istek DB'ye gitmeden reddedilir. 50.000 isteğin yaklaşık 100'ü DB'ye ulaşır. Redis'in failover'da yazı kaybedebileceğini bildiğim için Redis'i doğruluk kaynağı yapmam; DB yazımı fail ederse Redis'e stok iade, periyodik reconciliation.
  2. DB'de kilit süresini kısaltmak: Stok UPDATE'ini transaction'ın sonuna alırım; gerekirse stok bucket'ları.
  3. Rezervasyon + TTL: Stok satış anında değil rezervasyonla düşer, ödeme süresinde gelmezse geri döner.
  4. Kapasiteyi korumak: Ürün başına rate limit veya bekleme odası; DB connection havuzu için bulkhead.

Ölçerim: başarılı/reddedilen sipariş oranı, Redis ile DB stok farkı, kilit bekleme süreleri, havuz kullanımı. Testte 50 eşzamanlı istek/10 stok senaryosunu Testcontainers ile gerçek PostgreSQL'de koşarım; "10 sipariş, stok 0" iddiası mock'la test edilemez. Distributed lock kullanmam: kilit değil atomik bir "kontrol et ve düş" gerekiyor ve Redlock'un saat varsayımları doğruluk garantisi vermez.

Junior iki yaygın yanlışı birden yapıyor: tek JVM'lik synchronized ve gereksiz distributed lock. Mid doğru atomik UPDATE'i biliyor. Senior doğruluk ile throughput'u ayırıyor, hot row ve connection havuzu etkisini görüyor, Redis'in kalıcılık zayıflığını hesaba katıp katmanlı bir tasarım kuruyor, ölçüm ve test stratejisi ekliyor.

Takip sorusu: Redis'te stok düştü, DB'ye sipariş yazarken uygulama çöktü. Redis'teki stok ne olacak?

Soru: "Mobil uygulama ödeme isteğini timeout sonrası tekrar gönderiyor. Çift çekimi nasıl önlersin?"

İstek gelince aynı kullanıcının son birkaç saniyede aynı tutarda ödemesi var mı diye kontrol ederim, varsa ikinciyi reddederim.

Idempotency key kullanırım. İstemci her ödeme için bir UUID üretip Idempotency-Key header'ında gönderir, retry'larda aynısını kullanır. Sunucu key'i ödeme kaydıyla birlikte unique olarak saklar; aynı key tekrar gelirse yeni ödeme oluşturmadan ilk cevabı döner. Unique constraint eşzamanlı iki isteğin yarışını kapatır.

Idempotency key ile, ama ayrıntılar önemli:

  • Kapsam: Key istemci bazında benzersiz: PRIMARY KEY (client_id, idem_key).
  • Fingerprint: İsteğin hash'ini saklarım; aynı key farklı body ile gelirse 422. Bu, istemci hatalarını sessizce yanlış cevapla örtmemeyi sağlar.
  • Eşzamanlılık: İki aynı istek aynı anda gelirse ya unique index üzerinde bekletirim (tek transaction tasarımı) ya da ilk istek IN_PROGRESS iken ikinciye 409 + Retry-After dönerim. Ödeme gibi harici çağrı içeren akışta ikincisini seçerim; DB transaction'ını sağlayıcı çağrısı boyunca açık tutmam.
  • Harici sağlayıcı: Benim idempotency'm sağlayıcıya kadar uzanmazsa yetmez. Sağlayıcıya da kendi kaydımdan türetilen sabit bir idempotency key gönderirim; timeout alırsam aynı key ile tekrar sorarım ya da durumu sorgularım.
  • Neyi saklarım: Kalıcı sonuçları (başarılı, kart reddedildi) saklarım; geçici altyapı hatasını saklarsam istemci asla başarıya ulaşamaz. Stripe v1'in 500'leri de sakladığını bilirim; bu bir tasarım tercihi.
  • Yaşam döngüsü: 24 saat gibi bir saklama süresi, temizlik job'ı, IN_PROGRESS'te kalan kayıtlar için lease ve kurtarma.

Ölçerim: replay edilen istek oranı, 409/422 sayıları, IN_PROGRESS'te takılan kayıtlar.

Junior zaman ve tutar sezgisine dayalı bir deduplikasyon öneriyor; aynı tutarda iki meşru ödemeyi reddeder, gecikmeli retry'ı kaçırır. Mid standart çözümü ve unique constraint'in rolünü biliyor. Senior key kapsamını, fingerprint'i, eşzamanlı istek tasarımını, idempotency'nin harici sağlayıcıya kadar uzatılmasını ve hangi sonucun saklanacağı kararını konuşuyor.

Takip sorusu: Sağlayıcıya ödeme isteği gönderdin ve timeout aldın. Kayıt IN_PROGRESS'te. Kullanıcı aynı key ile tekrar geldi. Ne yaparsın?

Sorulmadı ama sorulabilir

Event sourcing ve outbox ilişkisi

Event sourcing'de varlığın durumu bir event dizisi olarak saklanır; event'ler zaten DB'deki kalıcı kayıttır. Bu yüzden event store'u okuyan bir relay, outbox'ın yaptığı işi doğal olarak yapar. Ama event sourcing'in maliyeti yüksektir: projeksiyonlar, şema evrimi, snapshot'lar, sorgulama zorluğu. Sadece "güvenilir event yayını" için event sourcing'e geçmek gereksizdir; outbox aynı garantiyi klasik CRUD modeliyle verir.

Mülakat özeti: "Güvenilir event yayını için event sourcing gerekmez; outbox CRUD modelini bozmadan aynı garantiyi verir."

Workflow motorları: Temporal, Camunda

Uzun süren saga'larda (günler süren iade süreçleri, insan onayı, zamanlayıcılar) durum makinesi, timeout, retry ve telafi kodunu elle yazmak hataya açıktır. Temporal gibi motorlar iş akışını sıradan kod gibi yazmanı sağlar; motor her adımın sonucunu kalıcı olarak saklar, süreç çökse bile kaldığı yerden devam eder (durable execution). Camunda BPMN tabanlıdır ve iş birimiyle ortak bir model sunar. Bedeli ek bir altyapı ve öğrenme eğrisidir.

Mülakat özeti: "Kısa ve az adımlı saga'lar için DB'de state machine; uzun süren, zamanlayıcı ve insan onayı içerenler için workflow motoru."

Stok rezervasyonu ve süre aşımı

Stoğu sipariş anında kesin düşmek, ödemesi tamamlanmayan siparişlerin stoğu kilitlemesi demektir. Rezervasyon modeli: reservation(id, product_id, qty, expires_at, status). Sipariş rezervasyon oluşturur, ödeme gelince rezervasyon CONFIRMED olur, expires_at geçince bir job rezervasyonu EXPIRED yapıp stoğu iade eder. Job ile ödeme onayının yarışı, durum geçişinin koşullu UPDATE ile yapılmasıyla çözülür (UPDATE ... SET status = 'EXPIRED' WHERE id = ? AND status = 'PENDING').

Mülakat özeti: "Stoğu rezervasyon + TTL ile tutarım; durum geçişlerini koşullu UPDATE ile yaparak süre aşımı ve ödeme onayı yarışını kapatırım."

Distributed lock ne zaman, ne zaman değil

Distributed lock, "aynı anda tek bir worker çalışsın" gereken yerde işe yarar (tek aktif outbox relay, zamanlanmış job). Doğruluk için kilide güveniliyorsa fencing token gerekir: kilit her verilişte artan bir numara üretir ve korunan kaynak eski numaralı yazmaları reddeder. Aksi halde GC duraklaması yaşayan bir süreç kilidinin süresi dolduğunu bilmeden yazmaya devam eder. Stok düşümü gibi tek bir atomik işlemle çözülebilen problemlerde kilit yerine atomik işlem (koşullu UPDATE, Lua) tercih edilir.

Mülakat özeti: "Kilit, atomik bir işlemle çözemediğim koordinasyon için; doğruluk kilide bağlıysa fencing token şart."

Event şeması ve versiyonlama

Outbox'tan çıkan event bir sözleşmedir; consumer'lar onu bağımsız deploy edilen servislerde okur. Kurallar: alan eklemek geriye uyumludur, alan silmek veya anlamını değiştirmek değildir. Event'e schemaVersion alanı koymak, Avro veya Protobuf ile bir schema registry'de uyumluluk kontrolü yapmak ve consumer'ları bilinmeyen alanları yok sayacak şekilde yazmak (Jackson'da FAIL_ON_UNKNOWN_PROPERTIES kapalı; Spring Boot'un varsayılan ObjectMapper'ı bunu zaten kapatır) yaygın yaklaşımlardır.

Mülakat özeti: "Event'ler sözleşmedir; sadece geriye uyumlu değişiklik yapar, uyumsuz değişiklikte yeni bir event tipi veya versiyon açarım."

Saga'da "ödeme alındı mı bilmiyorum" durumu

Ödeme sağlayıcısına istek gitti, timeout alındı. Ödeme sağlayıcıda gerçekleşmiş olabilir. Telafiye (iade) geçmek de, ödemeyi tekrar denemek de kör bir karardır. Doğru yaklaşım: aynı idempotency key ile isteği tekrarlamak (sağlayıcı aynı sonucu döner) veya sağlayıcının durum sorgulama endpoint'ini kullanmak; belirli süre içinde netleşmezse saga'yı PAYMENT_UNKNOWN gibi açık bir durumda tutup reconciliation sürecine (sağlayıcının gün sonu raporuyla karşılaştırma) bırakmak.

Mülakat özeti: "Belirsiz sonucu ne başarı ne hata sayarım; aynı idempotency key ile sorar, netleşmezse reconciliation'a bırakırım."

Sık yapılan hatalar

  • @Transactional metot içinde kafkaTemplate.send çağırmak → DB ve Kafka atomik değil; phantom veya lost event → Transactional Outbox.
  • AFTER_COMMIT listener'ı dual write çözümü sanmak → Commit ile gönderim arasında çökme event'i kaybettirir → Outbox.
  • send()'in sonucunu beklemeden outbox kaydını "gönderildi" işaretlemek → Başarısız gönderim kaybolur → Broker onayını bekle (get(timeout) veya callback).
  • Consumer'da "önce exists sorgula, sonra işle" → İki eşzamanlı teslimat ikisi de "yok" görür → INSERT ... ON CONFLICT DO NOTHING + unique constraint, iş yazısıyla aynı transaction.
  • processed_event kaydını iş yazısından ayrı commit etmek → İş fail edince mesaj "işlendi" sayılır ve kaybolur → Aynı transaction.
  • Aynı sınıf içinden @Transactional metodu çağırmak → Proxy atlanır, transaction açılmaz → Ayrı bean (OrderCreatedHandler örneği) veya TransactionTemplate.
  • Checked exception fırlatıp rollback beklemek → Spring varsayılan olarak checked exception'da commit eder → Unchecked'e sar ya da rollbackFor ver.
  • Telafiyi "rollback" gibi düşünmek → Ödeme silinmez, iade edilir; telafi de fail edebilir → Idempotent, retry edilebilir, izlenen semantik ters işlem.
  • Outbox'ı "son id'den büyükleri al" ile poll etmek → Geç commit olan düşük id'li kayıt atlanır → published_at IS NULL + SKIP LOCKED veya CDC.
  • Birden fazla relay instance'ında sıranın korunduğunu varsaymak → Batch'ler farklı sırada yayınlanabilir → Tek aktif relay, shard'lama veya CDC; consumer'da versiyon kontrolü.
  • Flash sale'de synchronized veya distributed lock → synchronized tek JVM'de çalışır; kilit gereksiz ve kırılgan → Koşullu UPDATE veya Lua ile atomik işlem.
  • Redis'i stok için tek doğruluk kaynağı yapmak → Asenkron replikasyon failover'da yazı kaybettirebilir → Redis ön filtre, DB son hakem, reconciliation.
  • Idempotency key'i global unique tutmak ve fingerprint saklamamak → Farklı istemciler çakışabilir, yanlış body ile eski cevap dönülür → (client_id, key) + request hash, farklı body'de 422.

Hızlı tekrar

S: 2PC neden "bloklayan" bir protokoldür?

C: Prepare'de "evet" diyen katılımcı, coordinator kararı gelene kadar kilitleri tutmak zorundadır; coordinator çökerse katılımcılar in-doubt kalır.

S: Saga nedir?

C: Uzun bir iş akışını her biri kendi DB'sinde atomik olan lokal transaction'lara bölüp, hata durumunda önceki adımları telafi transaction'larıyla tersine çeviren desen.

S: Telafi transaction'ı hangi özellikleri taşımalı?

C: Semantik ters işlem olmalı (silme değil iade), idempotent, sonunda başarılı olana kadar retry edilebilir, sırasız gelişe dayanıklı ve izlenebilir.

S: Pivot transaction nedir?

C: Saga'nın geri dönüşü olmayan noktası; commit olursa sonraki adımlar telafi edilmez, mutlaka tamamlanacak şekilde retry edilir.

S: Semantic lock nedir?

C: Kaydı uygulama seviyesinde ara bir durumla (PAYMENT_PENDING) işaretleyip saga sürerken çakışan işlemleri engellemek veya ertelemek.

S: Dual write'ın iki başarısızlık senaryosu nedir?

C: Phantom event (event gitti, DB commit fail oldu) ve lost event (DB commit oldu, event gönderilemedi).

S: Transactional Outbox hangi garantiyi verir, hangisini vermez?

C: Event'in iş verisiyle aynı kaderi paylaşmasını (kayıp ve hayalet event yok) garanti eder; teslimat at-least-once'tır, duplicate olabilir.

S: Polling relay'de FOR UPDATE SKIP LOCKED ne sağlar?

C: FOR UPDATE aynı satırın iki relay'e gitmesini, SKIP LOCKED relay'lerin birbirini beklemesini engeller.

S: CDC (Debezium) ile outbox'ın polling'e göre avantajı ve PostgreSQL'deki riski nedir?

C: Polling yükü yok, düşük gecikme, commit sırası. Risk: connector durursa replication slot WAL biriktirir ve disk dolabilir.

S: Idempotent consumer'da duplicate kontrolü neden INSERT ... ON CONFLICT DO NOTHING ile yapılır?

C: exists + save check-then-act yarışıdır; tek INSERT primary key'in unique garantisine dayanır ve eşzamanlı teslimatta sadece biri 1 alır.

S: Aynı idempotency key farklı body ile gelirse ne dönülür?

C: IETF taslağına göre 422; ilk istek hâlâ işleniyorsa 409, key zorunlu ama yoksa 400.

S: DB'de koşullu UPDATE ... WHERE quantity >= :qty neden overselling'i önler?

C: Satır kilitlenir, ikinci transaction bekler ve READ COMMITTED'da güncel değerle WHERE'i yeniden değerlendirir; stok yetmezse 0 satır günceller.

S: Flash sale'de Redis Lua neden tek doğruluk kaynağı yapılmaz?

C: Redis kalıcılığı ve asenkron replikasyonu failover'da son düşümleri kaybettirebilir; Redis ön filtre, DB son hakem olmalı.

S: Demo projede consumer neden OrderCreatedConsumer ve OrderCreatedHandler diye ikiye bölündü?

C: Aynı sınıftan @Transactional metodu çağırmak proxy'yi atlar (self-invocation); ayrı bean ile çağrı proxy'den geçer ve processed_event + kargo yazısı tek transaction olur.

Kaynaklar