Skip to content

บทที่ 3 — Distributed Transactions

← บทที่ 2 | สารบัญ | บทที่ 4 →

หลังจบบท คุณจะ:

  • เข้าใจ 2PC + ปัญหาของมัน
  • ใช้ Saga pattern (orchestration + choreography)
  • เข้าใจ Event Sourcing + CQRS
  • จัดการ idempotency + outbox pattern

1. ทำไม Distributed Transaction ยาก

ใน DB เดียว transaction เป็นเรื่องง่าย — BEGIN...COMMIT แล้ว DB รับประกัน ACID ให้ แต่พอ business operation ต้องแตะหลาย service/DB (order, inventory, payment, email) คำถามคือจะทำให้ "ทั้งหมดสำเร็จ หรือไม่สำเร็จเลย" ได้ยังไง ในเมื่อแต่ละ DB commit แยกกันและ undo ข้าม DB ไม่ได้ — นี่คือโจทย์หลักของทั้งบท:

text
Single DB:
BEGIN
UPDATE accounts SET balance = balance - 100 WHERE id = 1
UPDATE accounts SET balance = balance + 100 WHERE id = 2
COMMIT

ACID guaranteed by DB.

Multiple services / DBs:
- Order service writes to OrderDB
- Inventory service writes to InventoryDB
- Payment service charges Stripe
- Email service sends email

How to make all-or-nothing?
- Step 3 fails → roll back 1 + 2? But how if different DBs?
- Step 2 already committed locally — can't undo

2. ACID in Distributed Context — ACID เมื่อข้ามเครื่อง

ACID ที่เราคุ้นเคย (Atomicity/Consistency/Isolation/Durability) ทำงานได้ดีใน DB เดียว แต่พอข้ามเครื่องมันยากมากและช้า — โลก distributed จึงมักผ่อนเป็น "BASE" (Basically Available, Soft state, Eventually consistent) ที่ยอมแลก consistency ทันทีเพื่อ availability ส่วนนี้เปรียบเทียบสองแนวคิดให้เห็นชัด:

text
ACID (ใน DB เดียว):
- Atomicity: ทำทั้งหมดหรือไม่ทำเลย (all or none)
- Consistency: เคารพกฎความถูกต้อง
- Isolation: transaction ที่ทำพร้อมกันไม่กวนกัน
- Durability: commit แล้ว = บันทึกถาวร ไม่หาย

ACID แบบ distributed? — ยากมาก
- 2PC พยายามทำ (ช้า, blocking)
- หรือผ่อนลงเป็น BASE

BASE (ตรงข้ามแนวคิดกับ ACID — เน้นพร้อมใช้งานก่อน):
- Basically Available — พร้อมใช้งานเป็นหลัก
- Soft state — state อาจเปลี่ยนเองได้แม้ไม่มี input ใหม่
- Eventually consistent — ในที่สุดจะตรงกัน

ACID vs BASE — เปรียบเทียบเป็นตาราง

มุมมองACID (DB เดียว)BASE (distributed)
Atomicityทำทั้งหมด/ไม่ทำเลยBest-effort, eventual
Consistencystrong (ตรงทันที)eventual (ในที่สุดจะตรง)
Isolationtransaction ไม่กวนกันpartial state เห็นได้
Availabilityอาจ block เมื่อ conflict"พร้อมใช้งานก่อน"
ปรัชญาถูกต้อง > พร้อมใช้พร้อมใช้ > ถูกต้องทันที
เหมาะกับบัญชี/การเงิน, single DBfeed, social, cart, microservices

3. Two-Phase Commit (2PC) — protocol สำหรับ atomic transaction ข้าม DB

🔊 อ่านว่า: "2PC" ออกเสียง "ทู-พี-ซี" = Two-Phase Commit

⚠️ สถานะปี 2026: 2PC แทบไม่ใช้ใน online microservices ใหม่แล้ว — slow + blocking + coordinator SPOF ทำให้ทีมส่วนใหญ่ย้ายไป Saga + Outbox + idempotency แทน (รายละเอียดอยู่ในหัวข้อ 5-9) ส่วน 2PC ที่เหลือใช้จริง = legacy XA ในระบบเก่า, batch jobs ที่ไม่ sensitive latency, หรือกรณีพิเศษที่ทุก participant อยู่ data center เดียวกัน

text
ประกอบด้วย Coordinator (ผู้ประสาน) + Participants (ผู้ร่วม)

Phase 1 (Prepare / Vote — เตรียม/โหวต):
Coordinator → "เตรียม commit นะ" → ทุก participant
แต่ละ participant:
- ตรวจสอบ transaction ใน DB ตัวเอง
- ล็อก resource
- เขียน log (ถาวร)
- ตอบ: YES (พร้อม) หรือ NO (ยกเลิก)

Phase 2 (Commit / Abort — ยืนยัน/ยกเลิก):
ถ้าตอบ YES ครบทุกตัว:
    Coordinator → "Commit" → ทุกตัว
    แต่ละตัว: commit จริง, ปลดล็อก
ถ้ามีตัวไหนตอบ NO หรือ timeout:
    Coordinator → "Abort" → ทุกตัว
    แต่ละตัว: rollback, ปลดล็อก

ตัวอย่าง

text
Bank transfer (2PC):
1. Coordinator → Bank A: prepare to debit $100
2. Coordinator → Bank B: prepare to credit $100
3. Both reply YES
4. Coordinator → both: commit

If Bank A says NO (insufficient):
3. Bank A → NO
4. Coordinator → both: abort
5. Bank B undoes prepared credit

ข้อดี / ข้อเสีย

text
✅ Atomicity across systems
✅ ACID-like

❌ Slow (network round trip × N participants × 2 phases)
❌ Blocking (locks held during entire 2PC)
❌ Coordinator = single point of failure
   - Coordinator crashes between phase 1 + 2
   - Participants in "prepared" state, locks held
   - Manual intervention required
❌ Doesn't scale well

XA Transactions — มาตรฐาน 2PC ที่ JTA/Spring ใช้

🔊 อ่านว่า: "XA" ออกเสียง "เอ็กซ์-เอ" = X/Open Distributed Transaction Processing standard

💡 ถ้าไม่ใช่ Java dev: XA = มาตรฐาน 2PC protocol, JTA = Java implementation ของ XA — ข้ามหัวข้อนี้ได้

text
XA = standard 2PC protocol
- Implemented by JTA in Java
- Spring's @Transactional with multiple datasources
- DBs that support: Postgres, MySQL, Oracle
  - Postgres: ต้องตั้ง prepared_transactions > 0 (default = 0)
  - MySQL InnoDB: รองรับ XA แต่ performance penalty + มี known bugs ใน version เก่า
  - Oracle: รองรับเต็ม

Use:
- Same data center
- Few participants
- Strong consistency required

Don't use:
- Microservices (better: saga)
- High throughput
- Cross-region

⚠️ สถานะปี 2026: XA ถือเป็น "deprecated by practice" สำหรับ microservices ใหม่ — ทีมส่วนใหญ่ใช้ Saga + Outbox แทน Spring Boot 3.x ยังคง JTA support แต่ใช้กันน้อยลงมาก, Atomikos (JTA implementation หลัก) อยู่ใน maintenance mode, ทางเลือกใหม่คือ ChainedTransactionManager (best-effort) หรือ transactional outbox pattern


4. Three-Phase Commit (3PC) — "พยายามแก้ blocking ของ 2PC"

2PC มีปัญหา: ถ้า coordinator ตายหลัง phase 1 — participants ทุกตัวจะ "ค้าง" รอคำสั่ง (locked resources) 3PC พยายามแก้โดยเพิ่ม phase กลาง

Flow ของ 3PC

text
Phase 1: CAN-COMMIT (เหมือน 2PC's PREPARE)
Coordinator → "พร้อม commit ไหม?" → ทุก participant
participant → "พร้อม" หรือ "ไม่พร้อม"

Phase 2: PRE-COMMIT (intermediate — ใหม่!)
ถ้าทุกตัวพร้อม:
   Coordinator → "ทุกคนตอบพร้อม จะ commit แล้ว" → ทุก participant
   participant: บันทึก "เตรียม commit" + ACK

Phase 3: DO-COMMIT
Coordinator → "Commit เลย" → ทุก participant
participant: commit จริง + release locks

ดียังไง — ลดการ blocking

text
ถ้า coordinator ตายหลัง Phase 1:
- เหมือน 2PC: participants ไม่รู้ว่าควร commit หรือ abort
- ค้างจนกว่า coordinator ฟื้น

ถ้า coordinator ตายหลัง Phase 2 (PRE-COMMIT):
- 2PC: ค้างเหมือนเดิม
- 3PC: participants "รู้แล้ว" ว่าทุกคนพร้อม
       → คุยกันเอง: ใครได้ PRE-COMMIT แล้วบ้าง?
       → ถ้า majority ได้แล้ว → commit (assume coordinator จะมา)
       → ถ้าไม่ → abort

→ ลด "uncertainty window" ของ 2PC

ทำไมในชีวิตจริงไม่ใช้ 3PC?

text
ปัญหาของ 3PC:

1. เพิ่ม latency
   - 3 round trips ระหว่าง phase
   - ใน LAN: +5-10 ms
   - ใน WAN: +100-300 ms
   - มากกว่า 2PC ~50%

2. ยังมี edge cases ที่ block
   - Network partition + coordinator failure พร้อมกัน
   - Participant minority side อาจตัดสินใจผิด
   - ต้องใช้สมมติฐาน "partial synchrony" (timing bounds ที่ในทางปฏิบัติอาจไม่จริง);
     ทฤษฎีบอกว่า perfect failure detector เป็นไปไม่ได้ใน async ที่บริสุทธิ์

3. มี algorithm ที่ดีกว่า
   - Paxos / Raft = "consensus protocol" ที่ทนต่อ failure ดีกว่า
   - Saga = "eventual consistency" ที่ scale ดีกว่า
   - 3PC ตกขอบ ไม่มีคนใช้

บทเรียน: 3PC = ตัวอย่างของ "ทฤษฎีน่าสนใจ แต่ใช้จริงไม่คุ้ม" ในวงการ — ถ้าต้องการ atomicity ระดับ distributed → ใช้ Saga (microservices) หรือ 2PC (legacy XA) หรือ Paxos commit (advanced)

📚 ทางเลือกระดับ production: Paxos Commit (Gray-Lamport 2004) เป็นทายาทสมัยใหม่ของ 3PC — แทนที่จะใช้ timeout เพื่อตัดสินใจ ใช้ consensus (Paxos) แทน → ทนต่อ failure ดีกว่ามาก ใช้จริงใน Google Spanner และ FoundationDB


5. Saga Pattern — ทางเลือกสมัยใหม่แทน 2PC

🔊 อ่านว่า: "Saga" ออกเสียง "ซา-ก้า" (มาจากคำว่า "นิทาน/เรื่องเล่า" ในภาษานอร์ส — เพราะเป็นเรื่องเล่าหลายตอนต่อกัน)

แทนที่จะ lock resource ข้ามทุก service เหมือน 2PC — Saga มอง transaction ใหญ่เป็น "ลำดับของ local transaction" ที่แต่ละขั้นทำใน DB ของตัวเอง ถ้าขั้นไหนล้ม → เรียก "compensation" ของขั้นก่อนหน้าเพื่อย้อน effect (semantic rollback ไม่ใช่ exact undo)

⚠️ Saga ≠ ACID — สิ่งที่ต้องเข้าใจให้ชัด: Saga ให้ Atomicity, Consistency, Durability (เชิงความหมาย) แต่ เสีย Isolation (I) ไป — ระหว่างกลาง saga state ที่ "ยังไม่จบ" จะถูก reader คนอื่นเห็นได้ (เช่น เห็น order ที่ paid แต่ยังไม่ reserve inventory) ถ้า business ของคุณรับ intermediate state ไม่ได้ → saga ไม่ใช่ทางเลือก ต้องออกแบบใหม่ (เช่น hide state จน saga จบ, หรือใช้ semantic locking)

text
Saga = ลำดับของ local transaction (transaction ใน DB ตัวเองทีละขั้น)
แต่ละขั้น: ทำ local txn ของตัวเอง (ACID ภายใน DB เดียว)
ถ้าล้มเหลว: เรียก compensate ขั้นก่อนหน้า (rollback เชิงความหมาย ไม่ใช่ undo ตรง ๆ)

เทียบกับ 2PC:
- ไม่มี global lock (ไม่ล็อกข้ามทุก service)
- ไม่มี coordinator SPOF (แล้วแต่กรณี)
- eventually consistent
- scale ได้ดีกว่ามาก

Choreography vs Orchestration — 2 รูปแบบของ Saga

🔊 อ่านว่า:

  • Choreography (โค-ริ-อ็อก-รา-ฟี) = "ออกแบบท่าเต้นล่วงหน้า ทุกคนรู้จังหวะของตัวเอง ไม่มีคนสั่ง" (เหมือนนักเต้นหมู่)
  • Orchestration (ออร์-เคส-เทร-เชิน) = "วาทยกรกำกับวงดนตรี — มีคนกลางสั่งทุกคนว่าใครเล่นตอนไหน"

ในการทำ Saga มี 2 รูปแบบหลัก — เลือกอันไหนสำคัญมาก เพราะกระทบ debuggability + complexity

Choreography — "ไม่มีคนจัด ทุกคนรู้หน้าที่ตัวเอง"

Analogy: เหมือนการ "ส่งของเป็นโดมิโน" — ของล้มทีละตัว ต่อกันไป

ข้อดี:

  • ✅ Decoupled — services ไม่รู้จักกัน (รู้จักแค่ event type)
  • ✅ ไม่มี central SPOF
  • ✅ เพิ่ม service ใหม่ได้ง่าย (แค่ subscribe event)
  • ✅ Parallel processing — หลาย service ทำ event เดียวกันได้

ข้อเสีย:

  • ❌ Hard to track overall state — ดูจาก code ตัวเดียวไม่ออกว่า flow ทั้งหมดเป็นไง
  • ❌ Event spaghetti — flow ที่ซับซ้อนทำให้ดูยาก
  • ❌ Hard to debug — error เกิดที่ service ไหน? trace ยาก
  • ❌ Cyclic dependency risk — A emits → B listens → emits → A listens (loop!)

Orchestration — "มี orchestrator คอยบงการ"

Analogy: เหมือนผู้กำกับภาพยนตร์ที่บอกแต่ละนักแสดงว่า "ทำอะไรตอนไหน"

ข้อดี:

  • ✅ Clear flow — มี place เดียวที่เห็น flow ทั้งหมด
  • ✅ Easy to track + monitor (orchestrator state = saga state)
  • ✅ Easier to debug — error ที่ step ไหนชัด
  • ✅ Compensation logic อยู่ที่เดียว
  • ✅ Saga timeout / retry logic centralized

ข้อเสีย:

  • ❌ Orchestrator = central component (ต้อง replicate หรือ durable)
  • ❌ Coupling: orchestrator รู้จัก services ทั้งหมด
  • ❌ ถ้า orchestrator crash mid-saga → lock resources ค้าง (แก้ด้วย Temporal)

เปรียบเทียบเลือกใช้

ChoreographyOrchestration
Number of services2-34+
Flow complexityเรียบง่าย, linear (เรียงเส้น)ซับซ้อน, conditional (มีเงื่อนไข)
Team structureทีมเดียวหลายทีม (orchestrator = boundary)
Need to add services laterบ่อยนาน ๆ ครั้ง
Monitoring requirementกลางสูง (saga state สำคัญ)
Debug effort acceptable⚠️ ต้องสร้าง distributed trace✅ orchestrator log ตรงๆ

กฎคร่าว ๆ (Rule of thumb):

  • Saga ที่ services ≤ 3, flow เรียบง่าย → Choreography
  • Saga ที่ services ≥ 4, conditional flow, ต้อง monitor → Orchestration

Modern best practice: ใช้ Temporal (เทม-โพ-รัล) หรือ Cadence (เค-เด้นส์) ซึ่งเป็น workflow engine แบบ durable (รอด process crash ได้ — ดู section 7) สำหรับ business flow ซับซ้อน

💡 Temporal เป็น open-source fork ของ Cadence (ของ Uber, 2017) ในปี 2019 — สำหรับโปรเจกต์ใหม่ภายนอก Uber แนะนำ Temporal

Real-World Examples

CompanyApproachReason
UberCadence (internal, 2017) — Temporal (external fork, 2019)Trip booking flow ซับซ้อน
NetflixConductor (orchestration)Many microservices
StripeState machine (orchestration)Payment flow ต้อง audit ได้
TwitterChoreographyHigh-scale, simple flows
AmazonMix — Step Functions (orchestration) + SNS/SQS (choreography)Different bottlenecks per service

6. Saga Orchestrator Implementation — เขียน orchestrator เองด้วย state machine

มาดูว่า saga แบบ orchestration เขียนจริงเป็นโค้ดอย่างไร — มี orchestrator ตัวกลางสั่งทำทีละ step และบันทึก state ไว้ทุกขั้น ถ้า step ไหน fail ก็เรียก compensate() ย้อน step ที่ทำไปแล้วในลำดับกลับด้าน (refund, release) จุดสำคัญคือ compensation เป็น "semantic undo" ไม่ใช่ rollback แบบ DB:

Flow ของ saga ก่อนดูโค้ด (เป็นภาษาคน):

text
1. ลูกค้ากด "Place Order"
2. step1_charge: เรียก Payment → ตัดเงิน
   ├─ สำเร็จ → save state = 'CHARGED' → ไป step ต่อไป
   └─ ล้ม → throw → ไป compensate()
3. step2_reserveInventory: จอง stock
   ├─ สำเร็จ → save state = 'INVENTORY_RESERVED'
   └─ ล้ม → throw → compensate (refund payment)
4. step3_schedule, step4_notify ... (ทำต่อ)

compensate(): ทำย้อนกลับ "ลำดับกลับด้าน"
   - ถ้า INVENTORY_RESERVED → release inventory
   - ถ้า CHARGED → refund payment
   - save state = 'FAILED'
typescript
// Saga state machine
class OrderSaga {
    async execute(orderId: string) {
        try {
            await this.step1_charge(orderId);
            await this.step2_reserveInventory(orderId);
            await this.step3_schedule(orderId);
            await this.step4_notify(orderId);
        } catch (err) {
            await this.compensate(orderId, err);
            throw err;
        }
    }
    
    private async step1_charge(orderId: string) {
        try {
            await paymentService.charge(orderId);
            await saveState(orderId, 'CHARGED');
        } catch (err) {
            throw new SagaStepError('charge', err);
        }
    }
    
    private async step2_reserveInventory(orderId: string) {
        try {
            await inventoryService.reserve(orderId);
            await saveState(orderId, 'INVENTORY_RESERVED');
        } catch (err) {
            throw new SagaStepError('reserve', err);
        }
    }
    
    // ... more steps
    
    private async compensate(orderId: string, originalErr: any) {
        const state = await getState(orderId);
        
        // Reverse order of compensation
        if (state >= 'INVENTORY_RESERVED') {
            await inventoryService.release(orderId);
        }
        if (state >= 'CHARGED') {
            await paymentService.refund(orderId);
        }
        await saveState(orderId, 'FAILED');
    }
}

ตัวอย่าง Compensation

text
Action                Compensation
─────────             ─────────────
Charge payment        Refund
Reserve inventory     Release reservation
Send email            Send cancellation email
Reserve seat          Release seat
Send notification     N/A (can't unsend) — design around this

→ Compensation = semantic undo, not exact reverse


7. Saga Tools — เครื่องมือสำหรับเขียน Saga

เขียน orchestrator + compensation เองนั้นยากที่จะทำให้ทนทาน (durable) — ถ้า process ตายกลางทางจะกู้ state ยังไง? จึงมี workflow engine ที่จัดการเรื่องนี้ให้ โดยเฉพาะ Temporal ที่ให้เขียน workflow เหมือนโค้ดปกติแต่ "durable" (รอด process crash ได้) ส่วนนี้แนะนำเครื่องมือและตัวอย่าง:

text
1. State machine in code
   - Implement orchestrator manually

2. Workflow engines:
   - Temporal (recommended) — durable execution
   - AWS Step Functions
   - GCP Workflows
   - Apache Airflow (batch-oriented)
   - Cadence (Uber)
   - Zeebe / Camunda

Temporal (Workflow as Code) — เขียน workflow เหมือนโค้ดปกติ แต่ durable

💡 Temporal คืออะไร: open-source workflow engine ที่ fork มาจาก Cadence (ของ Uber, 2017) ในปี 2019 — รับประกันว่า workflow จะ "ทำต่อจากจุดที่ตายได้" แม้ process จะ crash กลางทาง

SDK ที่รองรับ: Go, Java, Python, TypeScript, .NET (2024), PHP (community) — เป็นจุดเด่นเพราะ workflow เดียวสามารถ mix หลายภาษาได้

typescript
import { proxyActivities } from '@temporalio/workflow';

const activities = proxyActivities<typeof activitiesImpl>({
    startToCloseTimeout: '1 minute'
});

export async function orderSaga(orderId: string) {
    try {
        await activities.chargePayment(orderId);
        try {
            await activities.reserveInventory(orderId);
            try {
                await activities.scheduleShipping(orderId);
            } catch (e) {
                await activities.releaseInventory(orderId);
                throw e;
            }
        } catch (e) {
            await activities.refundPayment(orderId);
            throw e;
        }
    } catch (e) {
        await activities.cancelOrder(orderId);
        throw e;
    }
}

→ Looks like regular code, but durable — Temporal handles restart, retry, timeout


8. Idempotency in Distributed — "ทำซ้ำได้ปลอดภัย"

ความหมาย + ทำไมสำคัญ

text
Idempotent operation:
   ทำ 1 ครั้ง vs 100 ครั้ง = effect เดียวกัน
   
ทางคณิตศาสตร์: f(f(x)) = f(x)

ตัวอย่าง idempotent:
- เปิดสวิตช์ไฟ (กดกี่ครั้งก็เปิดอยู่)
- DELETE FROM users WHERE id = 5 (ลบรอบเดียว, ครั้งที่ 2 ไม่มีแล้ว)

ตัวอย่าง NOT idempotent:
- กดปุ่ม "ส่งเงิน 100 บาท" (กด 2 ครั้ง = ส่ง 200)
- INSERT INTO orders (...) (2 ครั้ง = 2 orders)

ทำไมต้อง idempotency ใน distributed?

text
Problem 1: Network retry
   Client → Server (request)
   Network timeout → Client retry
   แต่จริงๆ server ได้รับ request แรกแล้ว (แค่ response หาย)
   → Server process 2 ครั้ง = duplicate effect

Problem 2: At-least-once message delivery
   Kafka/SQS guarantee "at least once" (อาจซ้ำได้)
   Consumer ได้ message ซ้ำ → process ซ้ำ
   → ต้อง dedupe

Problem 3: Saga compensation
   Compensate ก็อาจ retry → ต้อง idempotent

กฎ: ทุก endpoint ที่ "เปลี่ยน state" ต้องเป็น idempotent

Patterns

Pattern 1: Idempotency Key (Stripe-style) — มาตรฐานวงการ

text
Client gen UUID = "req-12345"
ส่งใน HTTP header: Idempotency-Key: req-12345

Server:
1. Check cache (Redis): seen key "req-12345" ไหม?
   - ใช่ → return cached response (ไม่ทำงานซ้ำ)
   - ไม่ → ทำงานต่อ
2. Process request
3. Store result + key (TTL 24 ชม.)
4. Return result

ตัวอย่าง code:

typescript
async function createPayment(req: PaymentRequest, idempotencyKey: string) {
    // 1. Check cache
    const cached = await redis.get(`idempotency:${idempotencyKey}`);
    if (cached) {
        return JSON.parse(cached);  // return เดิม ไม่ทำซ้ำ
    }
    
    // 2. Process
    const payment = await paymentService.process(req);
    
    // 3. Cache result (24h TTL)
    await redis.set(
        `idempotency:${idempotencyKey}`, 
        JSON.stringify(payment),
        'EX', 86400
    );
    
    return payment;
}

Edge case ที่ต้องระวัง:

text
Race condition: 2 requests พร้อมกัน ด้วย key เดียวกัน
   Request 1: check cache → ไม่มี → start processing
   Request 2: check cache → ไม่มี → start processing (พร้อมกัน!)
   → Process 2 ครั้ง = bug

Fix: ใช้ Redis SET NX (set if not exist) เป็น "lock"
   - Request 1: SET key "PROCESSING" NX → success → process
   - Request 2: SET key "PROCESSING" NX → fail → wait/retry

Pattern 2: Database Unique Constraint — ปลอดภัยที่สุด

sql
CREATE TABLE payments (
    id UUID PRIMARY KEY,
    idempotency_key VARCHAR UNIQUE,  -- ← key here
    amount DECIMAL,
    status VARCHAR,
    created_at TIMESTAMPTZ
);

-- Insert จะ fail ถ้า key ซ้ำ — ต้อง 2-step pattern (INSERT + fallback SELECT)
INSERT INTO payments (id, idempotency_key, amount, status, response)
VALUES (uuid_generate_v4(), 'req-12345', 100, 'PENDING', NULL)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id, status, response;
-- ⚠️ Postgres: ON CONFLICT DO NOTHING จะคืน "0 rows" เมื่อ conflict
--    (RETURNING return เฉพาะ row ที่ "insert ได้จริง" เท่านั้น
--     ไม่ return row เก่าที่ขัดแย้ง)

-- ถ้า RETURNING คืน 0 row = duplicate → SELECT response เดิมกลับไป (always run)
SELECT id, status, response
FROM payments
WHERE idempotency_key = 'req-12345';

⚠️ กับดักของ ON CONFLICT DO NOTHING แบบเดี่ยวๆ: มันไม่ return row ตอน conflict (RETURNING ใน Postgres return เฉพาะ row ที่ insert ใหม่จริง — ตอน DO NOTHING = 0 rows) → caller แยกไม่ออกว่าเป็น insert ใหม่หรือเจอซ้ำ และไม่เห็น response ที่เคยเก็บไว้ ต้อง follow-up ด้วย SELECT เสมอ Pattern ที่ Stripe ใช้คือ เก็บ response ของ request แรกลง DB แล้วตอนเจอซ้ำให้ SELECT response เดิมส่งกลับ (read-after-write) — ทำให้ retry ครั้งที่ 2, 3, N ได้คำตอบเดียวกัน เป็น idempotent จริงๆ

ข้อดี: DB constraint = atomic, ไม่มี race condition ข้อเสีย: ต้อง handle "duplicate detected" case — SELECT response เดิมแล้ว return กลับ (ไม่ใช่แค่ error)

Pattern 3: Set-based vs Delta-based Operations

Delta (NOT idempotent):

text
UPDATE balance SET amount = amount + 100 WHERE id = 1
   - ทำ 2 ครั้ง = +200 ❌

Set-based (idempotent):

text
UPDATE balance SET amount = 500 WHERE id = 1
   - ทำ 2 ครั้ง = 500 ✅

Compromise — Event sourcing style:

sql
-- Insert event (ไม่ใช่ update balance)
INSERT INTO balance_events (account_id, delta, event_id)
VALUES (1, 100, 'evt-12345')
ON CONFLICT (event_id) DO NOTHING;

-- Compute balance = SUM(delta) WHERE account_id = 1

→ Event มี unique id → INSERT ซ้ำ = no-op → idempotent

Pattern 4: State Machine

text
Order state: PENDING → PAID → SHIPPED → DELIVERED

Operation: "Mark as PAID"
- Current state = PENDING → transition to PAID ✅
- Current state = PAID → no-op (already paid) ✅
- Current state = CANCELLED → error (invalid transition)

ดู: ทำซ้ำ "Mark as PAID" → idempotent
typescript
async function markOrderPaid(orderId: string) {
    const order = await db.findOne({ id: orderId });
    
    if (order.status === 'PAID') {
        return order;  // already done, idempotent
    }
    
    if (order.status !== 'PENDING') {
        throw new Error(`Cannot mark paid, current: ${order.status}`);
    }
    
    return db.update({ id: orderId }, { status: 'PAID' });
}

Pattern 5: Conditional Update (CAS — Compare-And-Swap)

sql
-- Only update if version matches (optimistic locking)
UPDATE orders 
SET status = 'PAID', version = version + 1
WHERE id = 'order-1'
  AND version = 5;  -- ← only if I'm seeing v5

-- Rows updated:
--   1 → success, I won the race
--   0 → someone else updated already, idempotent failure

Idempotency กับ HTTP Verb ต่าง ๆ

VerbIdempotent by spec?Real-world reality
GETใช่ — ไม่เปลี่ยน state
PUTใช่ — "set to value X"
DELETEใช่ — "remove resource X"
POSTต้องใช้ idempotency key
PATCH❌ ส่วนใหญ่ถ้า delta-based = NOT idempotent

HTTP semantics ช่วยได้บางส่วน — แต่ที่ critical ต้องบังคับเอง

ตัวอย่าง production-ready

typescript
// Stripe-style API
@PostMapping("/payments")
public Payment create(
    @RequestHeader("Idempotency-Key") String key,
    @RequestBody PaymentRequest req
) {
    // 1. Validate key format (UUID v4)
    if (!isValidUUID(key)) {
        throw new BadRequestException("Invalid Idempotency-Key");
    }
    
    // 2. Try to find existing (DB unique constraint)
    Optional<Payment> existing = paymentRepo.findByIdempotencyKey(key);
    if (existing.isPresent()) {
        // Verify request body matches (prevent key collision)
        if (!existing.get().getRequestHash().equals(hash(req))) {
            throw new ConflictException("Key reused with different body");
        }
        return existing.get();  // return cached
    }
    
    // 3. Process + persist (atomic)
    Payment p = paymentService.process(req);
    p.setIdempotencyKey(key);
    p.setRequestHash(hash(req));
    
    try {
        return paymentRepo.save(p);
    } catch (DataIntegrityViolationException e) {
        // Race condition: another request beat us → return their result
        return paymentRepo.findByIdempotencyKey(key).orElseThrow();
    }
}

ตัวอย่างแบบสั้น (minimal)

typescript
@PostMapping("/payments")
public Payment create(
    @RequestHeader("Idempotency-Key") String key,
    @RequestBody PaymentRequest req
) {
    // 1. Check cache
    Optional<Payment> cached = idempotencyStore.find(key);
    if (cached.isPresent()) return cached.get();
    
    // 2. Process
    Payment p = paymentService.process(req);
    
    // 3. Cache (24h TTL)
    idempotencyStore.store(key, p, Duration.ofHours(24));
    
    return p;
}

9. Outbox Pattern — แก้ปัญหา dual-write ระหว่าง DB กับ message broker

ปัญหา dual-write คือสถานการณ์ที่ service ต้อง "เขียน DB" + "ส่ง event เข้า Kafka" พร้อมกัน แต่ไม่มี transaction ใดที่ครอบทั้งสองได้ — ถ้าทำแบบไม่ระวัง state จะ drift ระหว่าง DB กับ downstream

text
ปัญหา:
- Service เขียน DB + ส่ง event เข้า Kafka
- ทั้งสองต้องสำเร็จพร้อมกัน หรือล้มพร้อมกัน
- ถ้าไม่ atomic: state ไม่ตรงกัน

กรณีล้มเหลว:
- เขียน DB สำเร็จ
- ส่ง Kafka ล้มเหลว (network)
- → DB อัปเดตแล้วแต่ไม่มี event → downstream หลุด sync

วิธีแก้: Outbox

text
1. ใช้ DB transaction เดียว:
   - update business table (order)
   - INSERT INTO outbox (event เป็น JSON)
   - COMMIT (atomic — สำเร็จ/ล้มพร้อมกัน!)

2. Outbox publisher (ทำงานเบื้องหลัง):
   - อ่าน event ที่ยังไม่ได้ publish จาก outbox
   - publish ไป Kafka
   - mark ว่า published แล้ว

รับประกัน:
- Atomicity: เขียน DB + เก็บ event เป็น atomic ก้อนเดียว
- At-least-once: event ถูกส่งถึง Kafka (retry ถ้าล้ม)
- รักษาลำดับ (ถ้าใช้ publisher เดียวต่อ partition)

Schema ตาราง

sql
CREATE TABLE orders (
    id UUID PRIMARY KEY,
    status TEXT,
    total NUMERIC,
    ...
);

CREATE TABLE outbox (
    id BIGSERIAL PRIMARY KEY,
    aggregate_type TEXT,           -- "Order"
    aggregate_id UUID,              -- order id
    event_type TEXT,                -- "OrderCreated"
    payload JSONB,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    published_at TIMESTAMPTZ        -- NULL = not yet published
);

CREATE INDEX ON outbox(published_at) WHERE published_at IS NULL;
typescript
async function createOrder(req: CreateOrderRequest) {
    await db.transaction(async tx => {
        const order = await tx.insert('orders', { ... });
        
        // Same transaction
        await tx.insert('outbox', {
            aggregate_type: 'Order',
            aggregate_id: order.id,
            event_type: 'OrderCreated',
            payload: { orderId: order.id, total: order.total }
        });
    });
}

// Background publisher
async function publishOutbox() {
    while (true) {
        // ใช้ FOR UPDATE SKIP LOCKED → publisher หลายตัว run พร้อมกันได้โดยไม่หยิบ row ซ้ำ
        await db.transaction(async (tx) => {
            const events = await tx.query(`
                SELECT * FROM outbox
                WHERE published_at IS NULL
                ORDER BY id
                LIMIT 100
                FOR UPDATE SKIP LOCKED
            `);

            for (const event of events) {
                await kafka.publish('events', event.payload);
                await tx.execute('UPDATE outbox SET published_at = NOW() WHERE id = $1', event.id);
            }
        });

        await sleep(1000);
    }
}

⚠️ อ่านก่อนใช้ใน production:

  1. Delivery semantic = at-least-once เสมอ ไม่ใช่ exactly-once — ระหว่าง kafka.publish กับ UPDATE published_at ถ้า process crash, event จะถูก publish ซ้ำตอน restart → consumer ต้อง idempotent (ใช้ inbox pattern + unique event_id) Outbox ไม่ได้ "เสก" exactly-once ขึ้นมา — มันแค่ทำ at-least-once + idempotent consumer = effectively exactly-once (เสมือนเท่านั้น)
  2. OrderingORDER BY id ให้ global order ใน publisher ตัวเดียว แต่พอ scale หลาย publisher (SKIP LOCKED) จะรับประกันแค่ order ภายใน aggregate เดียวเท่านั้น → ถ้าต้อง strict order ให้ partition key ใน Kafka ตาม aggregate_id
  3. Throughput — publish แบบ sequential ใน loop ช้า; production แนะนำ batch publish หรือ async pipeline (Kafka producer batching) แต่ระวัง update published_at ต้องตรงกับที่ broker ack แล้วจริง
  4. ทางเลือก: ใช้ CDC (Debezium) อ่าน WAL ตรงๆ จะหลีกเลี่ยง publisher loop นี้ทั้งหมด (ดูหัวข้อถัดไป)

CDC (Change Data Capture) — Outbox เวอร์ชันโมเดิร์น

text
เวอร์ชันสมัยใหม่:
- ไม่ต้องเขียน outbox table
- ใช้ transaction log ของ DB (WAL = Write-Ahead Log บันทึกการเปลี่ยนแปลงก่อน apply จริง)
- Kafka Connect อ่าน WAL → สร้าง event

Tools:
- Debezium (นิยมสุด)
- Kafka Connect
- AWS DMS

สรุป (takeaway): มีประสิทธิภาพกว่า — ไม่ต้องมี outbox table (WAL ดู glossary บท 11)


10. Inbox Pattern — กรอง duplicate ที่ฝั่ง consumer

Outbox ทำให้ producer ส่ง event ได้แบบ "at-least-once" — แต่ฝั่ง consumer ต้องรับมือกับ event ซ้ำเอง (network retry, broker redelivery) วิธีมาตรฐานคือทำ "inbox table" ที่บันทึก event_id ของทุก event ที่เคย process แล้ว ถ้ามาซ้ำ → ข้าม

text
Consumer receives event multiple times (retry)
- Process duplicate? → bad
- Use inbox table: track processed events

Steps:
1. Receive event with event_id
2. Begin transaction
3. INSERT INTO inbox (event_id) — fails if duplicate (UNIQUE)
4. If duplicate → skip (already processed)
5. If new → process + commit transaction
typescript
async function handleOrderCreated(event: OrderCreatedEvent) {
    await db.transaction(async tx => {
        try {
            await tx.insert('inbox', {
                event_id: event.id,
                event_type: 'OrderCreated',
                processed_at: new Date()
            });
        } catch (err) {
            if (err.code === '23505') {  // unique violation
                // Already processed
                return;
            }
            throw err;
        }
        
        // Process event
        await tx.insert('inventory_reserved', { ... });
    });
}

11. Event Sourcing — เก็บ event ไม่เก็บ state

แทนที่จะเก็บ "ค่าปัจจุบัน" ใน table (เช่น balance = 320) — Event Sourcing เก็บ "ทุก event ที่เคยเกิดขึ้น" ไว้ตามลำดับ แล้ว state ปัจจุบัน = derive จากการเล่น event ทั้งหมดซ้ำ

text
แทนที่จะเก็บ state ปัจจุบัน → เก็บ event ทั้งหมด
state = คำนวณจากประวัติ event (derive)

ตัวอย่าง: บัญชีธนาคาร
- AccountOpened (เปิดบัญชี)
- Deposit($100) (ฝาก)
- Deposit($50)
- Withdraw($30) (ถอน)
- Deposit($200)

ยอดปัจจุบัน = ผลรวมของ event = $320

ข้อดี / ข้อเสียของ Event Sourcing

text
✅ Full audit trail (regulatory compliance)
✅ Time travel (state at any past time)
✅ Easy to add new projection (re-process events)
✅ Reactive (events drive everything)

❌ Complex (need to think in events, not state)
❌ Schema evolution hard (old events have old format)
❌ Slow to compute current state (snapshotting helps)
❌ Storage grows

การ implement

typescript
type AccountEvent = 
    | { type: 'AccountOpened', accountId: string, holder: string }
    | { type: 'Deposit', accountId: string, amount: number }
    | { type: 'Withdraw', accountId: string, amount: number };

class Account {
    private state = { holder: '', balance: 0 };
    
    apply(event: AccountEvent) {
        switch (event.type) {
            case 'AccountOpened':
                this.state.holder = event.holder;
                this.state.balance = 0;
                break;
            case 'Deposit':
                this.state.balance += event.amount;
                break;
            case 'Withdraw':
                if (this.state.balance < event.amount) {
                    throw new Error('Insufficient');
                }
                this.state.balance -= event.amount;
                break;
        }
    }
    
    static rebuild(events: AccountEvent[]): Account {
        const acc = new Account();
        for (const event of events) {
            acc.apply(event);
        }
        return acc;
    }
}

// Store events
await eventStore.append('account-42', { type: 'Deposit', amount: 100 });

// Rebuild current state
const events = await eventStore.read('account-42');
const account = Account.rebuild(events);

Snapshot — ลดเวลา replay เมื่อ event ยาว

text
1B events for hot account = slow to replay
Solution: snapshot

Every N events:
- Compute current state
- Save snapshot
- New writes: apply on top of snapshot

Read:
- Load latest snapshot
- Apply events since snapshot
- Much faster

Tools / เครื่องมือ

text
- EventStoreDB (purpose-built)
- Kafka (event log)
- Marten (Postgres-based)
- AxonServer
- Postgres with custom event table

12. CQRS (Command Query Responsibility Segregation) — แยก write model ออกจาก read model

แนวคิดหลัก: ระบบ "เขียน" กับ "อ่าน" มี requirement ต่างกันมาก — เขียนต้อง strict (ACID, validate, normalize) แต่อ่านต้อง fast (denormalized, cached, multiple views) CQRS แยกสอง path นี้ออกจากกันเลย

text
แยก write model (คำสั่ง/commands) ออกจาก read model (การอ่าน/queries)

ฝั่งเขียน (Write):
- commands → normalize → DB แบบ ACID
- strongly consistent
- เช่น "OrderService.create()"

ฝั่งอ่าน (Read):
- queries → อ่านจาก read model ที่ denormalize ไว้
- อาจเป็น eventually consistent
- แต่ละ model ปรับให้เหมาะกับ query

Flow:
[Command] → [ฝั่งเขียน]
              ↓ event
           [Event bus]
              ↓ มีหลาย subscriber
           [Read model 1] (เช่น user dashboard)
           [Read model 2] (เช่น analytics)
           [Read model 3] (เช่น search index)

ทำไมต้องแยก?

text
Single model for read + write:
- Optimized for neither
- Many JOINs for read
- Slow

CQRS:
- Write: simple table (current state)
- Read: precomputed views (fast)
- Each scales independently

Trade-off: complexity vs performance

เมื่อไหร่ควรใช้

text
✅ Read-heavy + complex queries
✅ Different consistency for read vs write
✅ Different teams own read vs write
✅ Event sourcing already used

❌ Simple CRUD
❌ Strict consistency throughout
❌ Small app

13. Combining: Event Sourcing + CQRS + Saga — เมื่อทั้งสาม pattern ทำงานด้วยกัน

ใน microservices ขนาดใหญ่ (Uber, Netflix, ฯลฯ) — pattern ทั้งสามมักทำงานร่วมกัน เพราะแต่ละตัวแก้คนละปัญหา: Event Sourcing เก็บความจริง, CQRS แยก scale, Saga ประสาน flow ข้าม service

text
Modern microservices pattern:

[Client] → [Command] → [Service]

                       Write event → [Event Store]
                                          ↓ subscribe
                                       [Projections] (read models)

                                       [Other services] (saga choreography)

Read: from projection (eventually consistent, fast)
Write: append event (durable, auditable)
Compose: chain events to coordinate cross-service

→ Powerful but complex — use only when complexity justified


14. Eventual Consistency UX — ออกแบบ UX ให้ผู้ใช้ไม่รู้สึกว่าระบบ "ค้าง"

เมื่อระบบเป็น eventually consistent — มีช่วงที่ data ยังไม่ propagate ไปทุก service แต่ผู้ใช้ต้องเห็น "บางอย่าง" ทันที ไม่งั้นจะเข้าใจว่าระบบเสีย UX pattern พวกนี้ช่วย "ซ่อนความช้า" ของ backend

text
User submits order → API responds 200 immediately
- DB updated, but downstream not yet (eventual)

Common UX:
- "Processing your order..."
- Show optimistic UI (item in cart immediately)
- Background sync
- Notification when complete

Patterns:
- Optimistic update (UI assumes success)
- Pessimistic (wait for confirmation)
- Hybrid (optimistic with rollback on fail)

15. Transactional Outbox Real Example — ตัวอย่าง production-ready

มาดู outbox pattern ในโค้ดจริงระดับ production — เคล็ดลับคือบันทึก order + outbox event ใน transaction เดียวกัน (atomic ใน DB เดียว) แล้วมี background publisher ดึง event จาก outbox ไปส่ง Kafka ทีหลัง ฝั่ง consumer ใช้ inbox pattern กันซ้ำ รวมกันได้ delivery แบบ exactly-once-like:

typescript
// Order Service

@PostMapping("/orders")
@Transactional
public Order create(@RequestBody CreateOrderRequest req) {
    Order order = new Order();
    order.setUserId(req.userId());
    order.setItems(req.items());
    order.setTotal(calculateTotal(req.items()));
    order.setStatus("PENDING");
    orderRepository.save(order);
    
    OutboxEvent event = new OutboxEvent();
    event.setAggregateType("Order");
    event.setAggregateId(order.getId());
    event.setEventType("OrderCreated");
    event.setPayload(toJson(Map.of(
        "orderId", order.getId(),
        "userId", order.getUserId(),
        "total", order.getTotal()
    )));
    outboxRepository.save(event);
    
    // Both saved in same transaction
    return order;
}

// Background Publisher (separate thread / pod)
@Scheduled(fixedDelay = 1000)
public void publishOutbox() {
    List<OutboxEvent> events = outboxRepository.findUnpublishedBatch(100);
    
    for (OutboxEvent event : events) {
        try {
            // ⚠️ producer ต้องตั้ง acks=all + retries=Integer.MAX_VALUE
            //     + enable.idempotence=true (Kafka ≥ 0.11) มิฉะนั้น
            //     "guaranteed delivery" ไม่จริง
            kafkaTemplate.send("order-events", event.getAggregateId(), event.getPayload()).get();
            event.setPublishedAt(Instant.now());
            outboxRepository.save(event);
            // ⚠️ ระหว่าง send().get() กับ save() ถ้า process crash
            //    event นี้จะถูก publish ซ้ำตอน restart
            //    → consumer ต้อง idempotent (inbox pattern) เสมอ
        } catch (Exception e) {
            log.error("Failed to publish, will retry", e);
            break;  // try next iteration
        }
    }
}
typescript
// Payment Service consumes

@KafkaListener(topics = "order-events")
public void handleOrderCreated(OrderCreatedEvent event) {
    // Inbox pattern: dedupe
    if (inboxRepository.existsByEventId(event.getEventId())) {
        return;  // already processed
    }
    
    transactionTemplate.execute(status -> {
        Charge charge = paymentService.charge(event.getOrderId(), event.getTotal());
        
        inboxRepository.save(new InboxEntry(event.getEventId(), Instant.now()));
        
        // Emit downstream event
        outboxRepository.save(new OutboxEvent(
            "Payment", charge.getId(), "PaymentCharged", toJson(charge)
        ));
        
        return null;
    });
}

→ End-to-end "effectively exactly-once" delivery — โดยรวม at-least-once (Outbox publisher อาจ publish ซ้ำตอน crash) + idempotent consumer (Inbox dedupe ด้วย event_id)

⚠️ อย่าสับสน — นี่ไม่ใช่ "true exactly-once":

  • Cross-system (DB → Kafka → DB อีกที่): ทำได้แค่ "at-least-once + idempotency" → ผลลัพธ์เสมือน exactly-once เชิงปฏิบัติ แต่ทฤษฎีไม่ได้รับประกัน (Kafka publish กับ DB commit ของ publisher เป็น "two writes" — crash ระหว่างกลางทำให้ publish ซ้ำได้)
  • Kafka-to-Kafka pipeline (stream processing): Kafka 0.11+ มี EOS (Exactly-Once Semantics) ของจริงผ่าน transactional producer (isolation.level=read_committed consumer + transactional producer) — ใช้ใน Kafka Streams โดยตรง

16. Anti-Patterns — รูปแบบที่ควรหลีกเลี่ยง

รวมกับดักของ distributed transaction ที่ทำให้ระบบช้า เปราะ หรือข้อมูลเพี้ยน — ใช้ 2PC กับ microservices (ช้า+blocking), เรียก API ต่อกันเป็นลูกโซ่แบบ sync, ลืม idempotency หรือลืม compensation รู้ไว้เพื่อเลือก saga/outbox แทนตั้งแต่ออกแบบ:

text
1. Distributed 2PC for microservices
   - Slow + blocking
   - Use saga instead

2. Direct API calls in chain (no async)
   - Order → Payment → Inventory → Shipping (all sync)
   - 1 slow → all slow
   - Tight coupling
   → Use events

3. No idempotency
   - Retry → duplicate
   → Always idempotent at consumer

4. Forget compensation
   - Saga doesn't roll back on failure
   → Define compensations upfront

5. Coordinator failure
   - Saga orchestrator dies mid-way
   - Locks held forever
   → Use durable orchestrator (Temporal)

6. Use 2PC across regions
   - Latency × multiple rounds = unusable
   → Async per region, or async cross-region

7. Forget outbox
   - DB write succeeds, Kafka write fails
   - State drift
   → Outbox pattern

17. Real Example — E-commerce Checkout (ตัวอย่างเต็มรูปแบบ)

ปิดท้ายด้วยการรวมทุกอย่างในบทเป็นเคสเดียว — checkout ของ e-commerce ที่ไล่ทีละ step (validate → charge → reserve → ship → notify) พร้อม compensation ของแต่ละ step ถ้าเกิด fail ตัวอย่างนี้แสดงให้เห็นว่า saga + outbox + idempotency ทำงานร่วมกันยังไงในระบบจริง:

text
User clicks "Place Order"

Step 1: Validate cart (Order Service)
   - Local DB check
   
Step 2: Create order (Order Service)
   - INSERT order (status: PENDING)
   - INSERT outbox: OrderCreated event
   
Step 3 (async, via Kafka):
   Payment Service consumes OrderCreated
   - Validate payment method
   - Charge card
   - INSERT outbox: PaymentSucceeded (or PaymentFailed)
   
Step 4 (async):
   Inventory Service consumes PaymentSucceeded
   - Reserve items
   - INSERT outbox: InventoryReserved (or InventoryShort)
   
Step 5 (async):
   Shipping Service consumes InventoryReserved
   - Schedule shipment
   - INSERT outbox: ShippingScheduled
   
Step 6 (async):
   Order Service consumes ShippingScheduled
   - Update order: status = CONFIRMED
   
   OR consumes InventoryShort / PaymentFailed:
   - Compensation chain:
     - If PaymentSucceeded but InventoryShort → refund
   - Update order: status = FAILED
   - Notify user

User sees:
- Immediate: "Order received, processing..."
- Later (sec): "Confirmed!" (push notification)

→ Decoupled + resilient + traceable


18. Checkpoint

ลองออกแบบและลงมือทำเองเพื่อให้ความรู้ติดตัว — แบบฝึกหัดนี้ให้คุณออกแบบ saga (พร้อม compensation), implement outbox และ idempotency จริง ไปจนถึงสร้าง event-sourced aggregate ทำครบจะเข้าใจ distributed transaction อย่างลึกซึ้ง:

🛠️ Checkpoint 3.1 — ออกแบบ Saga
ออกแบบ saga สำหรับ:

  • จองโรงแรม (ห้อง + ชำระเงิน + อีเมล)
  • จองทริป (เที่ยวบิน + โรงแรม + รถ)
  • อนุมัติสินเชื่อ (เช็กเครดิต + ยืนยันการจ้างงาน + เซ็นสัญญา)

แต่ละอัน: กำหนด step + compensation

🛠️ Checkpoint 3.2 — Outbox
implement outbox ใน Spring Boot:

  • สร้าง order → บันทึก outbox event
  • มี scheduler เบื้องหลังคอย publish
  • ลองดู: Kafka ล่ม → event สะสม → Kafka กลับมา → publish

🛠️ Checkpoint 3.3 — Idempotency
สร้าง payment API ที่ใช้ idempotency key

  • key เดิม + body เดิม → ได้ผลเดิม
  • ทดสอบ: ยิง 100 request พร้อมกันด้วย key เดียว → หักเงินแค่ครั้งเดียว

🛠️ Checkpoint 3.4 — Event Sourcing
สร้าง Account aggregate ด้วย event:

  • AccountOpened, Deposit, Withdraw
  • สร้าง state ปัจจุบันใหม่จาก event
  • เพิ่ม snapshot ทุก 100 event

🛠️ Checkpoint 3.5 — อ่าน DDIA บทที่ 7
"Transactions" — อ่าน + เขียนสรุป


19. สรุปบท

ทบทวนแก่นของบทนี้ — ตั้งแต่ทำไม distributed transaction ยาก, ACID vs BASE, 2PC/3PC, Saga (orchestration/choreography + compensation), idempotency, outbox/inbox และ event sourcing เช็กลิสต์นี้คือเครื่องมือที่ใช้ออกแบบ business operation ข้าม service:

2PC = atomic ข้าม DB ได้แต่ช้า + blocking — ใช้เท่าที่จำเป็น
Saga = ลำดับของ local txn + compensation — เหมาะกับ microservices ยุคใหม่
Orchestration > Choreography เมื่อ flow ซับซ้อน (แนะนำ Temporal)
Idempotency = retry ซ้ำได้ปลอดภัย (Idempotency-Key แบบ Stripe)
Outbox = เขียน DB + publish event แบบ atomic (แก้ปัญหา dual-write)
CDC (Debezium) = outbox เวอร์ชันใหม่ (อ่าน WAL ไม่ต้องมี outbox table)
Inbox = กรอง duplicate ที่ฝั่ง consumer
Event Sourcing = เก็บ event ไม่เก็บ state — ตรวจสอบย้อนหลัง + replay ได้
CQRS = แยก write + read model — scale + ยืดหยุ่น
✅ UX แบบ eventual consistency: optimistic UI + sync เบื้องหลัง + แจ้งเตือนเมื่อเสร็จ
✅ ใช้ durable workflow (Temporal) เพื่อเลี่ยงปัญหา coordinator ล่ม


← บทที่ 2 | บทที่ 4 → Time + Ordering


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