İçeriğe geç

Dağıtık Transaction — 2PC Neden Nadiren, Saga Neden Sık

İleri 10 dk Sık karşılaşılır

Önce şunu oku: Servis Sınırları — Bounded Context ve Database-per-Service , Saga Pattern

30 saniyede özet

Bir @Transactional yalnızca kendi veritabanını geri alır. Two-phase commit iki veritabanını tek karara bağlar, ama koordinatör düşerse herkes kilitli bekler. Mikroservisler bu yüzden çoğu zaman saga ve outbox'ı seçer.

İki arkadaş birlikte bir eve taşınmaya karar veriyor: ikisi de “tamam” demeden kimse eski evinin anahtarını bırakmamalı. Peki biri cevap vermeden ortadan kaybolursa, öteki ne kadar bekler?

  1. Bayt: Saga çok adım istiyor. İki veritabanını tek bir transaction'a bağlasak olmaz mı?

  2. Sen: Öyle bir şey gerçekten var mı?

  3. Bayt: Var! Veritabanlarının eski bir numarası: önce herkese sor, sonra karar ver.

  4. Bayt: Ama bir bedeli var. Bugün o bedeli birlikte ölçeceğiz.

Satır satır: @Transactional nereye kadar uzanır

Sipariş servisi siparişi kendi veritabanına yazıyor, parayı ise Payment servisine HTTP ile çektiriyor. Metodun üstünde @Transactional var; güvende miyiz?

Rollback nereye kadar ulaşır?

PlaceOrder.java
1@Transactional
2public void placeOrder(Order order) {
şu an çalışan satır orders.save(order);
4 paymentClient.charge(order.id(), order.total());
5 inventoryClient.reserve(order.id(), order.sku());
6}

Debug

Adım 1/4

Order Sipariş yazıldı ama commit edilmedi. Transaction yalnızca Order veritabanının bağlantısına bağlı.

Order DB
= sipariş (commit yok)
Java 21UTF-8LF3:1

Sol/sağ ok tuşlarıyla da gezebilirsin.

@Transactional tek bir veritabanı bağlantısını yönetir. Başka bir servisin commit ettiği iş bir ağ çağrısının ardındadır ve rollback ona ulaşmaz.

Database-per-service kuralı yüzünden bu istisna değil, kuraldır.

Kafam karıştı, daha basit anlat

Rollback yalnızca kendi defterindeki satırları siler. Başka bir servisin kendi defterine çoktan yazdığı satıra dokunamaz.

Hızlı kontrolOrta

Sipariş servisindeki `@Transactional` bir metot önce siparişi kendi veritabanına kaydediyor, sonra Payment servisini HTTP ile çağırıp parayı çektiriyor, en sonda bir exception fırlatıyor. Ne olur?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Two-phase commit: önce söz, sonra karar

two-phase commitBirden çok veritabanını tek transaction gibi bitiren protokol: koordinatör önce herkese 'hazır mısın?' diye sorar, hepsi evet derse COMMIT, biri hayır derse ABORT gönderir.Sözlükte gör → (2PC) işi bir koordinatöre verir. Birinci aşamada herkese PREPARE gönderir: işini yap, satırlarını kilitle, diske yaz ve “commit edebilirim” diye söz ver.

İkinci aşamada kararı bildirir. Herkes evet dediyse COMMIT, tek bir hayır varsa ABORT gider; katılımcılar uygular ve kilitlerini bırakır.

İki katılımcı da evet dedi, sonra koordinatör kararı bildiremeden çöktü. Payment beklemekten sıkılıp kendi başına rollback yapsa ne olabilir? Cevabı göster

Veri tutarsızlaşabilir. Koordinatör çökmeden önce Inventory’ye COMMIT göndermiş olabilir ve Payment bunu bilemez. Söz vermiş bir katılımcı bu yüzden tek başına karar veremez: bekler ve kilidini tutar.

Söz verildi, ilan gelmedi: kimse kalkıp gidemez.
Adım adım oku
  1. Koordinatör iki katılımcıya da hazır olup olmadıklarını soruyor.
  2. İkisi de evet diyor. Artık söz verdiler ve satırlarını kilitli tutuyorlar.
  3. Koordinatör kararı ilan edemeden ortadan kayboluyor.
  4. İki katılımcı da bekliyor. Kendi başlarına karar veremezler ve aynı satırları değiştirmek isteyen herkes de bekliyor.

Bu bekleyen katılımcıya in-doubt transactionPrepare'e 'evet' deyip koordinatörün kararını henüz almamış katılımcı. Tek başına commit de rollback de yapamaz; karar gelene kadar kilitlerini tutar.Sözlükte gör → denir. Aynı satırı değiştirmek isteyen her işlem de bekler: koordinatör ne kadar yoksa o satırlar o kadar donuk kalır.

Java'da 2PC: XA ve JTA· istersen atla

Katılımcıların bu protokolü konuşması için XAVeritabanı ve mesaj kuyruğu gibi kaynakların two-phase commit'e katılması için standart arayüz. Java'da JTA ile kullanılır; kaynağın XA desteklemesi gerekir.Sözlükte gör → arayüzü gerekir. Spring’de Narayana ya da Atomikos gibi bir JTA transaction manager, iki XA DataSource’u tek bir @Transactional altında toplayabilir. Kafka ise XA’ya katılmaz; HTTP arkasındaki bir servis de hiçbir zaman XA kaynağı değildir.

Kafam karıştı, daha basit anlat

2PC bir nikâh gibi: önce iki taraftan da “evet” alınır, sonra ilan edilir. Evet diyen taraf, ilan gelmeden kalkıp gidemez.

Hızlı kontrolOrta

Two-phase commit'in iki aşaması sırasıyla nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Prepare'e 'evet' demiş bir katılımcı koordinatörden bir türlü karar alamıyor. Doğru davranış hangisi?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Kendin gör

Aynı sipariş, iki protokol, bir arıza. Kartlarda her veritabanının durumu ve kilit süresi görünüyor.

2PC mi saga mı — hata anında kim, ne kadar bekliyor?

Tohum 3
Koordinatörbekliyortx-9023
  • Payment DBboşta

    hesaptan 100 TL düş

    kilit yok0 tick

    1. Inventory DBboşta

      1 adet stok ayır

      kilit yok0 tick

      Oynat ya da adımla.

      Hız
      Adım 0

      Şu an ne oldu?

      Bir sipariş, iki veritabanı, bir koordinatör

      Önce herkese "commit edebilir misin?" diye sorulacak, sonra karar herkese bildirilecek.

      Görevler0/3

      • Katılımcıları belirsizlikte bırakaçık

        İpucu

        2PC'de iki taraf EVET dedikten sonra koordinatör kaybolursa ne olur?

      • Commit edilmiş bir adımı telafiyle geri çeviraçık

        İpucu

        Sagada ilk adım commit ettikten sonra ikinci adım "hayır" derse?

      • Aynı çöküşü kimseyi kilitte bekletmeden atlataçık

        İpucu

        Koordinatör çökmesini sagada dene ve kilit sürelerini 2PC ile karşılaştır.

      Olay günlüğü (0)

      Henüz olay yok. Oynat veya adımla.

      1. Mutlu yolu 2PC ile oynat. Kilitler karar gelene kadar tutuluyor.
      2. Arızayı “Koordinatör yolun ortasında çöküyor” yap. İki taraf belirsiz ve kilitli bekliyor.
      3. Protokolü sagaya çevir, aynı arızayı dene. Kilit yok, ama bir süre yarım durum görünüyor.
      4. “Inventory hayır diyor” ile karşılaştır. 2PC iz bırakmıyor, saga bir iade satırı yazıyor.
      5. Üç görevi de tamamla.
      Hızlı kontrolOrta

      Her cümle hangi yaklaşımı anlatıyor?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

      Sınıflandırılmamış

      2PC

      Önce söz, sonra ortak karar

        Saga

        Yerel commit, gerekirse telafi

          İkisi de

          Her iki yaklaşımda da geçerli

            Mikroservisler neden çoğunlukla sagayı seçer

            2PC tutarlılığı beklemekle öder. Her katılımcı, en yavaş katılımcı ve koordinatör kadar kilit tutar; biri düşerse ötekiler de durur.

            Mikroservislerde bu bedel büyür. Servisler HTTP ya da Kafka ile konuşur ve bunlar XA’ya katılamaz; her servisi ortak bir koordinatöre bağlamak da onları birlikte kırılgan yapar.

            Saga başka bir takas yapar: her adım hemen commit eder, hata bir telafi adımıyla düzeltilir. Sistem eventual consistencySistemin bir süre tutarsız kalıp sonunda tutarlı hâle gelmesi. Saga'nın verdiği garanti budur; arada geçilen ara durumu tasarımın kabul etmesi gerekir.Sözlükte gör → ile, yani sonunda tutarlı olur; adımlar arasındaki mesajları outbox kaybolmaktan korur.

            2PCSaga
            Arada yarım durumKilitlerin arkasında saklıDışarıdan görünür
            Koordinatör düşerseKatılımcılar kilitli beklerKilit yok, saga kaldığı yerden sürer
            Bir adım hayır derseABORT, iz kalmazTelafi, iz kalır (iade satırı)
            GerekenXA destekleyen kaynaklarHer adıma telafi, idempotent adımlar

            2PC kötü değil, yeri dar. Aynı uygulamada XA destekleyen bir veritabanı ve bir JMS kuyruğu kısa bir işlemde birlikte değişmeliyse, 2PC basit ve doğru bir seçimdir.

            Kafam karıştı, daha basit anlat

            2PC herkesi bekletir ve hepsi birlikte bitirir. Saga kimseyi bekletmez; arada yarım bir durum görünür ve hata yeni bir adımla düzeltilir.

            Hızlı kontrolİleri

            Hangi durumda two-phase commit hâlâ makul bir seçimdir?

            Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

            Tuzaklar

            “JTA’yı açarız, olur” demek. Zincirde XA’ya katılmayan tek bir halka varsa garanti o halkada biter. Kafka’ya gönderilen mesaj ya da bir REST çağrısı bu halkalardandır.

            Belirsiz transaction’ı elle çözmek. Uzun süre bekleyen bir katılımcıyı yönetici elle commit ya da rollback edebilir. Koordinatörün kararıyla çelişirse tutarsızlığı artık insan yaratmıştır.

            Hızlı kontrolİleri

            JTA transaction manager kurulu, iki DataSource da XA. Yine de nadiren sipariş yokken para çekiliyor. Hangi satır garantinin dışında kalıyor?

            Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

            Hatalı satıra dokun, sonra kontrol et.

            OrderService.java
            Java 21UTF-8LF
            Derinleş · Hesaptan kart borcuna ödeme: iki servis, bir saga 4 dosya · ~93 satır · ilk okumada atlayabilirsin
            Proje dosyaları

            src/main/java/com/bank/payment/ CardPaymentSaga.java Orkestratör: adımları sırayla çalıştırıyor; kart adımı başarısız olursa hesaba iadeyi tetikliyor.

            src/main/java/com/bank/payment/CardPaymentSaga.java
            @Service
            class CardPaymentSaga {
            private final AccountClient accounts;
            private final CardClient cards;
            private final SagaLog log;
            CardPaymentSaga(AccountClient accounts, CardClient cards, SagaLog log) {
            this.accounts = accounts;
            this.cards = cards;
            this.log = log;
            }
            /** Each step commits on its own; a failure is undone by a new step, not by a rollback. */
            PaymentResult pay(PaymentRequest request) {
            UUID sagaId = log.start(request);
            accounts.debit(request.iban(), request.amount(), "debit-" + sagaId); // step 1 commits
            log.advance(sagaId, Step.DEBITED);
            try {
            cards.payDebt(request.cardId(), request.amount(), "card-" + sagaId); // step 2 commits
            log.advance(sagaId, Step.COMPLETED);
            return PaymentResult.paid(sagaId);
            } catch (CardRejectedException rejected) {
            accounts.refund(request.iban(), request.amount(), "refund-" + sagaId); // compensation
            log.advance(sagaId, Step.COMPENSATED);
            return PaymentResult.refunded(sagaId, rejected.reason());
            }
            }
            }

            src/main/java/com/bank/payment/ SagaLog.java Durum: sagaların hangi adımda olduğu kendi tablosunda; servis çökerse kaldığı yerden sürüyor.

            src/main/java/com/bank/payment/SagaLog.java
            @Repository
            class SagaLog {
            private final JdbcClient jdbc;
            SagaLog(JdbcClient jdbc) {
            this.jdbc = jdbc;
            }
            UUID start(PaymentRequest request) {
            UUID id = UUID.randomUUID();
            jdbc.sql("INSERT INTO payment_saga (id, iban, card_id, amount, step) VALUES (?, ?, ?, ?, 'STARTED')")
            .params(id, request.iban(), request.cardId(), request.amount())
            .update();
            return id;
            }
            void advance(UUID id, Step step) {
            jdbc.sql("UPDATE payment_saga SET step = ?, updated_at = now() WHERE id = ?").params(step.name(), id).update();
            }
            /** Sagas a crash left halfway: a scheduled job resumes or compensates them. */
            List<UUID> stuckSince(Instant before) {
            return jdbc.sql("SELECT id FROM payment_saga WHERE step = 'DEBITED' AND updated_at < ?")
            .param(before).query(UUID.class).list();
            }
            }

            src/main/java/com/bank/payment/ AccountClient.java İstemci: her çağrı aynı idempotency key ile; tekrar denenen çekim ikinci kez para almıyor.

            src/main/java/com/bank/payment/AccountClient.java
            @Component
            class AccountClient {
            private final RestClient rest;
            AccountClient(RestClient accountsRestClient) {
            this.rest = accountsRestClient;
            }
            void debit(String iban, BigDecimal amount, String idempotencyKey) {
            post("/accounts/{iban}/debits", iban, amount, idempotencyKey);
            }
            void refund(String iban, BigDecimal amount, String idempotencyKey) {
            post("/accounts/{iban}/refunds", iban, amount, idempotencyKey);
            }
            // The same key on every retry: the accounts service applies each debit or refund once.
            private void post(String uri, String iban, BigDecimal amount, String key) {
            rest.post().uri(uri, iban).header("Idempotency-Key", key).body(new Money(amount)).retrieve().toBodilessEntity();
            }
            }

            src/test/java/com/bank/payment/ CardPaymentSagaTest.java Test: kart servisi reddedince hesap eski bakiyesine dönüyor ve iz (iade satırı) kalıyor.

            src/test/java/com/bank/payment/CardPaymentSagaTest.java
            class CardPaymentSagaTest {
            private final FakeAccounts accounts = new FakeAccounts(Map.of("TR01", new BigDecimal("1000.00")));
            private final CardClient rejectingCards = (cardId, amount, key) -> { throw new CardRejectedException("LIMIT"); };
            private final CardPaymentSaga saga = new CardPaymentSaga(accounts, rejectingCards, new InMemorySagaLog());
            @Test
            void refundsTheAccountWhenTheCardStepFails() {
            PaymentResult result = saga.pay(new PaymentRequest("TR01", "CARD-7", new BigDecimal("250.00")));
            assertThat(result.refunded()).isTrue();
            assertThat(accounts.balance("TR01")).isEqualByComparingTo("1000.00");
            assertThat(accounts.history("TR01")).extracting(Entry::type).containsExactly("DEBIT", "REFUND"); // the trace stays
            }
            }

            Kendini sına

            Şimşek turu1/5

            Bir @Transactional metot, içinden çağrılan başka bir servisin commit ettiği işi de geri alır.

            Soru 1/4İleri

            Koordinatör COMMIT kararını kendi günlüğüne yazdı, Payment'a gönderdi ve Inventory'ye gönderemeden çöktü. Yeniden başladığında ne yapmalı?

            Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

            Aklında kalacak üç şey

            1. 1 Bir @Transactional yalnızca kendi veritabanı bağlantısını geri alır. Başka bir servisin commit ettiği iş bir ağ çağrısının ardındadır ve o rollback'e dahil olmaz.
            2. 2 Two-phase commit önce herkesten söz alır, sonra kararı bildirir. Söz vermiş bir katılımcı kararı alamazsa kilitlerini tutarak bekler; koordinatör ne kadar yoksa bekleme o kadar uzar.
            3. 3 Mikroservisler çoğunlukla saga ve outbox'ı seçer: kimse beklemez, bedeli arada görünen yarım durum ve telafi adımlarıdır. Aynı uygulamada, XA destekleyen iki kaynak arasında kısa bir işlem için 2PC hâlâ makul bir seçimdir.
            Sonraki kapı Bir istek beş servisten geçti ve biri hata verdi. Beş ayrı log dosyasında aynı isteği nasıl bulursun? Dağıtık Tracing — Bir İsteği Beş Servis Boyunca İzlemek · 10 dk

            5 kart sonraki derste seni bekliyor

            0/4 kart bu dersten toplandı