ภายใน process เดียว การเรียกฟังก์ชันจะจบแค่สองแบบ คือ return ค่ากลับมาหรือ throw error แต่เมื่อข้ามเครือข่ายจะมีผลลัพธ์แบบที่สาม คือ คุณไม่รู้ request อาจหายไประหว่างทาง อาจถูกประมวลผลแล้วแต่ response หายไป หรืออาจยังทำงานอยู่ ทุก reliability pattern ใน microservices มีขึ้นเพื่อรับมือกับความไม่แน่นอนนี้
Synchronous กับ asynchronous: เลือกอย่างตั้งใจ
Synchronous (REST, gRPC)
ฝั่งผู้เรียกส่ง request แล้วรอคำตอบ
เหมาะกับ:
- query ที่ผู้ใช้ต้องการคำตอบทันที ("ยอดเงินคงเหลือของฉันเท่าไร?")
- command ที่ต้องการผล validation กลับมาทันที
ต้นทุน:
- Temporal coupling ฝั่งที่ถูกเรียกต้องทำงานอยู่ ในขณะนั้น ฝั่งผู้เรียกถึงจะทำงานได้
- Latency ที่ซ้อนทับกัน A → B → C ทำให้ latency บวกกัน และโอกาสล้มเหลวทวีคูณ
gRPC เพิ่ม contract แบบ strongly typed ผ่าน Protocol Buffers การ encode แบบ binary ที่มีประสิทธิภาพ และรองรับ streaming จึงเหมาะกับ traffic ภายในระหว่าง service ด้วยกัน ส่วน REST ที่ใช้ JSON นั้น debug ได้ง่ายกว่า และยังคงเป็นตัวเลือกหลักสำหรับ public API
Asynchronous (event และ message ผ่าน broker)
ฝั่ง producer publish event แล้วทำงานอื่นต่อไปเลย ส่วน consumer จะประมวลผลเมื่อพร้อม
เหมาะกับ:
- side effect ที่ไม่จำเป็นต้อง block ผู้ใช้ (อีเมล, analytics, การทำ search index)
- การกระจาย (fan-out) ไปยังหลาย service ที่สนใจ
- การรองรับ traffic ที่พุ่งขึ้นชั่วคราว เพราะ queue ทำหน้าที่เป็น buffer
ต้นทุน:
- Eventual consistency ส่วนต่าง ๆ ของระบบอาจเห็นข้อมูลไม่ตรงกันในช่วงสั้น ๆ
- debug และ trace ได้ยากกว่า และยังต้องรับมือกับ message ที่ซ้ำหรือมาไม่ตามลำดับ
หลักง่าย ๆ: ใช้ synchronous call สำหรับ query และ validation ที่ต้องได้ผลทันที และใช้ event เพื่อกระจาย ข้อเท็จจริงที่เกิดขึ้นไปแล้ว
Timeout: pattern ที่ทุกคนลืม
call ที่ไม่มี timeout อาจรอไปได้ตลอดกาล โดยถือ thread, connection และหน่วยความจำไว้ตลอดเวลา เมื่อโหลดสูง call ที่ค้างเหล่านี้จะสะสมจนฝั่งผู้เรียกล่มตามไปด้วย
- ตั้ง timeout ให้ทุก outbound call HTTP client หลายตัวไม่มี timeout เป็นค่าเริ่มต้นเลย
- ส่งต่อ deadline ถ้า request ของผู้ใช้เหลือเวลาอีก 2 วินาที call ที่ส่งต่อไปยัง downstream ก็ไม่ควรได้ timeout ใหม่ 5 วินาที gRPC ส่งต่อ deadline ให้อัตโนมัติ ส่วนใน Go คุณจะได้พฤติกรรมนี้ด้วยการส่ง
context.Contextไปทุกที่ - กำหนด timeout จากการวัดผลจริง เริ่มจาก p99 latency ของฝั่งที่ถูกเรียก บวกเผื่ออีกเล็กน้อย ไม่ใช่ใช้ตัวเลขกลม ๆ ที่ตั้งขึ้นมาเอง
Retry: มีประโยชน์ จนกว่าจะไม่มี
retry ช่วยแก้ความล้มเหลวชั่วคราว (transient failure) ได้ แต่ก็ทำให้โหลดทวีคูณ การ retry ในทุก layer จะเปลี่ยนความล้มเหลวครั้งเดียวให้กลายเป็นหิมะถล่ม ถ้าสาม layer ต่าง retry ชั้นละ 3 ครั้ง request เดียวของผู้ใช้จะกลายเป็น 27 call ไปยัง service ชั้นล่างสุด ในจังหวะที่มันกำลังแย่อยู่แล้วพอดี
แนวทาง:
- retry เฉพาะความล้มเหลวที่ retry ได้: connection error,
503,429และ timeout ห้าม retry400หรือ422เพราะ request ผิดตั้งแต่ต้น และจะผิดต่อไป - ใช้ exponential backoff ร่วมกับ jitter:
func backoff(attempt int) time.Duration {
base := 100 * time.Millisecond
max := 5 * time.Second
d := base << attempt // 100ms, 200ms, 400ms...
if d > max {
d = max
}
return time.Duration(rand.Int63n(int64(d))) // full jitter
}jitter ช่วยไม่ให้ client นับพันตัว retry พร้อมกันในเสี้ยววินาทีเดียวกัน
- จำกัดจำนวนครั้ง (ปกติ 2–3 ครั้ง) และ retry เพียง layer เดียว โดยควรเป็นชั้นนอกสุดที่ใกล้ผู้ใช้ที่สุด
- ใช้ retry budget อนุญาตให้ retry ได้ไม่เกิน สมมติว่า 10% ของปริมาณ request ปกติ เพื่อไม่ให้ retry ไปซ้ำเติม service ที่กำลังมีปัญหาอยู่แล้ว
Idempotency: ทำให้การ retry ปลอดภัย
retry จะปลอดภัยก็ต่อเมื่อการทำ operation ซ้ำสองครั้งให้ผลเหมือนกับทำครั้งเดียว การอ่านข้อมูลเป็น idempotent โดยธรรมชาติ แต่การเขียนอย่าง "ตัดเงินจากบัตร" ไม่ใช่
วิธีแก้มาตรฐานคือ idempotency key โดย client สร้าง ID ที่ไม่ซ้ำกันสำหรับแต่ละ operation เชิงตรรกะ แล้วส่งไปพร้อมกับทุกความพยายาม
POST /payments
Idempotency-Key: 5f2b8a1e-3c4d-4e7f-9a0b-1c2d3e4f5a6bฝั่ง server จัดเก็บ key ไว้คู่กับผลลัพธ์:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
response JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);ในแต่ละ request:
- พยายาม insert key ถ้ามี key นี้อยู่แล้วพร้อม response ที่บันทึกไว้ ให้ คืน response ที่บันทึกไว้ โดยไม่ต้องทำงานซ้ำ
- ถ้ามี key อยู่แล้วแต่ hash ของ request body ไม่ตรงกัน ให้ปฏิเสธ request เพราะ client นำ key มาใช้ซ้ำโดยผิดพลาด
- นอกเหนือจากนั้น ให้ทำงานแล้วบันทึก response ไว้ใน transaction เดียวกัน กับการเปลี่ยนแปลงทางธุรกิจ
event consumer ก็ต้องการการป้องกันแบบเดียวกัน โดยทั่วไป broker จะรับประกันการส่งแบบ at-least-once (อย่างน้อยหนึ่งครั้ง) ดังนั้นทุก consumer ต้องรับมือกับ message ซ้ำได้ ซึ่งมักทำโดยการบันทึก ID ของ message ที่ประมวลผลแล้ว
Circuit breaker: ล้มให้เร็ว
เมื่อ dependency ล่ม call ที่รอจนหมด timeout จะเปลืองทรัพยากรและทำให้ทุกอย่างช้าลง circuit breaker จะคอยเฝ้าดูความล้มเหลว และตัดวงจร call ทันทีเมื่อถึงเกณฑ์ที่กำหนด:
- Closed: call ผ่านไปได้ตามปกติ และนับจำนวนครั้งที่ล้มเหลว
- Open: เมื่อถึงเกณฑ์ เช่น ล้มเหลว 50% จาก 20 request ทุก call จะล้มเหลวทันทีตลอดช่วง cooldown
- Half-open: หลังหมด cooldown จะปล่อย call ทดลองผ่านไปจำนวนหนึ่ง ถ้าสำเร็จ breaker จะกลับไปเป็น closed ถ้าล้มเหลว จะกลับไปเป็น open อีกครั้ง
ใช้ breaker ร่วมกับ fallback ในกรณีที่ตัวผลิตภัณฑ์ยอมรับได้ เช่น ข้อมูลจาก cache รายการแนะนำเริ่มต้น หรือ UI แบบลดความสามารถลง หน้าเว็บที่ไม่มีรายการแนะนำเฉพาะบุคคล ย่อมดีกว่าหน้าเว็บที่โหลดไม่ขึ้นเลยอย่างมาก
Bulkhead: จำกัดวงความเสียหาย
ให้แต่ละ dependency มี pool ที่จำกัดขนาด ของ connection หรือจำนวน call พร้อมกันเป็นของตัวเอง ถ้า recommendations service ที่ช้าใช้ pool 20 connection ของมันจนหมด call ไปยัง payment ก็ยังมี pool ของตัวเองให้ใช้ เปรียบเหมือนห้องกั้นน้ำในตัวเรือ ที่รอยรั่วจุดเดียวไม่ทำให้เรือทั้งลำจม
ปัญหา dual-write และ outbox pattern
bug คลาสสิกมีหน้าตาแบบนี้:
db.Save(order) // succeeds
broker.Publish(OrderPlaced{}) // process crashes before this lineตอนนี้ order มีอยู่ในระบบแล้ว แต่ไม่มี downstream service ตัวไหนจะได้รับรู้เรื่องนี้เลย การสลับลำดับก็ไม่ช่วย เพราะคุณอาจ publish event ของ order ที่ไม่เคยถูกบันทึกจริง
transactional outbox แก้ปัญหานี้ได้:
- ใน database transaction เดียวกัน กับการเปลี่ยนแปลงทางธุรกิจ ให้ insert event ลงในตาราง
outbox - มี relay process แยกต่างหากคอยอ่านแถวที่ยังไม่ถูก publish แล้ว publish ไปยัง broker พร้อมทำเครื่องหมายว่าแถวนั้นส่งแล้ว
- ถ้า relay crash มันจะทำงานต่อจากจุดที่ค้างไว้ ส่วน consumer จะรับมือกับ message ซ้ำที่อาจเกิดขึ้นบ้างด้วย idempotency
เครื่องมือ change-data-capture สามารถติดตาม log ของ database และทำหน้าที่เป็น relay ได้ ทำให้ไม่ต้อง polling อีกต่อไป
Saga สำหรับ workflow ที่ข้ามหลาย service
distributed transaction (two-phase commit) ที่ข้ามหลาย service นั้นเปราะบางและแทบไม่คุ้มค่า saga จำลอง workflow เป็นลำดับของ local transaction โดยแต่ละขั้นมี compensating action (การกระทำเพื่อชดเชยหรือย้อนกลับ):
Ordersสร้าง order ในสถานะ pendingPaymentsตัดเงินจากบัตร หรือถ้าล้มเหลวจะ publishPaymentFailedInventoryจองสต็อก หรือถ้าล้มเหลวจะสั่ง compensation เพื่อคืนเงินOrdersเปลี่ยนสถานะ order เป็น confirmed
saga ทำได้ทั้งแบบ choreography ที่แต่ละ service ตอบสนองต่อ event ของกันและกัน หรือแบบ orchestration ที่มีตัวประสานงานคอยสั่งการแต่ละขั้น แบบ orchestration จะติดตามได้ง่ายกว่าเมื่อ workflow มีมากกว่าสามหรือสี่ขั้นตอน
สรุป
- เลือก sync หรือ async เป็นราย interaction ไม่ใช่เลือกทีเดียวทั้งระบบ
- ใส่ timeout ให้ทุกอย่าง และส่งต่อ deadline
- retry อย่างระมัดระวัง: ทำเพียง layer เดียว ใช้ backoff ที่มี jitter และมี budget
- ทำให้ทุกการเขียนเป็น idempotent และทุก consumer ก็เช่นกัน
- ใช้ circuit breaker และ bulkhead เพื่อจำกัดวงความล้มเหลว
- publish event ผ่าน outbox ห้ามใช้ dual write
distributed system จะล้มเหลวในแบบที่เป็นบางส่วนและชวนสับสนเสมอ pattern เหล่านี้ช่วยให้ความล้มเหลวเหล่านั้นมีขนาดเล็ก มองเห็นได้ และกู้คืนได้
