Skip to content

บทที่ 12 — Event-Driven Patterns (Saga + Outbox + Inbox + CDC)

← บทที่ 11: Stream Processing | สารบัญ | บทที่ 13: Resilience →

TL;DR: บทนี้ตอบ "pattern ที่บังคับเมื่อทำ event-driven microservices" — pattern หลักที่จะเจอ:

  • Saga (ซา-ก้า = แผนสำรองหลายขั้น: ถ้า step ไหนล้มก็ undo step ก่อนหน้า) — มี 2 สไตล์ Choreography vs Orchestration, ใช้ Temporal ช่วยได้
  • Outbox (atomic save + publish) — เขียน DB กับ event พร้อมกันใน transaction เดียว
  • Inbox (idempotent = ทำซ้ำกี่รอบก็ผลเดิม) — pattern ฝั่ง consumer ที่ scale ได้
  • Idempotency-Key — pattern ของ HTTP sync API แบบ Stripe
  • CDC + Debezium — ดักการเปลี่ยนแปลงจาก DB ไปเป็น event อัตโนมัติ
  • Event Sourcing (intro) — เก็บ event แทน state สุดท้าย

ข้ามได้ถ้า: ทำ Saga + Outbox + Idempotent consumer เป็นนิสัยอยู่แล้ว — แต่ถ้าใช้ broker บังคับอ่าน

🛠️ โค้ดที่ run ได้: โค้ด lab (Saga choreography เต็ม flow ภาษา Go + Outbox demo 4 ภาษา ผ่าน Kafka + Postgres บน Docker Compose) จะตามมาทีหลัง — ไม่ใช่ของที่ต้องมีก่อนอ่านบทนี้. ระหว่างนี้: ดู snippet ในบทแต่ละ Part ประกอบกับ official doc ของ Debezium / Temporal / Kafka — สามารถรัน Kafka + Postgres ด้วย Docker Compose เองแล้วเอา snippet จากบทไป test ได้ทันที

บทนี้คือ pattern ที่ บังคับ เมื่อทำ microservices ที่มี broker:

  • Saga (ซา-ก้า = นิยายลำดับเหตุการณ์) — กระบวนการธุรกิจหลายขั้นตอนข้าม service ที่ rollback ด้วย compensating action (การชดเชย — action ที่ "ย้อน" action ก่อนหน้า เช่น ถ้า charge เงินแล้ว step ถัดไปล้ม ต้อง refund)
  • Outbox (เอาท์-บ๊อกซ์ = กล่องขาออก) — เขียน DB + queue ใน transaction เดียวผ่าน outbox table → atomic
  • Inbox (กล่องขาเข้า) — pattern ฝั่ง consumer ที่บันทึก message id ที่เคยประมวลใน table ก่อน เจอซ้ำ skip; กัน double processing คู่กับ outbox
  • Idempotency-Key (HTTP) — pattern สำหรับ sync API ที่ retry ได้
  • Change Data Capture (CDC) — Debezium 3.x (2026 baseline; 2.x ยังคง maintained แต่ 3.x แนะนำสำหรับ project ใหม่)
  • Event Sourcing (เก็บ event เป็น source of truth — ไม่ใช่ state สุดท้าย; ดู intro)
  • 🆕 Transactional Messaging Standards 2026 — Outbox + CDC ที่ถูกยอมรับเป็นมาตรฐาน

📖 ศัพท์สำคัญที่จะเจอตลอดบท:

  • eventual consistency (สอดคล้องในที่สุด) — ข้อมูลใน service ต่าง ๆ อาจไม่ตรงทันที แต่จะตรงกันในที่สุดเมื่อ event propagate ครบ
  • choreography (คอ-รี-อ๊อก-ระ-ฟี = การเต้นประสาน) — service แต่ละตัวฟัง event แล้วทำเอง ไม่มีตัวกำกับ
  • orchestration (อ๊อก-เคส-เทรชั่น = การกำกับวงดนตรี) — มี orchestrator เป็นตัวกำกับสั่ง service ทีละตัว
  • event replay (เล่น event ซ้ำ) — ดึง event ทั้งหมดจาก log มาประมวลใหม่ตั้งแต่ต้น ใช้สร้าง state ใหม่หรือ migrate
  • projection (โพร-เจค-ชั่น = การฉายภาพ) — สร้าง view ของ state ปัจจุบันโดยเล่น event ทั้งหมดผ่าน aggregator

ใช้เวลา 4-5 ชั่วโมง (เข้มข้น)

🗺️ บทนี้ยาว 1700+ บรรทัด — TOC สำหรับกระโดดอ่าน (anchor link ทำงานได้ใน GitHub / VitePress — ถ้าอ่านใน markdown viewer บางตัวอาจกด link ไม่ได้):

  1. Part 1: ทำไม Pattern เหล่านี้
  2. Part 2: Saga Pattern — Choreography vs Orchestration + decision tree + Temporal
  3. Part 3: Outbox Patternatomic save + publish (กัน dual-write)
  4. Part 4: Inbox + Idempotent Consumer
  5. Part 5: Idempotency-Key (HTTP sync) — Stripe pattern
  6. Part 6: Change Data Capture (CDC) — Debezium 3.x
  7. Part 7: Event Sourcing (intro) — ลึกในบท 14
  8. Part 8: Transactional Messaging 2026 Standards
  9. Part 9: Production Recipe + Cheat Sheet

เพิ่งเริ่ม: Part 1 + 2 + 3 ก่อน (Saga + Outbox = ครบ 80% ของ use case). มี race condition: Part 4 (Inbox). มี legacy DB: Part 6 (CDC).


Part 1: ทำไม Pattern เหล่านี้

1.1 ปัญหารากฐาน

ใน monolith:

java
@Transactional
public void placeOrder(...) {
    orderRepo.save(order);
    inventoryRepo.decrement(...);
    paymentRepo.charge(...);
    // DB transaction = atomic
}

ใน microservices:

text
Order Service: save order ใน Order DB
Inventory Service: decrement stock ใน Inventory DB
Payment Service: charge ใน Payment DB

→ 3 DB ของ 3 service — ไม่มี global transaction

ปัญหา:

  • Order saved → Inventory ล่ม = stock ไม่ลด → oversell (เช่น ลูกค้าสั่งของ 100 ชิ้น, order save สำเร็จและส่ง confirm ให้ลูกค้าแล้ว แต่ inventory ลด stock ไม่สำเร็จ → จริง ๆ ของหมด → เกิด overselling)
  • Order saved + event published → DB rollback = event ผี (event บอกว่ามี order แต่ใน DB ไม่มี)
  • Event consumed → process → ack lost → reprocess (duplicate — process ซ้ำ)

Saga, Outbox, Inbox แก้ทีละปัญหา


Part 2: Saga Pattern

2.1 หลักการ — ทำไมต้อง Saga

💡 Analogy: จองทริปเที่ยวต่างประเทศ

คุณจองทริปไป Tokyo ต้อง 3 step:

  1. จองเครื่องบิน TG (50,000 บาท)
  2. จองโรงแรม APA (30,000 บาท)
  3. ซื้อประกันเดินทาง (5,000 บาท)

ถ้า step 3 fail (บัตรประกันถูก reject) — คุณไม่สามารถ "ROLLBACK" เครื่องบินกับโรงแรมแบบ DB transaction ได้ เพราะมัน commit ไปแล้วในระบบของ TG กับ APA

สิ่งที่ทำได้คือ:

  • ยกเลิกตั๋วเครื่องบิน (TG จะคืนเงิน 80% — มี penalty)
  • ยกเลิกโรงแรม (APA คืนเต็มถ้ายกเลิกก่อน 24 ชม.)

→ นี่คือ compensation — "ขั้นตอนแก้ที่ทำตรงข้าม" ของแต่ละ step (ในตัวอย่างคือยกเลิกตั๋ว/โรงแรม)

Saga = ทำ atomic operation ระหว่าง multi-service โดย ใช้ compensation แทน rollback


ทำไม DB transaction ใช้ไม่ได้

ใน monolith:

java
@Transactional
public void placeOrder(Order o) {
    stockRepo.decrement(o.itemId(), o.qty());   // 1
    paymentRepo.charge(o.userId(), o.amount());  // 2
    shipmentRepo.create(o);                       // 3
}
// ถ้า step 3 throw → ทุกอย่าง ROLLBACK โดย DB

ใน microservices — แต่ละ service มี DB ของตัวเอง:

text
❌ Distributed transaction (2PC) — slow + brittle + ไม่ค่อยรองรับ
❌ Share DB — anti-pattern (บท 00 Part 6.3)
✅ Saga — compensate แทน rollback

📖 2PC (Two-Phase Commit) คืออะไร — protocol สำหรับทำ transaction ข้ามหลาย DB/node ให้ atomic เหมือน DB เดียว อธิบายทีละประเด็น:

  • มี coordinator (ตัวกลาง) คอยถามทุก node ว่า "พร้อม commit ไหม" (phase 1: เตรียมพร้อม) แล้วค่อยสั่ง "commit จริง" พร้อมกันทุก node (phase 2: commit)
  • ปัญหาคือ coordinator เป็น bottleneck — ถ้า coordinator ล่มระหว่างรอ vote จาก node ทั้งหมด ทุก node ต้องค้าง lock ไว้รอ ทำให้ availability แย่
  • ไม่เข้ากับหลัก partition tolerance ของ microservices สมัยใหม่ (ระบบต้องทนได้แม้ network ระหว่าง service ขาดชั่วคราว)
  • เทคโนโลยีที่รองรับ 2PC มีอยู่ (Postgres prepared transactions, MS DTC) แต่ industry ส่วนใหญ่หลีกเลี่ยงเพราะเหตุผลข้างต้น

Saga = sequence of local transactions + compensation

text
HAPPY PATH:
Step 1: [Inventory]  Reserve Stock     → success
Step 2: [Payment]    Charge Card        → success
Step 3: [Shipping]   Create Shipment    → success
→ Order CONFIRMED

FAIL AT STEP 3:
Step 1: [Inventory]  Reserve Stock     → success ✓
Step 2: [Payment]    Charge Card        → success ✓
Step 3: [Shipping]   Create Shipment    → FAIL ✗

→ Run compensating actions REVERSE order:
Step 2': [Payment]    Refund Card       → undo step 2
Step 1': [Inventory]  Release Stock     → undo step 1
→ Order CANCELLED

กฎทอง 4 ข้อของ Saga:

  1. แต่ละ step = atomic ภายใน 1 service (atomic = ทำได้สมบูรณ์หรือไม่ทำเลย — ใช้ DB transaction ภายใน + Outbox; อ่านเพิ่มเติมได้ที่ บท 06 ถ้าต้องการ — ไม่จำเป็นต้องหยุดอ่านตอนนี้)
  2. ระหว่าง step = async event (ไม่ใช่ sync REST call)
  3. Compensation ต้อง idempotent (idempotent = ทำซ้ำกี่รอบก็ได้ผลเดิม — อ่านรายละเอียดเพิ่มเติมได้ที่ บท 06) — อาจ trigger ซ้ำเพราะ network retry
  4. Compensation reverse order (ย้อนลำดับ) — step ที่ commit ทีหลังต้อง undo ก่อน

2.2 2 Styles ของ Saga

มี 2 แบบใหญ่: Choreography (ไม่มี leader) vs Orchestration (มี leader)

🕺 Choreography (event-driven, ไม่มี orchestrator)

Analogy: วงเต้น flash mob (flash mob = กลุ่มคนนัดมารวมตัวเต้นพร้อมกันในที่สาธารณะ โดยไม่มีใครสั่งการ ณ ตอนนั้น) — ไม่มีคนคุม, แต่ละคนรู้ว่าตัวเองจะเต้นท่าไหนเมื่อเห็น cue ของเพื่อนข้าง ๆ

หลักการ: แต่ละ service subscribe event ของ service อื่น แล้วทำงานเอง → publish event ถัดไป

⚠️ "publish" ในรูปไม่ใช่ kafka.send() ตรง ๆ ภายใน transaction — ทุก service เขียน outbox row ใน DB transaction เดียวกับงานหลัก แล้ว Outbox relay / Debezium ดึงไป publish Kafka ทีหลัง. ดู Part 3 (Outbox) — ถ้าทำ kafka.send() inline ใน choreography จะเกิด dual-write problem

ข้อดี:

  • Loose coupling สูงสุด — Order Service ไม่รู้จัก Payment Service ตรง ๆ, แค่ publish event
  • No single point of failure — ไม่มี orchestrator ที่ล่มได้
  • เพิ่ม service ใหม่ง่าย — subscribe event ใหม่ ไม่ต้องแก้ใคร (เช่น เพิ่ม Loyalty service ที่ listen OrderPlaced → ให้คะแนน)

ข้อเสีย:

  • End-to-end flow มองยาก — ต้องอ่าน code ทุก service เพื่อเห็น flow รวม
  • Cyclic dependency risk — ถ้า A → B → C → A ก็เกิด loop
  • Debug ยากมาก — ต้องใช้ distributed tracing (บท 15) + correlation ID
  • Changes scary — แก้ flow = แก้ event flow หลาย service พร้อมกัน

เหมาะกับ:

  • Saga สั้น (≤ 4 steps)
  • Service ที่ owner คนละทีม + flow ไม่เปลี่ยนบ่อย
  • Use case ที่ "fan-out" — เพิ่ม consumer ใหม่บ่อย

🎼 Orchestration (มี conductor)

Analogy: วงดนตรี classical — มี conductor (วาทยกร) บอกแต่ละคนเล่นเมื่อไหร่, นักดนตรีไม่รู้จักกัน

หลักการ: มี Saga Orchestrator เป็น state machine กลาง → ส่ง command ไปแต่ละ service → รอ result → ส่ง command ถัดไป

Orchestrator มี state machine ชัดเจน:

💡 Temporal คืออะไร (เจอครั้งแรกในบทนี้): Temporal คือ workflow orchestration engine (โปรแกรมกลางที่คุมลำดับขั้นตอนของ workflow ให้) — เขียนโค้ด saga เป็น Java ธรรมดา (@WorkflowMethod, @Workflow annotation) แล้ว Temporal จัดการ retry / timeout / เก็บ state ระหว่างรอให้อัตโนมัติ แม้ process จะ crash กลางทาง Temporal ก็จำได้ว่าทำถึงไหนแล้ว และมี Saga API ในตัวสำหรับลงทะเบียน compensation ของแต่ละ step (ดูตัวอย่างเต็มที่ §2.4)

java
// Pseudo-code
@SagaState
enum OrderSagaState {
    STARTED, STOCK_RESERVED, PAYMENT_CAPTURED, SHIPPED,
    COMPENSATING, FAILED, COMPLETED
}

// Temporal workflow — ใช้ Saga API ของ Temporal (ดู §2.4 สำหรับตัวอย่างเต็ม)
@WorkflowMethod
public void runOrderSaga(Order order) {
    Saga saga = new Saga(new Saga.Options.Builder().build());
    try {
        inventoryActivities.reserveStock(order);   // step 1
        saga.addCompensation(() -> inventoryActivities.releaseStock(order));

        paymentActivities.charge(order);            // step 2
        saga.addCompensation(() -> paymentActivities.refund(order));

        shippingActivities.createShipment(order);   // step 3
        orderActivities.markCompleted(order);       // done
    } catch (ActivityFailure e) {
        // saga.compensate() จะ run compensation ตามลำดับย้อนกลับให้อัตโนมัติ
        // ไม่ต้องใช้ flag ติดตามเองว่า step ไหนทำสำเร็จแล้ว
        // (flag เช่น paymentCharged/stockReserved ใน workflow ต้องเป็น
        //  workflow state ที่ deterministic — ใช้ local variable ตรง ๆ ใน catch
        //  จะทำงานไม่ถูกตอน workflow replay)
        saga.compensate();
        orderActivities.markCancelled(order);
    }
}

ข้อดี:

  • Flow มองเห็นชัดในที่เดียว — เปิด orchestrator code → เห็น flow ทั้งหมด
  • Debug ง่าย — Temporal/Camunda มี UI ดู workflow execution
  • Centralized error handling — แก้ retry policy / timeout ที่เดียว
  • Visibility สูง — ดู saga status ได้แบบ realtime

ข้อเสีย:

  • Orchestrator เป็น single dependency — ทุก service ต้อง trust orchestrator
  • Coupling ผูกกับ orchestrator — service ต้องรับ command interface ของ orchestrator
  • Orchestrator ใหญ่ขึ้นเรื่อย ๆ — รวมหลาย workflow → กลายเป็น "smart god service"

เหมาะกับ:

  • Saga ยาว (≥ 5 steps)
  • Flow ซับซ้อน — มี conditional branching, parallel steps
  • ต้องการ visibility / audit สูง (financial, healthcare)
  • ทีมพร้อมเอา workflow engine (Temporal, Camunda) มาใช้

2.2.5 เปรียบเทียบ Choreography vs Orchestration

Aspect🕺 Choreography🎼 Orchestration
CouplingLoose (event-driven)Tighter (command-based)
Flow visibilityกระจาย — ต้อง traceCentralized — เห็นใน 1 ที่
Debugยาก (need tracing)ง่าย (workflow UI)
Add new stepง่าย (เพิ่ม subscriber)ต้องแก้ orchestrator
Change flowยาก (กระจาย)ง่าย (แก้ใน orchestrator)
Single point of failureไม่มีOrchestrator (แต่ Temporal HA ได้)
Complexity at small scale⭐⭐⭐⭐⭐ (overhead workflow engine)
Complexity at large scale⭐⭐⭐⭐⭐ (cyclic, hard track)⭐⭐ (scale ดี)
Best for steps≤ 4≥ 5
Best for complexitySimple linear flowComplex (branching, parallel)

🎯 Decision tree — เลือก style ไหน

text
มี business process ที่ต้องการ saga →

Q1: จำนวน steps?
  ≤ 4 steps → ไป Q2
  ≥ 5 steps → Orchestration (ใช้ Temporal/Camunda)

Q2: Flow มี branching / parallel / wait-for-event ไหม?
  ❌ Linear ตรง ๆ → ไป Q3
  ✅ ซับซ้อน → Orchestration

Q3: ทีม dev มี exposure กับ workflow engine ไหม?
  ❌ ไม่มี + project เล็ก → Choreography (เริ่มง่ายกว่า)
  ✅ มี + พร้อมเรียน → Orchestration (long-term winner)

Q4: ต้องการ visibility / audit สูงไหม? (banking, healthcare)
  ✅ → Orchestration เสมอ
  ❌ → Choreography OK

2026 recommendation: ใน greenfield project ที่ business logic ซับซ้อน — เลือก Orchestration ด้วย Temporal เกือบทุกครั้ง. เห็น flow ชัด + retry/timeout/versioning built-in + scale ได้ดี

ใน project เก่าที่มี event-driven อยู่แล้ว — Choreography ก็ได้ ถ้าจำนวน step น้อยและ flow stable


⚠️ Mixed mode — ใช้ทั้ง 2 styles

ใน production จริง บ่อยครั้งจะ mix:

text
Big picture: Orchestration (Temporal)
  └─ Step "process payment": Choreography (PaymentEvents → Loyalty, Notification, Audit)
                              ← fan-out ใช้ events เพราะหลายระบบสนใจ

กฎทอง: Orchestrate critical path, Choreograph fan-out — flow หลักที่ต้อง consistency ใช้ orchestration, side-effect ที่ broadcast ใช้ choreography

2.3 Choreography Example (Spring + Kafka)

Outbox pattern ใน 4 ภาษา — pattern เดียวกัน เปลี่ยนแค่ syntax (เลือกอ่านเฉพาะภาษาที่ถนัด — logic เหมือนกันทุกภาษา; หมายเหตุ: ::: tabs เป็น syntax ของ VitePress — ใน GitHub Preview จะเห็นเป็น plain text แต่เนื้อหาครบ):

java
@Transactional
public Order place(CreateOrderCommand cmd) {
    Order o = orderRepo.save(new Order(cmd, OrderStatus.PLACED));
    // เวอร์ชันย่อเพื่อโชว์ concept — ตัวเต็ม (พร้อม aggregate_type/headers)
    // อยู่ที่ §3.2 ด้านล่าง, toJson(...) คือ helper แปลง object → JSON string
    outboxRepo.save(new OutboxEvent(
        UUID.randomUUID(), "Order", o.id().toString(), "OrderPlaced", toJson(o), null));
    return o;
}

หัวใจของ Outbox 4 ภาษา: insert "งานหลัก" (orders) + insert "event" (outbox) ใน transaction เดียวatomic. มี relay service แยกอ่าน outbox table แล้ว publish ไป Kafka (จะลงรายละเอียดด้านล่าง)


ตัวอย่าง choreography จริง — แต่ละ service ฟัง event ของกันและกันแล้วทำงานต่อโดยไม่มีตัวกลางสั่งการ: order → inventory จองของ → payment ตัดเงิน → ถ้าพลาดก็ปล่อย event ให้คนอื่น compensate สังเกตว่าทุก service เขียน event ผ่าน outbox ใน transaction เดียวกับงานหลัก:

Order Service

java
@Service
public class OrderService {
    @Transactional
    public Order place(CreateOrderCommand cmd) {
        Order o = orderRepo.save(new Order(cmd, OrderStatus.PLACED));
        // toJson(...) = helper แปลง object → JSON string; constructor เต็มดู §3.2
        outboxRepo.save(new OutboxEvent(
            UUID.randomUUID(), "Order", o.id().toString(), "OrderPlaced", toJson(o), null));
        return o;
    }
}

@Component
public class OrderEventsHandler {

    @KafkaListener(topics = "payment-events")
    @Transactional
    public void onPaymentEvent(PaymentEvent event) {
        if (event instanceof PaymentCaptured pc) {
            orderRepo.updateStatus(pc.orderId(), OrderStatus.CONFIRMED);
        } else if (event instanceof PaymentFailed pf) {
            orderRepo.updateStatus(pf.orderId(), OrderStatus.CANCELLED);
            outboxRepo.save(new OutboxEvent("OrderCancelled",
                pf.orderId(), Map.of("reason", pf.reason())));
        }
    }
}

⚠️ Polymorphic deserialization ต้อง config ก่อน — เพื่อให้ pattern matching event instanceof PaymentCaptured pc ทำงาน Jackson ต้องรู้ subtype mapping จากฝั่ง Kafka payload:

java
// sealed interface (ต้องการ Java 17+ — ถ้าใช้ JDK เก่ากว่านี้ใช้ interface ธรรมดาแทนได้ แค่เสีย exhaustive pattern matching)
// = exhaustive pattern matching + Jackson รู้ permits list
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = PaymentCaptured.class, name = "PaymentCaptured"),
    @JsonSubTypes.Type(value = PaymentFailed.class,   name = "PaymentFailed")
})
public sealed interface PaymentEvent permits PaymentCaptured, PaymentFailed {}

public record PaymentCaptured(String orderId, BigDecimal amount) implements PaymentEvent {}
public record PaymentFailed(String orderId, String reason)       implements PaymentEvent {}
  • ใน Spring Kafka ต้องตั้ง JsonDeserializer.TRUSTED_PACKAGES + VALUE_DEFAULT_TYPE หรือใช้ Schema Registry (Avro/Protobuf) ซึ่ง type info มากับ schema id อยู่แล้ว — ไม่ต้อง config Jackson เอง

Inventory Service

java
@KafkaListener(topics = "order-events")
@Transactional
public void onOrderPlaced(OrderPlaced event) {
    boolean ok = stockRepo.tryReserve(event.productId(), event.qty(), event.orderId());
    if (ok) {
        outboxRepo.save(new OutboxEvent("StockReserved", event.orderId(), ...));
    } else {
        outboxRepo.save(new OutboxEvent("StockReservationFailed", event.orderId(), ...));
    }
}

@KafkaListener(topics = "order-events")
@Transactional
public void onOrderCancelled(OrderCancelled event) {
    stockRepo.release(event.orderId());
    outboxRepo.save(new OutboxEvent("StockReleased", event.orderId(), ...));
}

Payment Service

java
@KafkaListener(topics = "inventory-events")
@Transactional
public void onStockReserved(StockReserved event) {
    try {
        paymentGateway.charge(event.orderId(), event.amount());
        paymentRepo.save(new Payment(event.orderId(), CAPTURED));
        outboxRepo.save(new OutboxEvent("PaymentCaptured", event.orderId(), ...));
    } catch (PaymentException e) {
        outboxRepo.save(new OutboxEvent("PaymentFailed", event.orderId(),
            Map.of("reason", e.getMessage())));
    }
}

🚨 Anti-pattern ในโค้ดข้างบน — อย่าทำใน production:paymentGateway.charge() คือ external HTTP call ที่อยู่ใน @Transactional — ขณะที่ HTTP รอ response (อาจนาน 100-500ms+) DB connection ยังถือค้างอยู่. ถ้า connection pool มี 10 connections แล้วมี 10 request พร้อมกัน → pool หมด → service ล่ม cascade

วิธีที่ถูก: เขียนแค่ข้อมูลที่จำเป็นลง DB ใน transaction ก่อน → ปิด transaction → แล้วค่อย call external service นอก transaction; หรือใช้ Outbox pattern: เขียน pending-payment event ลง outbox ใน DB transaction → ให้ process แยกต่างหาก call payment gateway → update status ใน transaction ใหม่

2.4 Orchestration with Temporal

ตรงข้ามกับ choreography คือ orchestration ที่มี "ตัวกลาง" คุม flow ทั้งหมด — Temporal ให้เขียน saga เป็นโค้ดธรรมดาที่อ่านเป็นลำดับขั้น พร้อมลงทะเบียน compensation ของแต่ละ step ถ้าพังกลางทางมันจะ rollback ย้อนกลับให้ และเก็บ state/retry/timeout อัตโนมัติ:

java
@WorkflowInterface
public interface OrderSaga {
    @WorkflowMethod
    OrderResult execute(OrderRequest req);
}

public class OrderSagaImpl implements OrderSaga {

    private final InventoryActivity inventory = Workflow.newActivityStub(...);
    private final PaymentActivity payment = Workflow.newActivityStub(...);
    private final ShippingActivity shipping = Workflow.newActivityStub(...);

    @Override
    public OrderResult execute(OrderRequest req) {
        Saga saga = new Saga();
        try {
            inventory.reserve(req.productId(), req.qty());
            saga.addCompensation(() -> inventory.release(req.productId(), req.qty()));

            payment.charge(req.userId(), req.amount());
            saga.addCompensation(() -> payment.refund(req.userId(), req.amount()));

            shipping.schedule(req);

            return new OrderResult(OK);
        } catch (Exception e) {
            saga.compensate();
            return new OrderResult(FAILED, e.getMessage());
        }
    }
}

→ Temporal เก็บ workflow state + retry/timeout/versioning อัตโนมัติ

2.5 Saga Design Rules

  1. Each step idempotent — retry safe
  2. Each compensation idempotent — compensation อาจ retry
  3. Semantic rollback ≠ DB rollback — เช่น refund ≠ unsign payment (audit ต้องเห็นทั้ง 2 entry)
  4. Timeout — compensation triggered ถ้า step timeout
  5. Visibility — log + trace ทุก step

Part 3: Outbox Pattern

3.1 ปัญหา Dual Write

ทบทวนปัญหาหลักของ event-driven: การเขียน 2 ที่ (DB + Kafka) ในงานเดียวไม่มีทาง atomic — DB สำเร็จแต่ Kafka พังก็ event หาย, หรือ Kafka สำเร็จแต่ DB rollback ก็เกิด event ผี ทั้งสองทางทำให้ข้อมูลสองฝั่งไม่ตรงกัน Outbox pattern คือทางแก้:

java
@Transactional
public void placeOrder(...) {
    orderRepo.save(order);             // DB commit (success)
    kafka.send("orders", event);       // Kafka publish (FAIL!)
    // → Order ใน DB, event หาย
}

// OR

public void placeOrder(...) {
    kafka.send("orders", event);       // publish success
    orderRepo.save(order);             // DB rollback!
    // → Event ผีใน Kafka
}

= "Dual Write Problem" — ไม่มี atomic guarantee ระหว่าง DB + broker

3.2 Solution: Outbox Table

หัวใจของ Outbox คือเขียนทั้งข้อมูลธุรกิจและ event ลง DB เดียวกันใน transaction เดียว — เพิ่มตาราง outbox เก็บ event ที่ยังไม่ส่ง (sent_at IS NULL) เพราะอยู่ใน transaction เดียวกัน จึงการันตีว่า save กับ event เกิดพร้อมกันเสมอ (atomic):

sql
CREATE TABLE outbox (
    id UUID PRIMARY KEY,
    aggregate_type TEXT NOT NULL,
    aggregate_id TEXT NOT NULL,
    event_type TEXT NOT NULL,
    payload JSONB NOT NULL,
    headers JSONB,
    created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
    sent_at TIMESTAMPTZ
);

CREATE INDEX idx_outbox_unsent ON outbox(created_at) WHERE sent_at IS NULL;
java
@Transactional
public Order placeOrder(CreateOrderCommand cmd) {
    Order o = orderRepo.save(new Order(cmd));
    
    OutboxEvent event = new OutboxEvent(
        UUID.randomUUID(),
        "Order",
        o.id().toString(),
        "OrderPlaced",
        toJson(new OrderPlacedEvent(o.id(), o.customerId(), o.total())),
        null
    );
    outboxRepo.save(event);
    
    return o;
    // 1 DB transaction → atomic
}

3.3 Outbox Relay (Polling)

Background process อ่าน unsent events + publish

🚨 Anti-pattern — อย่าทำแบบนี้ (ผิดบ่อยที่สุด):

java
// ❌ ผิด: kafka.send().get() ภายใน @Transactional
@Scheduled(fixedDelay = 1000)
@Transactional                                      // ⚠️ DB transaction เปิดอยู่
public void relayBAD() {
    List<OutboxEvent> events = outboxRepo.findUnsent(100);
    for (OutboxEvent e : events) {
        kafka.send(topic(e), e.aggregateId(), e.payload()).get();  // ⚠️ block!
        outboxRepo.markSent(e.id());
    }
}

ปัญหา: kafka.send().get() block thread รอ Kafka ack — ขณะที่ DB connection ยังถือ transaction ค้างไว้. ถ้า Kafka ช้า/ล่ม → connection pool หมด → service ทั้งตัวล่ม. นี่คือ classic case ของ "ทำ I/O slow ใน DB transaction"

กฎ: อย่าทำ network I/O ที่ block ใน @Transactional — DB connection แพง

📖 ปูพื้นก่อน: row lock / FOR UPDATE คืออะไร — เวลา 2 transaction พยายามอ่าน/แก้ row เดียวกันพร้อมกัน DB จะให้ transaction แรกที่แตะ row นั้น "ล็อก" ไว้ก่อน (คนอื่นต้องรอ) ถ้าเขียน SQL ว่า SELECT ... FOR UPDATE คือสั่ง DB ล็อก row ที่ query เจอไว้เลย ป้องกันคนอื่นมาแก้ row เดียวกันพร้อมกัน — ปกติ lock จะ ปล่อยอัตโนมัติตอน transaction commit หรือ rollback เท่านั้น ส่วน SKIP LOCKED คือบอก DB ว่า "ถ้า row ไหนโดนคนอื่นล็อกอยู่ ให้ข้ามไปเลย อย่ารอ" — มีประโยชน์มากตอนหลาย worker แย่งกัน poll คิวงานพร้อมกัน (แต่ละคนจะได้คนละ row ไม่ชนกัน)

🚨 กับดักที่ snippet เก่าเคยพลาด (สำคัญ — อ่านก่อน):

ใครเคยเห็นโค้ดที่ใช้ เฉพาะ SELECT ... FOR UPDATE SKIP LOCKED แล้ว commit ทันที (ไม่ UPDATE/DELETE row) → มี bug เป็น duplicate ใต้ concurrency เพราะ 3 ข้อนี้ต่อกัน:

  1. Row lock ของ FOR UPDATE ปล่อยตอน commit เท่านั้น — ไม่ได้ปล่อยแค่เพราะ query จบ
  2. ถ้าก่อน commit เราไม่ได้เปลี่ยน status ของ row (ไม่มี UPDATE/DELETE) — row ยังหน้าตาเหมือนเดิมทุกอย่างหลัง commit
  3. Polling รอบหน้า (ไม่ว่า instance เดิมหรือ instance อื่น) จะ "claim" row เดิมซ้ำอีกครั้ง เพราะเงื่อนไข WHERE sent_at IS NULL ยังเป็นจริงอยู่ — SKIP LOCKED กันแค่การชนกัน ระหว่าง transaction ที่เปิดพร้อมกัน ไม่ได้กันการ claim ซ้ำหลังคนแรก commit ไปแล้ว

3 ทางที่ถูก (เลือก 1):

  • A) Claim-by-update (แนะนำสำหรับ polling) — UPDATE outbox SET claimed_at=now(), claimed_by=:instance WHERE id IN (SELECT id ... FOR UPDATE SKIP LOCKED) RETURNING *. claimed_at persist ผ่าน commit เป็น dedup key + ต้องมี reaper job รีคืน row ที่ stuck (claimed_at < now() - 5 min)
  • B) Delete-on-claimDELETE FROM outbox WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED LIMIT ?) RETURNING * → send → commit. ง่ายแต่ "lose-on-crash" (ถ้า crash หลัง delete ก่อน send → event หาย — เหมาะกับ workflow ที่ตามด้วย Inbox ฝั่งรับ + retryable upstream)
  • C) ใช้ Debezium CDC แทน polling เลย — ไม่ต้องเขียน relay code ทั้งบล็อก (ดู Part 3.4 / Part 5) — แนะนำที่สุดถ้า infra พร้อม

ตัวอย่าง A ที่ใช้ได้จริงอยู่ด้านล่าง

Pattern ที่ถูก (A — Claim-by-update): 2-phase processing — transaction สั้น ๆ + claim ที่ persist ผ่าน commit

java
// RowMapper สำหรับแปลง ResultSet → OutboxEvent
@Bean
RowMapper<OutboxEvent> outboxRowMapper() {
    return (rs, rowNum) -> new OutboxEvent(
        (UUID) rs.getObject("id"),
        rs.getString("aggregate_type"),
        rs.getString("aggregate_id"),
        rs.getString("event_type"),
        rs.getString("payload"),
        rs.getString("headers")
    );
}

// ⚠️ Postgres-specific: jdbcTemplate.query() ปกติออกแบบมาสำหรับ SELECT
// แต่ใช้กับ UPDATE ... RETURNING ได้ที่นี่เพราะ pgjdbc driver คืน ResultSet
// กลับมาให้ (ไม่ใช่ JDBC contract มาตรฐานที่ driver อื่นรับประกัน) —
// ถ้าเปลี่ยนไปใช้ DB อื่น (MySQL, SQL Server) วิธีนี้อาจใช้ไม่ได้ ต้องแยกเป็น
// 2 statement (UPDATE แล้ว SELECT แยก) หรือใช้ driver ที่รองรับ generated-keys แทน
// ✅ Phase 1: atomically claim batch (UPDATE persist past commit — ไม่พึ่ง row lock)
@Transactional
public List<OutboxEvent> claimBatch(String instanceId, int limit) {
    return jdbcTemplate.query("""
        UPDATE outbox
           SET claimed_at = now(),
               claimed_by = ?
         WHERE id IN (
               SELECT id FROM outbox
                WHERE sent_at IS NULL
                  AND (claimed_at IS NULL
                       OR claimed_at < now() - interval '5 minutes')   -- reaper ในตัว
                ORDER BY created_at
                LIMIT ?
                FOR UPDATE SKIP LOCKED                                  -- ⭐ กันชนกัน "ระหว่าง" tx เปิด
         )
        RETURNING *
        """, outboxRowMapper, instanceId, limit);
    // commit ที่นี่ → row ตอนนี้มี claimed_at + claimed_by ติดอยู่
    // poller รอบหน้าจะมองข้าม (claimed_at ใหม่กว่า now() - 5 min)
}

// ✅ Phase 2: send to Kafka OUTSIDE any DB transaction
@Scheduled(fixedDelay = 1000)
public void relay() {
    List<OutboxEvent> events = claimBatch(instanceId, 100);   // tx จบทันที
    for (OutboxEvent e : events) {
        try {
            // I/O slow ตรงนี้ ไม่กระทบ DB connection
            kafka.send(topic(e), e.aggregateId(), e.payload()).get();
            markSent(e.id());                       // Phase 3 — short tx อีกอัน
        } catch (Exception ex) {
            log.error("send fail, will retry next tick", ex);
            // ไม่ break — ลอง event อื่นต่อ
            // (event ที่ fail จะถูก reaper ปล่อยคืนหลัง 5 นาทีแล้ว claim ใหม่)
        }
    }
}

// ✅ Phase 3: mark sent in its own short transaction
@Transactional
public void markSent(UUID id) {
    jdbcTemplate.update("UPDATE outbox SET sent_at = now() WHERE id = ?", id);
}

ทำไมแบบนี้ถูก:

  1. UPDATE ... SET claimed_at = now() → ค่า claim persist หลัง commit (ไม่พึ่ง row lock) → poller รอบหน้า/instance อื่นจะข้าม row นี้ตาม WHERE claimed_at IS NULL OR claimed_at < now() - 5 min
  2. FOR UPDATE SKIP LOCKED ภายใน subquery → ยังกันการชนกัน "ระหว่าง" 2 instance ที่เปิด tx พร้อมกัน
  3. Reaper logic ใน condition (claimed_at < now() - interval '5 minutes') → ถ้า relay crash ระหว่าง send, event ถูกปล่อยคืน claim รอบใหม่อัตโนมัติ — ไม่ต้องมี cleanup job แยก
  4. kafka.send().get() ทำ นอก DB transaction → ไม่ถือ connection ค้าง
  5. Kafka idempotent producer (Part 3.5) + consumer ใช้ Inbox (Part 4) → ถ้า crash หลัง send ก่อน markSent → tick หน้า claim ใหม่แล้ว send ซ้ำ แต่ consumer dedup ได้

💡 Performance tip — partial index บังคับสำหรับ scale: ที่ volume สูง (>1M outbox rows) ให้สร้าง partial index ลด scan:

sql
CREATE INDEX idx_outbox_unsent ON outbox(created_at)
  WHERE sent_at IS NULL;

ทำให้ SELECT ... WHERE sent_at IS NULL ORDER BY created_at LIMIT ? FOR UPDATE SKIP LOCKED ใช้ index scan แทน seq scan + ขนาด index เล็กกว่ามาก (เก็บแค่ row ที่ยังไม่ส่ง)

⚠️ เพิ่มเติม:

  • Order preserving — ภายใน aggregate_id เดียวกันต้องเรียงตาม created_at; ใช้ aggregate_id เป็น Kafka partition key
  • Cleanup — ลบ sent events เก่า (ดู war story ที่ Part 8.5)
  • ทางเลือกที่ดีกว่า: ใช้ Debezium CDC (Part 3.4 / Part 5) → ไม่ต้องเขียน relay code เลย ลด surface ที่จะพลาด

3.4 Outbox Relay (CDC) — แนะนำกว่า polling

ใช้ Debezium อ่าน Postgres WAL → publish Kafka อัตโนมัติ:

text
1. Service A saves order + outbox row (DB transaction)
2. Postgres writes WAL
3. Debezium reads WAL → publish to Kafka
4. Service A doesn't run relay code!

ดูเรื่อง CDC ที่ Part 5

3.5 Outbox + Idempotent Publish

ตัว relay ที่อ่าน outbox ไปส่ง Kafka อาจ retry ได้ (เช่น crash หลังส่งแต่ก่อน mark sent) จึงต้องเปิด idempotent producer ของ Kafka ด้วย ไม่งั้นจะเกิด event ซ้ำใน Kafka เมื่อรวมกันแล้วได้ exactly-once ภายใน Kafka:

Producer ต้อง idempotent (Kafka idempotent producer) — ไม่งั้น retry = duplicate ใน Kafka

yaml
# default = true ใน Kafka 3.0+ — ระบุชัดเจนเพื่อความชัดของ intent
spring.kafka.producer.properties.enable.idempotence: true

→ ระหว่าง outbox → Kafka exactly-once (within Kafka)


Part 4: Inbox Pattern

4.1 ปัญหา Idempotent Consumer ที่ Scale

จากบท 06 — idempotent consumer ใช้ dedup table

java
@Transactional
public void handle(OrderEvent e) {
    if (processedRepo.existsById(e.id())) return;
    processedRepo.save(new ProcessedEvent(e.id()));
    doWork(e);
}

ปัญหา:

  • Dedup table อาจ contention สูง
  • ขนาดโต — ต้อง TTL/purge
  • ถ้า broker delivery ส่งซ้ำหลังนาน (e.g. 1 hr) — dedup table ต้องเก็บนาน

4.2 Inbox Solution

Inbox pattern คือ Outbox ฝั่งรับ — ฝั่ง consumer มีตาราง inbox เก็บ event_id ที่เคยรับ การ insert event_id (ที่เป็น PRIMARY KEY) จะ fail ถ้าซ้ำ ใช้กลไกนี้ทำ dedup + business logic ใน transaction เดียว แทน dedup table ที่ contention สูง:

sql
CREATE TABLE inbox (
    event_id UUID PRIMARY KEY,
    source TEXT,
    received_at TIMESTAMPTZ DEFAULT now()
);
java
@Transactional
public void handle(OrderEvent e) {
    try {
        inboxRepo.insert(new InboxEvent(e.id(), e.source()));
    } catch (DataIntegrityViolationException dup) {
        return;     // already processed
    }
    doWork(e);
}

→ Insert into inbox table = atomic dedup. ใน DB transaction ก็ทำ business

4.3 Inbox + Outbox = Reliable Messaging

เมื่อรวม Outbox (ฝั่งส่ง) กับ Inbox (ฝั่งรับ) เข้าด้วยกัน จะได้ messaging ที่เชื่อถือได้ end-to-end — ฝั่งส่งการันตีว่า event ออกแน่ ฝั่งรับการันตีว่า process ครั้งเดียว ผลรวมคือ "effective exactly-once" แม้ broker จะเป็นแค่ at-least-once:

text
Service A:
  Transaction:
    INSERT business
    INSERT outbox
  COMMIT
  → Debezium → Kafka

Service B:
  Receive from Kafka
  Transaction:
    INSERT inbox  ← dedup
    INSERT business (apply)
    INSERT outbox (if needs to publish further)
  COMMIT

→ end-to-end at-least-once + idempotent = effective exactly-once


Part 4.5: Idempotency-Key Pattern (HTTP Sync)

สำหรับ sync HTTP API ที่ client retry ได้ — ใช้ pattern เดียวกับ Stripe/AWS

4.5.1 ปัญหา

ปัญหาเดียวกับ event ซ้ำ แต่เกิดกับ HTTP API แบบ sync — client ส่ง POST แล้ว timeout (จริง ๆ server ทำสำเร็จแล้ว) พอ client retry server ก็สร้าง payment ซ้ำเป็น 2 รายการ การ retry ที่ปลอดภัยจึงต้องมีกลไกกันซ้ำ:

text
Client → POST /payments {amount: 100}
        → timeout (network blip)
Client retry → POST /payments {amount: 100}
        → success
Server: 2 payment เกิดขึ้น

4.5.2 Solution: Idempotency-Key (IETF draft + Stripe convention)

⚠️ ข้อมูลแก้ไข 2026-06: มาตรฐาน IETF ที่กำลังร่างคือ draft-ietf-httpapi-idempotency-key-header (ยัง draft ไม่ใช่ RFC — interface อาจเปลี่ยน; Stripe convention เป็น de facto standard ที่ stable กว่า). อย่าสับสนกับ RFC 9457 ซึ่งเป็น "Problem Details for HTTP APIs" (ใช้กับ error response คนละเรื่อง). ในทางปฏิบัติส่วนใหญ่อิงตาม Stripe convention

วิธีมาตรฐาน (Stripe, AWS ใช้) คือให้ client แนบ Idempotency-Key ที่ unique ทุก request — server เช็คว่าเคยเห็น key นี้ไหม ถ้าเคยก็ส่งผลลัพธ์เดิมกลับ (replay) ถ้าไม่เคยจึงประมวลผลจริงแล้ว cache ไว้ retry กี่ครั้งก็ได้ผลเดียว:

Client ส่ง unique key ทุก request:

http
POST /payments
Idempotency-Key: 8e8b3c2a-1b2c-4d5e-9f8a-7b6c5d4e3f2a
Content-Type: application/json

{"amount": 100, "currency": "THB"}

Server:

java
@PostMapping("/payments")
public ResponseEntity<PaymentResponse> create(
        @RequestHeader("Idempotency-Key") @NotBlank String key,
        @RequestBody PaymentRequest req) {

    return idempotencyRepo.findByKey(key)
        .map(saved -> ResponseEntity.status(saved.status())
                                     .body(saved.response()))    // replay
        .orElseGet(() -> {
            // First time — process + cache
            PaymentResponse resp = paymentService.charge(req);
            idempotencyRepo.save(new IdempotencyRecord(
                key,
                hashRequest(req),       // verify same body
                resp,
                201,
                Instant.now().plus(24, HOURS)));
            return ResponseEntity.status(201).body(resp);
        });
}

🚨 Race condition ที่ pattern นี้พลาด — และวิธีแก้:

โค้ดข้างบนทำ findByKey → process → save แบบไม่ atomic. ใน high-concurrency 2 request พร้อมกันที่ key เดียวกัน → ทั้งคู่ผ่าน findByKey (เจอ empty) → ทั้งคู่ charge → save → duplicate payment (PRIMARY KEY ที่ save จะ throw แต่หลังจาก charge ไปแล้ว — เงินถูกตัด 2 ครั้ง)

แก้ด้วย "INSERT first, charge second":

java
// ✅ Reserve key ก่อน charge — ใช้ unique constraint เป็น race guard
int inserted = jdbc.update("""
    INSERT INTO idempotency(key, request_hash, status_code, response, expires_at)
    VALUES (?, ?, 0, NULL, ?)
    ON CONFLICT (key) DO NOTHING
    """, key, hashRequest(req), Instant.now().plus(24, HOURS));

if (inserted == 0) {
    // คนอื่น claim key ไปแล้ว — รอผลหรือ replay
    return waitOrReplay(key);          // polling จนกว่า status_code != 0
}

// เราเป็นคนแรกที่ claim key สำเร็จ — process จริงได้
PaymentResponse resp = paymentService.charge(req);
jdbc.update("UPDATE idempotency SET status_code=?, response=? WHERE key=?",
            201, toJson(resp), key);

หรือใช้ SERIALIZABLE isolation level (ราคาแพงกว่า). PostgreSQL INSERT ... ON CONFLICT DO NOTHING + ตรวจ affected rows คือ pattern ที่นิยมที่สุด

💡 TTL convention เลือกตาม retry window จริง: Stripe ใช้ 24h, AWS Lambda Powertools ใช้ 1h เป็น default (เหมาะกับ retry window สั้นของ Lambda) — tune ตาม window ที่ client retry จริง อย่าตั้งสั้นกว่า retry window มากสุด ไม่งั้นจะเกิดเหตุการณ์แบบ Stripe 2017 ใน war story

4.5.3 Schema

ตาราง idempotency เก็บผลลัพธ์ของแต่ละ key ไว้ replay — จุดสำคัญคือ request_hash ใช้ตรวจว่า key เดิมแต่ body ต่าง (client ใช้ key ผิด) และ expires_at ตั้ง TTL (เช่น 24 ชม.) เพื่อไม่ให้ตารางโตไม่จบ:

sql
CREATE TABLE idempotency (
    key            TEXT PRIMARY KEY,
    request_hash   TEXT NOT NULL,        -- detect "same key different body"
    response       JSONB NOT NULL,
    status_code    INT NOT NULL,
    created_at     TIMESTAMPTZ DEFAULT now(),
    expires_at     TIMESTAMPTZ NOT NULL  -- 24h TTL
);
CREATE INDEX idx_idempotency_expires ON idempotency(expires_at);

4.5.4 Edge cases

สถานการณ์Response
Key ใหม่, สำเร็จ201 + cache
Key ซ้ำ, body เหมือนเดิมreplay cached response
Key ซ้ำ, body ต่าง422 Unprocessable Entity (ผิดพลาด — client bug)
Key ซ้ำ, request ยังประมวลอยู่409 Conflict + Retry-After
Key หมดอายุ (>24h)treat as new

4.5.5 ใครต้องส่ง key?

ไม่ใช่ทุก endpoint ต้องใช้ idempotency-key — ใช้กับ operation ที่ "เปลี่ยน state และ retry แล้วซ้ำได้" (POST/PUT/PATCH/DELETE เช่น สร้าง payment) ส่วน GET หรือ operation ที่ idempotent ตามนิยาม HTTP อยู่แล้วไม่จำเป็น:

  • ✅ POST/PUT/PATCH/DELETE ที่ mutate state
  • ✅ Payment, order create, message send
  • ❌ GET (already idempotent by HTTP semantics)
  • ❌ Read-only PUT (idempotent ตามนิยาม)

4.5.6 Client library pattern

java
// auto-generate key in HTTP client
public class IdempotencyInterceptor implements ClientHttpRequestInterceptor {
    @Override
    public ClientHttpResponse intercept(HttpRequest req, byte[] body, ClientHttpRequestExecution exec) {
        if (req.getMethod() == HttpMethod.POST
                && !req.getHeaders().containsKey("Idempotency-Key")) {
            req.getHeaders().set("Idempotency-Key", UUID.randomUUID().toString());
        }
        return exec.execute(req, body);
    }
}

💡 ทำที่ HTTP client → application code ไม่ต้องคิด, retry ปลอดภัยอัตโนมัติ


Part 5: Change Data Capture (CDC)

5.1 CDC คืออะไร

Change Data Capture (CDC) คือการดักทุกการเปลี่ยนแปลงใน DB (insert/update/delete) แล้วแปลงเป็น event ส่งเข้า broker อัตโนมัติ — โดยไม่ต้องแก้โค้ด app เลย เพราะอ่านจาก transaction log ของ DB ตรง ๆ ทำให้ DB เป็น source of truth ที่ระบบอื่นรับรู้การเปลี่ยนแปลงได้แบบ real-time:

text
DB write happens → captured as event → published to broker

ไม่ต้อง modify app code → DB เป็น source of truth

5.2 Debezium

Debezium คือ CDC platform ยอดนิยม (open-source) ที่รันเป็น Kafka Connect connector — อ่าน transaction log ของ DB (WAL/binlog/oplog) แล้ว publish การเปลี่ยนแปลงเข้า Kafka รองรับ DB หลักแทบทุกตัว เป็นรากฐานของ Outbox+CDC pattern:

Open-source CDC platform — ทำงานเป็น Kafka Connect connector

Supports (Debezium 3.x baseline):

  • PostgreSQL (via logical replication)
  • MySQL (via binlog)
  • MongoDB (oplog)
  • SQL Server, Oracle, DB2, Cassandra
  • Vitess (sharded MySQL — YouTube/Slack scale)
  • Google Spanner (Debezium 2.x+)
  • Generic JDBC source connector — สำหรับ DB ที่ไม่มี dedicated connector (poll-based; ไม่ใช่ log-based)

5.3 Setup PostgreSQL + Debezium

ตั้ง CDC บน Postgres ต้องเปิด logical replication (wal_level = logical) เพื่อให้ Debezium อ่าน WAL ได้ สร้าง replication slot + publication ระบุตารางที่จะติดตาม จากนั้นลงทะเบียน connector ใน Kafka Connect ชี้มาที่ DB:

📖 คำศัพท์ PostgreSQL สำหรับ CDC:

  • WAL (Write-Ahead Log) = บันทึกทุก change ก่อน commit — Postgres เขียน WAL ก่อนเสมอ เพื่อ crash recovery; Debezium อ่าน WAL นี้เพื่อดักการเปลี่ยนแปลง
  • LSN (Log Sequence Number) = ลำดับตำแหน่งใน WAL — เป็น ID บอกว่า Debezium อ่านมาถึงตำแหน่งไหนแล้ว ใช้ monitor lag

PostgreSQL config (manual approach — มี trade-off ดูหมายเหตุ):

sql
ALTER SYSTEM SET wal_level = logical;
-- ⚠️ ต้อง restart Postgres ตรงนี้ก่อนทำขั้นต่อ (pg_reload_conf() / SIGHUP ไม่พอ)
-- ถ้าข้าม restart → Debezium จะ error: "wal_level not logical" และ slot ที่สร้างจะ unusable

SELECT pg_create_logical_replication_slot('debezium', 'pgoutput');

CREATE PUBLICATION dbz_pub FOR TABLE outbox, orders, users;

⚠️ wal_level, max_wal_senders, max_replication_slots ทั้งหมดเป็น postmaster parameters → ต้อง restart Postgres (ไม่ใช่ reload). ตรวจหลัง restart: SHOW wal_level; ต้องเป็น logical ก่อนสร้าง slot/publication

💡 ตั้ง wal_sender_timeout ป้องกัน WAL sender ค้าง:

sql
ALTER SYSTEM SET wal_sender_timeout = '60s';  -- ถ้า client (Debezium) เงียบเกิน 60s → terminate sender

ช่วยให้ Postgres ปล่อย resource เร็วเมื่อ Debezium ล่มกะทันหัน — ไม่ปล่อยค้างจนเกิด WAL retention bloat

Monitor slot lag เป็นนิสัย:

sql
SELECT slot_name, active, confirmed_flush_lsn,
       pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes
  FROM pg_replication_slots;

💡 Debezium 2.x/3.x จัดการให้อัตโนมัติได้ — ตั้งให้ connector สร้าง slot + publication เองดีกว่าทำมือ (กัน conflict + maintenance น้อยกว่า):

properties
# ใน connector config — ปล่อยให้ Debezium จัดการ
slot.name=debezium
publication.name=dbz_pub
publication.autocreate.mode=filtered    # ค่า: disabled | all_tables | filtered
table.include.list=public.outbox,public.orders,public.users

publication.autocreate.mode ค่าคืออะไร:

  • disabled — Debezium ไม่สร้าง publication ให้ ต้อง pre-create เอง (production strict)
  • all_tables — สร้าง FOR ALL TABLES (ทุกตารางใน DB — มักไม่ใช่สิ่งที่ต้องการ)
  • filtered — สร้าง publication ที่ รวมเฉพาะตารางใน table.include.list (แนะนำ — minimal surface)

เพิ่ม table ใหม่ → แก้แค่ table.include.list แล้ว restart connector — Debezium แก้ PUBLICATION ให้

💡 Snapshot modes (Debezium 3.x):

  • initial — snapshot ครั้งเดียวตอน connector start ครั้งแรก แล้วตามด้วย WAL streaming (default)
  • never — ข้าม snapshot, อ่าน WAL ตั้งแต่ slot creation
  • when_needed — snapshot เฉพาะเมื่อ Debezium คิดว่าจำเป็น (เช่น slot หาย)
  • initial_only — snapshot อย่างเดียว ไม่ stream WAL ต่อ (ใช้ migrate ครั้งเดียว)
  • incremental (ใหม่ใน 2.x, mature ใน 3.x) — snapshot ทีละ chunk ทำในขณะที่ stream WAL ไปด้วย → ไม่ block + ทยอย backfill

สำหรับ ad-hoc snapshot (ระหว่างรัน):

properties
signal.data.collection=public.debezium_signal

แล้ว INSERT INTO debezium_signal (id, type, data) VALUES ('snap-1', 'execute-snapshot', '{"data-collections":["public.orders"]}'); → Debezium snapshot ตาราง orders โดยไม่ restart connector

Kafka Connect connector:

bash
curl -X POST http://connect:8083/connectors \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "shop-source",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "plugin.name": "pgoutput",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "secret",
    "database.dbname": "shop",
    "publication.name": "dbz_pub",
    "slot.name": "debezium",
    "topic.prefix": "shop",
    "table.include.list": "public.outbox,public.orders"
  }
}'

5.4 Event Message Format

Debezium publish ตัวอย่าง:

json
{
  "schema": {...},
  "payload": {
    "before": null,
    "after": {
      "id": "ord-123",
      "customer_id": "cust-1",
      "total": 99.5,
      "status": "PLACED"
    },
    "source": {
      "db": "shop",
      "table": "orders",
      "ts_ms": 1716172800000,
      "lsn": 12345
    },
    "op": "c",                     // c=create, u=update, d=delete, r=read snapshot
    "ts_ms": 1716172800500
  }
}

แทนที่จะใช้ Debezium format ดิบ → wrap ใน CloudEvents envelope สำหรับ standardize:

json
{
  "specversion": "1.0",
  "type": "com.acme.order.created",
  "source": "/cdc/shop/orders",
  "id": "evt-ord-123-c-1716172800500",
  "time": "2026-05-20T00:00:00Z",
  "datacontenttype": "application/json",
  "subject": "ord-123",
  "traceparent": "00-abc-def-01",

  "data": {
    "before": null,
    "after": {
      "id": "ord-123",
      "customer_id": "cust-1",
      "total": 99.5,
      "status": "PLACED"
    },
    "op": "c"
  }
}

Debezium มี built-in สำหรับ wrap CloudEvents — โดยใช้ CloudEventsConverter เป็น value.converter (ไม่ใช่ SMT chain). Outbox routing ทำด้วย EventRouter SMT แยกต่างหาก:

properties
# debezium connector config — Outbox SMT + CloudEvents converter
transforms=outbox

# 1. Outbox router (SMT) — แตก row ของ outbox table เป็น Kafka record + กำหนด topic
transforms.outbox.type=io.debezium.transforms.outbox.EventRouter
transforms.outbox.table.field.event.id=id
transforms.outbox.table.field.event.key=aggregate_id
transforms.outbox.table.field.event.type=event_type
transforms.outbox.route.by.field=aggregate_type
transforms.outbox.route.topic.replacement=${routedByValue}.events

# 2. CloudEvents wrapping ทำที่ converter — ไม่ใช่ SMT
value.converter=io.debezium.converters.CloudEventsConverter
value.converter.serializer.type=json
value.converter.data.serializer.type=json
# (ถ้าใช้ Avro: serializer.type=avro + ตั้ง schema.registry.url)

# key converter ปกติ
key.converter=org.apache.kafka.connect.storage.StringConverter

⚠️ ข้อมูลแก้ไข 2026-06: edition ก่อนหน้าเขียน io.debezium.transforms.outbox.EventRouterCloudEvents ซึ่ง class นี้ไม่มีอยู่ใน Debezium mainline (ทั้ง 2.x และ 3.x) — ถ้าใส่ในจริง connector จะ start ไม่ติดด้วย ClassNotFoundException. ของจริงคือ:

  • SMT: io.debezium.transforms.outbox.EventRouter (outbox routing)
  • Converter: io.debezium.converters.CloudEventsConverter (CloudEvents envelope — ตั้งเป็น value.converter ไม่ใช่ chain เป็น SMT)

2 ตัวนี้ใช้ร่วมกันได้ และเป็นวิธี recommended สำหรับ CloudEvents output จาก Debezium

💡 ดู Part 12.3 ใน บท 06 สำหรับ CloudEvents เต็ม + AsyncAPI document spec


นี่คือรูปแบบที่แนะนำที่สุดในการ publish event อย่างเชื่อถือได้ — รวม Outbox กับ CDC: service แค่เขียน orders + outbox ลง DB (atomic) ส่วน Debezium อ่าน outbox จาก WAL ไป publish Kafka ให้เอง ทำให้ service ไม่ต้องแตะ Kafka ตรง ๆ เลย ลด coupling และไม่มี dual write:

→ Service A ไม่ต้องเรียก Kafka ตรง ๆ — แค่ write DB

5.6 ⚠️ CDC Caveats

  • 🚨 WAL slot ที่ inactive ใน Postgres = ปัญหารุนแรงกว่าแค่ "disk เต็ม":

    • ถ้า Debezium ล่ม + restart นาน → slot ไม่ถูก consume → Postgres เก็บ WAL ไว้ทั้งหมด → disk เต็ม
    • ที่ร้ายกว่า: inactive slot ยัง block autovacuum ของทุก table ที่อยู่ใน publication → catalog bloat (system catalog โต ทำให้ query ทุก query ช้า) + เสี่ยง transaction ID wraparound (Postgres ใช้ 32-bit transaction id; ถ้า vacuum ไม่ทำงาน 2 พันล้าน tx, DB จะ refuse write เพื่อกัน corrupt — เคยเป็นเหตุ outage ของ Sentry, Joyent)
    • กฎ: ตั้ง alert ที่ pg_replication_slots.confirmed_flush_lsn lag > X เสมอ + มี runbook auto-drop slot ถ้า lag เกิน threshold (ยอม data loss > DB ล่มทั้งหมด — drop slot แล้ว reseed Debezium จาก snapshot ใหม่)
    • Monitor pg_stat_replication + pg_replication_slots.active = true ทุกตัวที่สร้าง
  • Schema change ต้อง coordinate (drop column = consumer พัง — ใช้ Schema Registry + backward compatibility)

  • Initial snapshot อาจ heavy — ตาราง 100GB อาจใช้เวลาชั่วโมง + lock — ใช้ snapshot.mode=initial_only + read replica สำหรับ snapshot


Part 6: Event Sourcing (Intro)

6.1 หลักการ

แทนที่จะเก็บ "current state" ของ entity:

text
users table:
  id=1, name=Alice, age=25

→ เก็บ "event ที่ทำให้ state เปลี่ยน":

text
user_events:
  id=1, type=UserRegistered, data={name:"Alice", age:24}
  id=1, type=BirthdayCelebrated, data={newAge:25}
  id=1, type=NameChanged, data={oldName:"Alice", newName:"Alice S."}

Current state = replay events จากต้น:

java
User user = new User();
events.forEach(user::apply);

6.2 ข้อดี

ข้อดีหลักของ event sourcing มาจากการที่เราเก็บ "ประวัติทุกการเปลี่ยนแปลง" ไว้ครบ — ได้ audit log ฟรี, ย้อนดู state ณ เวลาใดก็ได้ (time travel), สร้าง read model ได้หลายแบบ (CQRS) และ debug ด้วยการ replay event ซ้ำ:

  • Audit log built-in — เห็นทุก state change
  • Time travel — replay ถึงจุดใดก็ได้
  • Multiple read models (CQRS — บท 14)
  • Debug + replay bug

6.3 ข้อเสีย

แต่ event sourcing ก็มีราคาที่ต้องจ่าย — ซับซ้อนกว่ามาก (ต้องจัดการ schema versioning, snapshot), storage โตเพราะเก็บ event ไว้ตลอด, และการดึง current state ต้อง replay/project ซึ่งช้าถ้าไม่ทำ snapshot อย่าใช้ถ้าไม่จำเป็นจริง:

  • Complexity — schema evolution, versioning, snapshot
  • Storage — events เก็บตลอด
  • Query — current state ต้อง project (slow ถ้าไม่ snapshot)

6.4 Snapshot

ปัญหา "replay ช้า" ของ event sourcing แก้ด้วย snapshot — บันทึก state สำเร็จรูป ณ event ที่ N ไว้ พอจะ rebuild ก็เริ่มจาก snapshot แล้ว replay เฉพาะ event ที่เกิดหลังจากนั้น ไม่ต้องไล่จากศูนย์ทุกครั้ง:

text
Events: 0 1 2 3 ... 1000
Snapshot @ event 800
Events 801 1000 (replay จาก snapshot)

→ ไม่ต้อง replay ทั้งหมด

6.5 Tools

ไม่ต้องสร้าง event store เองจากศูนย์ — มีเครื่องมือสำเร็จ: EventStoreDB (ออกแบบมาเพื่อ event sourcing โดยเฉพาะ), Axon Framework (สำหรับ Java/Spring), หรือใช้ Kafka + โค้ดเอง ส่วนบน AWS ก็มี DynamoDB Streams:

  • EventStoreDB — purpose-built
  • Axon Framework — Java/Spring framework
  • Kafka + custom — manual
  • AWS DynamoDB Streams — managed

ดูลึกบท 14 (Data Management + CQRS)


Part 7: Putting It All Together — Full Example

E-commerce order flow:

text
1. POST /orders (HTTP sync)
   Order Service:
     Transaction:
       INSERT orders (status=PLACED)
       INSERT outbox (OrderPlaced event)
     COMMIT
     
2. Debezium captures outbox INSERT → Kafka "orders.events"

3. Inventory Service consumes:
   Transaction:
     INSERT inbox (event_id)
     UPDATE stock SET reserved = reserved + qty
     INSERT outbox (StockReserved event)
   COMMIT
   
4. Debezium captures → Kafka "inventory.events"

5. Payment Service consumes:
   Transaction:
     INSERT inbox
     INSERT payment (CAPTURED)
     INSERT outbox (PaymentCaptured)
   COMMIT
   
6. Kafka "payment.events"

7. Order Service consumes own event:
   Transaction:
     INSERT inbox
     UPDATE orders SET status=CONFIRMED
   COMMIT

8. Shipping Service consumes PaymentCaptured:
   ...

→ Atomic at each service + at-least-once + idempotent + auditable


Part 8: Anti-patterns

8.1 Dual Write

เขียน DB + broker แยกกันโดยไม่มี atomic = ข้อมูลสองฝั่งหลุดกัน ทางแก้คือใช้ Outbox pattern เขียนทุกอย่างลง DB ที่เดียวก่อน:

ใช้ Outbox

8.2 No Idempotency

ลืมกัน message ซ้ำ = บั๊กแน่นอน เพราะ broker เป็น at-least-once ทางแก้คือทำ Inbox / dedup ที่ consumer:

ใช้ Inbox / dedup

8.3 Tight Saga Coupling

ถ้าทุก service ต้องรู้ flow ทั้งหมดของ saga ระบบจะกลายเป็น distributed monolith ที่แก้ที่เดียวกระทบทุกที่ — ออกแบบให้แต่ละ service รู้แค่ส่วนของตัวเอง:

Saga คุณก็เป็น distributed monolith ถ้า ทุก service รู้ flow

8.4 Compensation ที่ผิด

compensation ต้องเป็น "การกระทำย้อนกลับเชิงธุรกิจ" ไม่ใช่ลบข้อมูลทิ้ง — เช่น refund ต้องเก็บ audit ทั้งการจ่ายและการคืน ไม่ใช่ DELETE แถวจ่ายเงินทิ้งเหมือนไม่เคยเกิด:

Refund ≠ DELETE payment row — เก็บ audit

8.5 Long-running Sync

ให้ user รอจน saga ทั้งชุดเสร็จ = block นานและ UX แย่ ควรตอบ ack ทันทีแล้วแจ้งสถานะภายหลัง (websocket/polling):

รอ saga จบ → block user

ใช้ async (return ack ทันที + websocket/polling for status)

8.6 No Schema

event payload ที่ไม่มี contract = ทุกการเปลี่ยน schema จะ break consumer เงียบ ๆ ควรนิยาม schema กลาง (Avro/Protobuf) + Schema Registry บังคับ compatibility:

Event payload ไม่มี contract — ทุก change break consumer

ใช้ Avro/Protobuf + Schema Registry

8.7 Synchronous Within Workflow

ใน orchestrator call HTTP sync → block + slow

ใช้ activity worker pattern (Temporal)


Part 8.5: Production War Stories

🔥 Square (2019): saga compensation race ทำ double refund $400K

Order saga: charge → ship → fail at ship → compensate refund. แต่ user click "cancel" พร้อมกัน → 2 refund commands → user ได้เงินคืน 2 เท่า

Lesson:

  • Compensation ต้อง idempotent + ใช้ compensation_id (UUID per saga instance) เป็น dedup key
  • State machine ใน orchestrator → bar concurrent transitions
  • ใช้ Temporal (built-in idempotent activity) แทน hand-rolled

🔥 บริษัท e-commerce (2023): outbox table 200GB ใน 3 เดือน

ไม่ลบ sent events → table โต → query WHERE sent_at IS NULL slow → relay lag → cascading

Lesson:

  • Outbox cleanup job ทุก 1-6 ชม. (DELETE WHERE sent_at < now() - interval '7 day')
  • ใช้ partitioned table (per day) → drop partition แทน delete
  • Monitor outbox row count, alert > 100K unsent

🔥 Stripe (2017): Idempotency-Key cache TTL ทำ payment ซ้ำ

ℹ️ Disclaimer: เรื่องนี้เป็น pattern-based learning จากการสังเกต Idempotency-Key implementations ที่ผิดบ่อย ไม่ใช่ official postmortem ที่ Stripe ประกาศ — ใช้เป็น mental model ของ failure mode ที่เคยเจอจริงในหลายบริษัท ไม่ใช่ historical record ของ Stripe โดยตรง

TTL 1 ชม. — user retry หลัง 2 ชม. → key หมดอายุ → กลายเป็น new request → charge ซ้ำ

Lesson:

  • TTL ต้อง ≥ 24 ชม. (Stripe ปัจจุบันใช้ 24h, AWS ใช้ 10 min สำหรับ ec2)
  • Documented TTL ให้ client รู้
  • Client retry ต้องไม่เกิน TTL window

🔥 Capital One (2021): Debezium WAL slot ทำ Postgres disk เต็ม

Debezium pod ตาย 12 ชม. → WAL ไม่ถูก consume → Postgres เก็บ WAL ต่อ → disk เต็ม → DB หยุดทั้ง cluster

Lesson:

  • Monitor pg_replication_slots.confirmed_flush_lsn lag
  • Alert ถ้า WAL lag > threshold
  • Debezium HA: 2+ replica + leader election
  • Disable replication slot ทันทีถ้า Debezium ล่มนาน (เลือก data loss > DB ล่ม)

Part 8.6: 📋 Cheat Sheet

Pattern decision tree

Outbox + CDC checklist

text
☐ outbox table มี index (created_at) WHERE sent_at IS NULL
☐ outbox cleanup job (delete sent > 7 day)
☐ partitioned by day (optional, สำหรับ throughput สูง)
☐ Debezium 2+ replica + monitoring WAL lag
☐ Outbox SMT (Single Message Transform) route ตาม aggregate_type
☐ Idempotent producer Kafka (enable.idempotence=true)
☐ Consumer inbox table (event_id PK)
☐ Schema Registry (Avro/Proto) สำหรับ outbox payload
☐ Alert: outbox unsent > 1000 rows for 5 min
☐ Alert: WAL lag > 100MB

Idempotency-Key implementation rules

text
1. Server: HMAC-validate or hash request body → store with key
2. Server: Same key + different body = 422 (not 201)
3. Server: TTL ≥ 24h (Stripe standard)
4. Client: auto-generate UUID per logical request (not per retry)
5. Client library: HTTP interceptor adds header automatically
6. Document TTL in API spec
7. Test: simulate timeout + retry — should not duplicate

Saga design rules

text
1. Each step = local DB transaction (atomic per service)
2. Each step idempotent (retry-safe)
3. Each compensation idempotent
4. Compensation: semantic rollback (refund), not DB rollback
5. State machine in orchestrator — no concurrent state transitions
6. Timeout per step (orchestrator handles)
7. Audit trail per saga instance
8. Visibility: link all spans via baggage (trace_id propagation)
9. Test: kill orchestrator mid-saga → resume on restart
10. Test: compensation duplication → final state same

Part 9: Tools Summary

Toolสำหรับ
TemporalWorkflow orchestration (open source, Go-written) — โดด่นที่สุดในปี 2026
Cadence (Uber)บรรพบุรุษของ Temporal — ยังมี community
Camunda 8 (Zeebe)BPMN workflow รุ่นใหม่ (cloud-native, gRPC) — ต่างจาก Camunda 7 (JVM)
Conductor (Netflix)Workflow microservices — JSON-based DSL
AWS Step FunctionsManaged AWS — JSON state machine
Debezium 3.xCDC จาก DB → Kafka — EventRouter SMT + CloudEventsConverter
Axon FrameworkEvent Sourcing + CQRS framework (Spring/Java)
Eventuate TramSaga + Outbox framework สำหรับ Spring (Chris Richardson)
EventStoreDBEvent-sourcing database (Greg Young)

💡 2026 sweet spot ของ Saga orchestration: Temporal (greenfield, polyglot), Camunda 8 (ต้องการ BPMN visualization สำหรับ BA/business), AWS Step Functions (lock-in กับ AWS ได้, ทีมเล็ก). Choreography (ไม่มี orchestrator) ใช้ event ผ่าน Kafka ตรง ๆ + Outbox ทุก service — เหมาะ flow สั้น ≤3 step


Part 10: Checkpoint

  1. Saga คืออะไร? compensating action ใช้แทน global transaction ยังไง?
  2. Choreography vs Orchestration — เลือกอันไหน?
  3. Outbox pattern แก้ dual write ปัญหายังไง?
  4. Outbox Relay 2 แบบคือ?
  5. Inbox + Outbox = effective exactly-once. ทำไม?
  6. Debezium ทำงานยังไง?
  7. Outbox SMT ของ Debezium ทำอะไร?
  8. Event Sourcing เก็บอะไรแทนอะไร?
  9. Snapshot ใน Event Sourcing แก้ปัญหาอะไร?
  10. Compensation ต้อง idempotent ทำไม?

Part 11: สรุปบทนี้

  • Saga = local transaction + compensation. Orchestration > Choreography (มัก)
  • Outbox = atomic DB save + event publish ผ่าน Debezium CDC
  • Inbox = idempotent consumer ที่ scale
  • Outbox + Inbox = effective exactly-once (end-to-end)
  • 🆕 Idempotency-Key = HTTP sync API retry-safe (Stripe/AWS convention)
  • CDC (Debezium 3.x) = capture DB changes → broker, มี Outbox SMT (EventRouter) + CloudEvents converter built-in
  • Event Sourcing = events เป็น source of truth → state คือ projection
  • Temporal / Camunda = workflow engine สำหรับ complex saga
  • Lessons from production: outbox cleanup, WAL slot monitoring, idempotency TTL ≥ 24h, compensation idempotent

บทถัดไป — Resilience (Circuit Breaker, Bulkhead, Retry budget, Backpressure)


← บทที่ 11 | สารบัญ | บทที่ 13: Resilience →


🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12