กลับไปยังบทความ

Backend

การสื่อสารระหว่าง Microservices ให้ทนทาน: Sync Call, Event, Retry และ Idempotency

ในระบบแบบกระจาย (distributed system) ทุก network call อาจล้มเหลว ค้าง หรือสำเร็จซ้ำสองครั้งได้ บทความนี้เปรียบเทียบการสื่อสารแบบ synchronous กับแบบ event-driven และอธิบายเรื่อง timeout, retry พร้อม backoff, idempotency key, circuit breaker และ outbox pattern

24 กันยายน 25696 นาทีในการอ่าน

Resilient Service Communication: Sync Calls, Events, Retries, and Idempotency

ภายใน 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 ห้าม retry 400 หรือ 422 เพราะ request ผิดตั้งแต่ต้น และจะผิดต่อไป
  • ใช้ exponential backoff ร่วมกับ jitter:
go
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 เชิงตรรกะ แล้วส่งไปพร้อมกับทุกความพยายาม

http
POST /payments
Idempotency-Key: 5f2b8a1e-3c4d-4e7f-9a0b-1c2d3e4f5a6b

ฝั่ง server จัดเก็บ key ไว้คู่กับผลลัพธ์:

sql
CREATE TABLE idempotency_keys (
    key         TEXT PRIMARY KEY,
    request_hash TEXT NOT NULL,
    response    JSONB,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

ในแต่ละ request:

  1. พยายาม insert key ถ้ามี key นี้อยู่แล้วพร้อม response ที่บันทึกไว้ ให้ คืน response ที่บันทึกไว้ โดยไม่ต้องทำงานซ้ำ
  2. ถ้ามี key อยู่แล้วแต่ hash ของ request body ไม่ตรงกัน ให้ปฏิเสธ request เพราะ client นำ key มาใช้ซ้ำโดยผิดพลาด
  3. นอกเหนือจากนั้น ให้ทำงานแล้วบันทึก 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 คลาสสิกมีหน้าตาแบบนี้:

go
db.Save(order)                // succeeds
broker.Publish(OrderPlaced{}) // process crashes before this line

ตอนนี้ order มีอยู่ในระบบแล้ว แต่ไม่มี downstream service ตัวไหนจะได้รับรู้เรื่องนี้เลย การสลับลำดับก็ไม่ช่วย เพราะคุณอาจ publish event ของ order ที่ไม่เคยถูกบันทึกจริง

transactional outbox แก้ปัญหานี้ได้:

  1. ใน database transaction เดียวกัน กับการเปลี่ยนแปลงทางธุรกิจ ให้ insert event ลงในตาราง outbox
  2. มี relay process แยกต่างหากคอยอ่านแถวที่ยังไม่ถูก publish แล้ว publish ไปยัง broker พร้อมทำเครื่องหมายว่าแถวนั้นส่งแล้ว
  3. ถ้า 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 (การกระทำเพื่อชดเชยหรือย้อนกลับ):

  1. Orders สร้าง order ในสถานะ pending
  2. Payments ตัดเงินจากบัตร หรือถ้าล้มเหลวจะ publish PaymentFailed
  3. Inventory จองสต็อก หรือถ้าล้มเหลวจะสั่ง compensation เพื่อคืนเงิน
  4. 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 เหล่านี้ช่วยให้ความล้มเหลวเหล่านั้นมีขนาดเล็ก มองเห็นได้ และกู้คืนได้

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ