การสื่อสารใน Microservices
ใน monolith สองโมดูลทำงานร่วมกันด้วยการเรียกฟังก์ชันของกันและกัน ซึ่งก็คือการเรียก method บน stack เดียวกัน ใน process เดียวกัน และใช้หน่วยความจำร่วมกัน การเรียกแบบนี้ไม่มีวันล้มเหลวเพราะเครือข่าย ไม่ timeout ไม่มาถึงซ้ำสองครั้ง และไม่ทำข้อความหาย latency วัดกันเป็นนาโนวินาที ส่วนรูปแบบความล้มเหลวก็เป็นแค่บั๊กธรรมดาใน code เท่านั้น
พอคุณแยกโมดูลเหล่านั้นออกเป็น service คนละตัว ทุกการทำงานร่วมกันที่เคยเป็นการเรียกฟังก์ชันจะกลายเป็นข้อความที่ต้องข้ามเครือข่าย ซึ่งช้ากว่าหน่วยความจำมาก ทำ packet หล่นและสลับลำดับได้ แถม service อีกฝั่งอาจกำลัง deploy อยู่ รับโหลดเกิน หรือหายไปเฉย ๆ ก็ได้ การสื่อสารจึงเลิกเป็นเพียงรายละเอียดเล็กน้อย และกลายเป็นหนึ่งในประเด็นสำคัญที่นิยามทั้งระบบ
การเรียกฟังก์ชันซ่อนการตัดสินใจหลายอย่างที่การเรียกบริการบังคับให้คุณต้องเลือกอย่างชัดเจน ผู้เรียกจะรอคำตอบ หรือยิงทิ้งแล้วไปต่อ? ผู้เรียกกำลังพูดกับบริการเพียงหนึ่งเดียว หรือกระจายไปยังหลายบริการ? แล้วผู้เรียกจะหาที่อยู่ของบริการที่ต้องการได้อย่างไร ในเมื่อ instance เริ่มทำงาน หยุดทำงาน และย้ายที่อยู่ตลอดเวลา? และคุณจะป้องกันไม่ให้เครือข่ายสะดุดเพียงครั้งเดียวลุกลามกลายเป็นความขัดข้องที่ล้มทั้งระบบได้อย่างไร?
ไม่มีคำตอบเดียวที่ถูกต้อง คำตอบที่ถูกขึ้นอยู่กับการโต้ตอบ และระบบจริงก็ใช้หลายรูปแบบไปพร้อมกัน โมดูลนี้จะมอบคลังคำศัพท์สำหรับรูปแบบเหล่านั้น และ pattern ที่นำมาใช้งานจริง
วิธีแก้
หัวข้อที่มีชื่อว่า “วิธีแก้”การโต้ตอบระหว่างบริการแตกต่างกันไปตามแกนอิสระสองแกน
แกนแรกคือ synchronous กับ asynchronous ในการโต้ตอบแบบ synchronous ผู้เรียกส่งคำขอแล้วบล็อกรอจนกว่าจะได้รับการตอบกลับ ทั้งสองบริการถูกผูกมัดกันในเชิงเวลา เพราะทั้งคู่ต้องพร้อมใช้งานในขณะเดียวกัน ในการโต้ตอบแบบ asynchronous ผู้เรียกส่งข้อความให้ตัวกลางแล้วไปทำอย่างอื่นต่อ ผู้รับจะประมวลผลเมื่อใดก็ตามที่พร้อม ดังนั้นทั้งสองบริการไม่จำเป็นต้องทำงานพร้อมกันเลย
แกนที่สองคือ one-to-one กับ one-to-many การโต้ตอบแบบ one-to-one คือคำขอที่ถูกจัดการโดย instance หนึ่งของบริการหนึ่งเท่านั้น การโต้ตอบแบบ one-to-many คือข้อความที่กระจายออกไปยังผู้บริโภคทุกรายที่สนใจ ดังนั้น event หนึ่งสามารถขับเคลื่อนปฏิกิริยาอิสระได้หลายอย่าง
flowchart LR
subgraph Sync[Synchronous: caller waits]
A[Service A] -->|request| B[Service B]
B -->|response| A
end
subgraph Async[Asynchronous: caller moves on]
P[Producer] -->|message| Broker[(Broker)]
Broker --> C1[Consumer 1]
Broker --> C2[Consumer 2]
end การไขว้แกนทั้งสองทำให้ได้แผนผังง่าย ๆ ของรูปแบบการโต้ตอบ:
- Synchronous, one-to-one — การเรียกแบบ request/response ไปยังบริการเดียว นี่คือ Remote Procedure Invocation (RPI) ผ่าน REST หรือ gRPC
- Asynchronous, one-to-one — command ที่ส่งไปยังผู้บริโภคหนึ่งรายผ่าน queue และประมวลผลเมื่อพร้อม
- Asynchronous, one-to-many — event ที่เผยแพร่ไปยัง topic และส่งถึงผู้สมัครรับทุกราย ทั้งสองรูปแบบ async คือ Messaging
เหนือรูปแบบดิบเหล่านี้มี pattern ที่จัดระเบียบการสื่อสารสำหรับทั้งระบบ:
- Remote Procedure Invocation — รูปแบบที่ง่ายที่สุด: การเรียก request/response แบบมี type เข้าใจได้ง่าย แต่ผูกมัดกันในเชิงเวลา
- Messaging — การสื่อสารผ่าน broker ลดการผูกมัดของบริการในเชิงเวลาและรองรับการกระจาย (broadcast)
- API Gateway — จุดเข้าจุดเดียวที่จัดเส้นทาง รวมผลลัพธ์ และรับภาระ cross-cutting concerns ให้กับ client ภายนอก
- Backends for Frontends — gateway แยกต่างหากสำหรับ client แต่ละชนิด แต่ละตัวปรับแต่งเฉพาะและเป็นเจ้าของโดยทีม client
- Service Discovery — วิธีที่ผู้เรียกค้นหาตำแหน่งบนเครือข่ายของ instance บริการเมื่อ instance เกิดและดับ
โมดูลนี้ครอบคลุมอะไรบ้าง
หัวข้อที่มีชื่อว่า “โมดูลนี้ครอบคลุมอะไรบ้าง”เราเริ่มจากรูปแบบการโต้ตอบดิบสองแบบ — Remote Procedure Invocation และ Messaging — เพราะ pattern ที่สูงกว่าทุกตัวสร้างขึ้นจากรูปแบบเหล่านี้ จากนั้นเราดูว่า client ภายนอกเข้าถึงระบบผ่าน API Gateway อย่างไร แนวคิดนั้นพิเศษขึ้นกลายเป็น Backends for Frontends อย่างไร และสุดท้ายว่าผู้เรียกใด ๆ ค้นหา instance ที่ยังมีชีวิตอยู่ผ่าน Service Discovery อย่างไร แต่ละบทเรียนมีรูปแบบเดียวกัน: สถานการณ์ที่นำไปสู่ pattern, แรงที่ขัดแย้งกัน, สิ่งที่ pattern ทำ, แผนภาพ, ตัวอย่าง และการมองอย่างตรงไปตรงมาว่าต้องจ่ายอะไรบ้าง
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”- Remote Procedure Invocation — เริ่มที่นี่ รูปแบบที่ง่ายที่สุด
- Messaging — ทางเลือกแบบ asynchronous