ข้ามไปยังเนื้อหา

Remote Procedure Invocation

บริการ Order ต้องการที่อยู่จัดส่งของผู้ซื้อ ซึ่งอยู่ในบริการ Customer สิ่งที่เป็นธรรมชาติที่สุดในโลกคือการขอที่อยู่นั้นแล้วรอคำตอบ เหมือนกับที่ monolith เดิมเรียก method getAddress เป๊ะ ๆ ผู้เรียกต้องการการตอบกลับเพียงครั้งเดียวและทันที เพื่อจะได้สร้างคำสั่งซื้อต่อไปได้

คุณต้องการให้บริการหนึ่งเรียกใช้การทำงานบนอีกบริการหนึ่งและรับผลลัพธ์กลับมา โดยมีพิธีรีตองให้น้อยที่สุด ทีมงานคิดในแง่ของ method ที่รับอาร์กิวเมนต์และคืนค่าอยู่แล้ว และการทำงานนี้เป็นคำถามที่ต้องการคำตอบจริง ๆ ก่อนที่ผู้เรียกจะไปต่อได้ คุณจะทำให้การเรียกผ่านเครือข่ายรู้สึกเหมือนการเรียกฟังก์ชันที่มาแทนที่ได้อย่างไร โดยยังต้องระบุอย่างชัดเจนถึงสิ่งที่การเรียกฟังก์ชันไม่เคยต้องกังวล — การ serialize, timeout และ contract ที่ทั้งสองฝั่งเห็นพ้องกัน?

Remote Procedure Invocation (RPI) คือรูปแบบ synchronous, one-to-one ผู้เรียก (client) ส่งคำขอไปยัง instance บริการเดียวแล้วบล็อกจนกว่าการตอบกลับจะมาถึง โดยมี proxy หรือ client library ซ่อนกลไกไว้ ทำหน้าที่ serialize อาร์กิวเมนต์ ส่งผ่านสายไป รอ แล้ว deserialize ผลลัพธ์กลับมาเป็นค่าคืนธรรมดา

มีสอง transport ที่ครองพื้นที่:

  • REST over HTTP — ทรัพยากรระบุด้วย URL การทำงานแสดงผ่าน HTTP verb และ payload มักอยู่ในรูป JSON อ่านโดยมนุษย์ได้ มีอยู่ทุกหนทุกแห่ง และทดสอบได้ง่ายด้วย browser หรือ curl
  • gRPC — contract นิยามไว้ในไฟล์ .proto payload เป็น Protobuf แบบ binary ผ่าน HTTP/2 พร้อม client และ server stub ที่ถูกสร้างให้ กระชับกว่าและเร็วกว่า มี schema ที่เคร่งครัดและมีเวอร์ชัน และรองรับ streaming เป็นชั้นหนึ่ง

ไม่ว่าจะ transport ใด การโต้ตอบก็มีรูปร่างเดียวกัน: หนึ่งคำขอ หนึ่งการตอบกลับ และผู้เรียกรออยู่ระหว่างนั้น

sequenceDiagram
  participant C as Client (Order Service)
  participant S as Customer Service
  C->>S: GET /customers/42/address
  activate S
  S->>S: load customer 42
  S-->>C: 200 OK { address }
  deactivate S
  Note over C: caller was blocked until the response arrived
Remote Procedure Invocation — หนึ่งคำขอ หนึ่งการตอบกลับ ผู้เรียกถูกบล็อกอยู่ระหว่างนั้น

client แบบมี type สำหรับดึงที่อยู่จัดส่งของลูกค้า client ห่อหุ้ม transport ไว้เพื่อให้ผู้เรียกเขียนอะไรที่ใกล้เคียงกับการเรียก method ธรรมดา แต่ timeout ทำให้การมีอยู่ของเครือข่ายชัดเจนขึ้น แต่ละตัวอย่างสมบูรณ์ในตัวเองและเป็นสำนวนของภาษานั้น ๆ

type Address = { line1: string; city: string; postalCode: string };
class CustomerClient {
constructor(private baseUrl: string) {}
async getAddress(customerId: string): Promise<Address> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
try {
const res = await fetch(`${this.baseUrl}/customers/${customerId}/address`, {
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`customer service returned ${res.status}`);
}
return (await res.json()) as Address;
} finally {
clearTimeout(timer);
}
}
}
const customers = new CustomerClient('http://customer-service');
const address = await customers.getAddress('42');

สิ่งที่คุณได้:

  • ความเรียบง่าย โมเดลนี้คือสิ่งที่นักพัฒนาทุกคนรู้จักดีอยู่แล้ว: เรียก รอ ได้ผลลัพธ์ ไม่มี broker ให้ดูแล และไม่มีสถานะกลางทางให้คอยติดตาม
  • contract ที่ชัดเจน โดยเฉพาะกับ gRPC type ของคำขอและการตอบกลับถูกนิยามไว้ครั้งเดียวและบังคับใช้กับทั้งสองฝั่ง ดังนั้นความไม่ตรงกันจะถูกจับได้ตั้งแต่ตอน build

สิ่งที่คุณต้องจ่าย:

  • Temporal coupling (การผูกมัดในเชิงเวลา) เพราะผู้เรียกบล็อก ทั้งสองบริการจึงต้องพร้อมใช้งานในขณะเดียวกัน ถ้าฝั่งที่ถูกเรียกล่มหรือกำลัง deploy คำขอของผู้เรียกจะล้มเหลวทันที — ไม่มีบัฟเฟอร์คั่นกลาง
  • ความล้มเหลวลุกลามเป็นทอด ๆ (cascading failures) ฝั่งที่ถูกเรียกที่ช้าทำให้ผู้เรียกช้าตามไปด้วย thread หรือ connection ของผู้เรียกกองสะสมรออยู่ และความช้าก็แพร่ขึ้นไปต้นน้ำจนทั้งสายของบริการติดขัด service เดียวที่กำลังดิ้นรนจึงล้มทุกอย่างที่พึ่งพาอยู่ได้
  • ความพร้อมใช้งานที่ลดลง ความพร้อมใช้งานโดยรวมของสายการเรียกแบบ synchronous คือผลคูณของความพร้อมใช้งานของแต่ละข้อต่อ ดังนั้นยิ่งคำขอกระโดดมากครั้งเท่าใด ก็ยิ่งเปราะบางมากขึ้นเท่านั้น

เพราะรูปแบบความล้มเหลวเหล่านี้ อย่าเรียกแบบ synchronous ดิบ ๆ โดยไม่มีการป้องกัน ให้ห่อไว้ด้วย timeout, retry และ Circuit Breaker เพื่อให้ dependency ที่ล้มเหลวล้มเร็วแทนที่จะลากผู้เรียกล้มตามไปด้วย ในกรณีที่การโต้ตอบไม่จำเป็นต้องได้คำตอบทันทีจริง ๆ ให้เลือกใช้ Messaging แทน

  • Messaging — ทางเลือกแบบ asynchronous ที่ขจัด temporal coupling
  • Service Discovery — วิธีที่ client ค้นหาที่อยู่ที่จะส่งคำขอไปหา
  • API Gateway — มักเป็นประตูหน้าที่การเรียก RPI จากภายนอกเข้ามา
ข้อดีข้อแลกเปลี่ยน
เข้าใจง่าย — เหมือน function call ข้าม networkผูก caller กับ callee อย่างแน่นหนา — ล้มทั้งคู่
response ทันที — รู้ผลทันทีว่าสำเร็จหรือไม่network latency และ partial failure ซ่อนอยู่
ง่ายต่อการ test และ debugcascade failure — service หนึ่งช้าทำให้ caller ช้าตาม
เหมาะกับ query ที่ต้องการ response ทันทีavailability ขึ้นกับทุก dependency ใน chain

Synchronous Call Chain — service เรียก service เรียก service หลายชั้น

อาการ:

  • request เดียวเรียก service ห้าตัวแบบ sequential
  • latency รวม = sum ของ latency ทุก hop
  • service ตัวใดตัวหนึ่งช้าทำให้ทั้ง chain ช้า

Tight Coupling via RPC — caller รู้ implementation detail ของ callee

อาการ:

  • เปลี่ยน schema ของ response ใน service B ทำให้ service A พัง
  • ต้อง deploy service A และ B พร้อมกันเสมอ

💡 ตัวอย่างจากของจริง

Google (gRPC):

  • สร้าง gRPC สำหรับ internal service communication
  • ใช้ Protocol Buffers เพื่อ strong contract และ efficient serialization
  • ปัจจุบัน open source และใช้แพร่หลายใน microservices

Netflix:

  • ใช้ทั้ง REST และ gRPC ขึ้นกับ latency requirement
  • internal high-frequency call ใช้ gRPC, external API ใช้ REST
Remote Procedure Invocation เป็นการโต้ตอบชนิดใด?
temporal coupling ในบริบทของ RPI คืออะไร?
เหตุใดบริการที่ช้าเพียงตัวเดียวจึงทำให้เกิดความล้มเหลวลุกลามเป็นทอด ๆ กับ RPI ได้?
pattern ใดควรห่อการเรียกแบบ synchronous ไว้ เพื่อกันไม่ให้ลากผู้เรียกล้มตาม