โหมดมืด
บทที่ 3 — Distributed Transactions
หลังจบบท คุณจะ:
- เข้าใจ 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 undo2. 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 |
| Consistency | strong (ตรงทันที) | eventual (ในที่สุดจะตรง) |
| Isolation | transaction ไม่กวนกัน | partial state เห็นได้ |
| Availability | อาจ block เมื่อ conflict | "พร้อมใช้งานก่อน" |
| ปรัชญา | ถูกต้อง > พร้อมใช้ | พร้อมใช้ > ถูกต้องทันที |
| เหมาะกับ | บัญชี/การเงิน, single DB | feed, 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 wellXA 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)
เปรียบเทียบเลือกใช้
| Choreography | Orchestration | |
|---|---|---|
| Number of services | 2-3 | 4+ |
| 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
| Company | Approach | Reason |
|---|---|---|
| Uber | Cadence (internal, 2017) — Temporal (external fork, 2019) | Trip booking flow ซับซ้อน |
| Netflix | Conductor (orchestration) | Many microservices |
| Stripe | State machine (orchestration) | Payment flow ต้อง audit ได้ |
| Choreography | High-scale, simple flows | |
| Amazon | Mix — 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 / CamundaTemporal (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/retryPattern 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" → idempotenttypescript
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 failureIdempotency กับ HTTP Verb ต่าง ๆ
| Verb | Idempotent 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:
- 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 (เสมือนเท่านั้น)- Ordering —
ORDER BY idให้ global order ใน publisher ตัวเดียว แต่พอ scale หลาย publisher (SKIP LOCKED) จะรับประกันแค่ order ภายใน aggregate เดียวเท่านั้น → ถ้าต้อง strict order ให้ partition key ใน Kafka ตามaggregate_id- Throughput — publish แบบ sequential ใน loop ช้า; production แนะนำ batch publish หรือ async pipeline (Kafka producer batching) แต่ระวัง update
published_atต้องตรงกับที่ broker ack แล้วจริง- ทางเลือก: ใช้ 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 transactiontypescript
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 fasterTools / เครื่องมือ
text
- EventStoreDB (purpose-built)
- Kafka (event log)
- Marten (Postgres-based)
- AxonServer
- Postgres with custom event table12. 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 app13. 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_committedconsumer + 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 pattern17. 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