สารบัญชุดบทความ

  1. Communication Styles และ Trade-offs
  2. แยก Background Job ก่อน Scale ข้าม Process
  3. ส่ง Job ข้าม Process ด้วย NATS
  4. ทำให้ Job ไม่หายด้วย NATS JetStream
  5. เมื่อ Job เติบโตเป็น Event Stream ด้วย Kafka
  6. เลือก Communication Style ให้เหมาะกับแต่ละ Service

เมื่อ service หนึ่งต้องพึ่งอีก service หนึ่ง

เมื่อเราแยกระบบออกเป็น microservices แต่ละ service อาจรับผิดชอบ job ของตัวเองได้ชัดเจนขึ้น แต่การทำ business workflow หนึ่งอย่างให้สำเร็จยังอาจต้องอาศัยหลาย service ร่วมกัน เช่น ใน food delivery app ลูกค้าเลือกเมนูจาก Restaurant Service จากนั้น Order Service คำนวณยอดที่ต้องจ่าย และเมื่อร้านรับ order แล้ว Rider Matching Service ต้องหา rider ไปรับอาหาร

คำถามจึงไม่ได้จบที่ “จะแบ่ง system อย่างไร” แต่ตามมาด้วย “service เหล่านี้ควร communicate กันอย่างไร”

ก่อนเลือก HTTP หรือ message broker เราควรเข้าใจ communication style ก่อน เพราะแต่ละทางเลือกเปลี่ยนทั้ง wait time ของลูกค้า วิธีรับมือเมื่อบาง service ล่ม และความยากในการ track ว่า job ไปถึงไหนแล้ว

บทนี้ใช้หัวข้อการสื่อสารเป็นกรอบในการอธิบาย โดยตัวอย่างและข้อพิจารณาด้านล่างเขียนขึ้นเพื่อประกอบความเข้าใจ ไม่ใช่คำแปลเนื้อหาในหนังสือ (อ้างอิงกรอบหัวข้อ: Building Microservices, 2nd Edition — Microservice Communication Styles)

การเรียกข้าม service ต่างจากการเรียก function ตรงไหน

ใน application เดียวกัน เราอาจเรียก function คำนวณราคาแล้วใช้ค่าที่ return มาทันที แต่เมื่อการคำนวณนั้นย้ายไปอีก service request ต้องเดินทางผ่าน network service ปลายทางอาจช้า ติดต่อไม่ได้ หรือทำงานเสร็จแล้วแต่ response ส่งกลับมาไม่ถึง

กรณีสุดท้ายสำคัญมาก สมมติ Order Service ขอให้ Payment Service ตัดเงินแล้วรอจน timeout สิ่งที่เรารู้คือ “ยังไม่ได้ response” ไม่ใช่ “ยังไม่ได้ตัดเงิน” หากส่ง request ใหม่โดยไม่คิดถึงกรณีนี้ ลูกค้าอาจถูกตัดเงินซ้ำ

ดังนั้นการเลือกวิธีสื่อสารจึงเป็นการเลือกด้วยว่า เราจะจัดการกับการรอและความไม่แน่นอนนี้อย่างไร

ทางเลือกแรก: รอ response ก่อนเดินต่อ

สมมติลูกค้าเลือกข้าวกะเพราจากร้านหนึ่งแล้ว และต้องเห็นค่าส่งจากร้านถึงบ้านก่อนกดยืนยัน order Order Service จึงถาม Delivery Fee Service แล้วรอราคากลับมาเพื่อรวมกับค่าอาหาร

สีใน diagram ใช้เหมือนกันตลอดชุดบทความ: Order / Order API = สีฟ้า; Pricing / Delivery Fee = สีส้ม; Rider Matching = สีเขียว; Queue / broker / event stream = สีเหลือง; Client / database / file / infrastructure = สีเทา สีบอก service หรือประเภทขององค์ประกอบ โดยชื่อในกล่องระบุหน้าที่ ส่วนกรอบแดงเส้นประพร้อมคำว่า “stopped” บอก worker ที่หยุดทำงาน

sequenceDiagram
    box rgb(219,234,254)
        participant Order as Order Service
    end
    box rgb(255,237,213)
        participant Shipping as Delivery Fee Service
    end
    Order->>Shipping: คำนวณค่าส่งจากร้านนี้ถึงบ้านลูกค้า
    Note over Order: รอผลก่อนคำนวณยอดรวม
    Shipping-->>Order: ค่าจัดส่ง 50 บาท
    Order->>Order: รวมค่าอาหารกับค่าจัดส่ง

รูปแบบนี้เรียกว่า synchronous blocking ผู้เรียกต้องรอ response ก่อนเดินต่อใน business workflow นั้น ข้อดีคือเห็นลำดับชัดเจน ถ้าได้ราคาแล้วก็ใช้ราคานั้น ถ้าเกิด error ก็จัดการตรงจุดที่เรียกได้ เหมาะกับ job ที่ต้องใช้ response เพื่อไปต่อจริง ๆ

สิ่งที่ต้องแลกคือความสำเร็จและ response time ของเราไปขึ้นกับอีก service หาก Delivery Fee Service ช้า หน้ายืนยัน order ก็ช้าตาม หากติดต่อไม่ได้ เราต้องตัดสินใจว่าจะให้ลูกค้ารอ retry หรือใช้ทางเลือกสำรองที่ business ยอมรับได้

ความสัมพันธ์ที่ต้องพร้อมทำงานในช่วงเวลาเดียวกันนี้เรียกว่า temporal coupling ยิ่งมีการเรียกต่อกันหลายทอด ก็ยิ่งมีจุดที่ทำให้ทั้ง workflow ต้องรอ

การกำหนด timeout ช่วยจำกัด wait time แต่ไม่ได้ยกเลิก job ที่ปลายทางโดยอัตโนมัติ และไม่ได้พิสูจน์ว่า job นั้นไม่สำเร็จ โดยเฉพาะ request ที่เปลี่ยนข้อมูล เราจึงต้องคิดต่อว่าจะตรวจผลหรือ retry อย่างไร

ทางเลือกที่สอง: ส่งงานไว้ แล้วค่อยรับรู้ผลภายหลัง

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

ถ้าคำตอบคือไม่ Order Service สามารถ enqueue rider job แล้วแสดง status “ร้านรับ order แล้ว กำลังหา rider” ส่วน Rider Matching Service รับ job ไปทำและ update ผลกลับมาภายหลัง

นี่คือ asynchronous nonblocking ผู้ส่งไม่ต้องรอผลลัพธ์สุดท้ายของ job นั้นก่อนเดินต่อ แต่ยังอาจต้องรอการยืนยันว่า enqueue สำเร็จ เช่น ตัวกลาง save message ไว้แล้ว การรับ job กับการทำ job เสร็จเป็นคนละจุดกัน

sequenceDiagram
    box rgb(241,245,249)
        participant Customer as ลูกค้า
    end
    box rgb(219,234,254)
        participant Order as Order Service
    end
    box rgb(254,243,199)
        participant Queue as Queue
    end
    box rgb(220,252,231)
        participant Matching as Rider Matching Service
    end
    Note over Order: ร้านรับ order แล้ว
    Order->>Queue: Enqueue rider job
    Queue-->>Order: รับ job ไว้แล้ว
    Order-->>Customer: ร้านรับ order แล้ว กำลังหา rider
    Matching->>Queue: รับ job เมื่อพร้อม
    Matching->>Matching: หา rider ที่รับ job ได้
    Matching-->>Order: จับคู่ rider สำเร็จ
    Order-->>Customer: แสดงข้อมูล rider

ประโยชน์คือ Order Service แสดงความคืบหน้าได้โดยไม่ต้องรอจนจับคู่สำเร็จ ถ้า queue เป็น durable queue ที่เก็บ job ไว้ได้แม้ service restart Rider Matching Service ก็กลับมาทำ job ค้างได้ แต่การใช้ asynchronous เพียงอย่างเดียวไม่ได้รับประกันว่า message จะถูกเก็บไว้ ต้องดูความสามารถและ configuration ของกลไกที่เลือกด้วย

trade-off ย้ายมาอยู่ที่การ track job เราต้องรู้ว่า order ไหนยังอยู่ในสถานะกำลังหา rider job ไหนค้างอยู่ และถ้าหา rider ไม่ได้ภายในเวลาที่กำหนดจะทำอย่างไร? business ต้องกำหนดทั้งวิธีแจ้งลูกค้าและทางออก เช่น ให้เจ้าหน้าที่ช่วยหรือยกเลิกพร้อมคืนเงินตามเงื่อนไข การตอบกลับเร็วไม่ได้แปลว่ารับประกันการจัดส่งสำเร็จแล้ว

การไม่รอจึงช่วยให้ส่วนหนึ่งเดินต่อได้ แต่ไม่ได้ทำให้ job ที่เหลือหายไปหรือเร็วขึ้นโดยอัตโนมัติ

อีกคำถามหนึ่ง: เรากำลังขอ response หรือ notify event

คำว่า synchronous และ asynchronous อธิบายเรื่องการรอ แต่ยังไม่ได้บอกความหมายของ communication เพื่อออกแบบให้ครบ เราต้องถามอีกข้อว่า “ผู้ส่งต้องการอะไรจากผู้รับ”

Request-response คือการส่ง request และคาดหวัง response เช่น “ค่าจัดส่งเท่าไร” แล้วรับราคากลับมา เราทำแบบ synchronous ได้อย่างตัวอย่างแรก หรือส่ง request แบบ asynchronous แล้วรอ response ที่ตามมาภายหลังก็ได้ กรณีหลังต้องมี ID สำหรับจับคู่ว่า response นี้เป็นของ request ไหน

แม้ application จะทำ job อื่นระหว่างรอได้ แต่ถ้า business workflow ยังต้องใช้ response นั้นก่อนดำเนินต่อ ความจำเป็นในการรอก็ยังอยู่

ส่วน event-driven communication เริ่มจากการ notify ข้อเท็จจริง เช่น “ร้านรับ order แล้ว” publisher ไม่ได้ระบุว่าทุกคนต้องทำอะไรต่อ Rider Matching Service อาจเริ่มหา rider Notification Service อาจส่ง message ให้ลูกค้า แต่ละ service ตอบสนองตามหน้าที่ของตัวเอง

ความต่างเห็นได้จากชื่อ message FindRider คือคำสั่งให้ทำ job หนึ่ง หรือ command ส่วน RestaurantAcceptedOrder คือ event ที่บอกว่าเหตุการณ์เกิดขึ้นแล้ว ผู้รับอาจมีหลายรายหรือยังไม่มีรายใดสนใจก็ได้

การ notify event ช่วยให้เพิ่ม consumer ใหม่ได้โดย publisher ไม่ต้องเรียกหา consumer แต่ละราย แต่ก็ทำให้ภาพรวมของ job กระจายไปหลายที่ เราอ่าน code ของ Order Service เพียงแห่งเดียวแล้วอาจไม่รู้ว่ามีอะไรเกิดตามมาบ้าง ทุกฝ่ายยังต้องตกลงความหมายของ event ให้ตรงกัน เช่น RestaurantAcceptedOrder หมายถึงร้านรับ order แล้ว ยังไม่ได้หมายความว่าทำอาหารเสร็จหรือมี rider รับ job แล้ว

การรอ response กับรูปแบบ communication จึงเป็นคนละแกน การใช้ HTTP ไม่ได้บังคับว่าต้องรอจน business workflow เสร็จ การใช้ broker ก็ไม่ได้แปลว่า message ทั้งหมดเป็น event เราสามารถส่ง command หรือทำ request-response ผ่าน broker ได้เช่นกัน

ทางเลือกที่สาม: ไม่ส่ง message หากันโดยตรง แต่แลกเปลี่ยนผ่านข้อมูลกลาง

บาง job ไม่จำเป็นต้องเกิดการ communication ทีละ request เช่น Accounting Service ต้องการคำนวณยอดที่ต้องจ่ายให้แต่ละร้านอาหารเมื่อสิ้นวัน Order Service อาจ export ไฟล์สรุป delivery ที่สำเร็จ และรายการ adjustment จาก cancellation หรือ refund แล้ว save ไฟล์ไว้ใน shared location ที่ทั้งสอง service ตกลงกัน จากนั้น Accounting Service จะมาอ่านไฟล์เมื่อพร้อม

นี่คือ communication through common data เป็นการสื่อสารผ่าน data ที่ทั้งสองฝ่ายเข้าถึงได้ เช่นไฟล์หรือ shared storage

ข้อดีคือไม่ต้องให้ทั้งสอง system online พร้อมกันตอนประมวลผล และเหมาะกับข้อมูลจำนวนมากที่รับเป็นรอบได้ trade-off คือข้อมูลมี delay ตามรอบส่ง ทั้งสองฝ่ายต้องตกลง data format วิธีบอกว่าไฟล์เขียนครบแล้ว และวิธีป้องกันการอ่านชุดเดิมซ้ำ

ถ้าเลือกใช้ table ใน database ของ service หนึ่งให้ service อื่นอ่านตรง ๆ ก็จะเกิดการพึ่งพา table schema นั้น การเปลี่ยน schema อาจกระทบ consumer อื่น การมี data set สำหรับแลกเปลี่ยนที่กำหนด contract ชัดเจนจึงต่างจากการเปิดรายละเอียด database ทั้งหมดให้ใช้ร่วมกัน

เลือกจากสิ่งที่ job ต้องการ

เราไม่จำเป็นต้องเลือกรูปแบบเดียวให้ทั้ง system หน้ายืนยัน order อาจรอค่าส่ง การจับคู่ rider ทำภายหลังพร้อมแสดง status ให้ลูกค้า Accounting Service อ่านไฟล์เพื่อคำนวณยอดจ่ายร้านอาหารตอนสิ้นวันใน system เดียวกัน แต่ละ service ไม่จำเป็นต้องใช้ communication style เดียวกัน Order Service อาจใช้ synchronous request เพื่อขอค่าส่งทันที Rider Matching Service อาจรับ rider job ไปทำแบบ asynchronous

Accounting Service อาจอ่านไฟล์สรุปตอนสิ้นวัน ทั้งสาม service ยังเป็น service แยกกัน และยังมี ownership ของตัวเอง เราเพียงเลือกวิธี communicate ให้เหมาะกับ business workflow ของแต่ละ service

คำถามที่ต้องตอบ สิ่งที่ช่วยตัดสินใจ
ต้องใช้คำตอบนี้ก่อนเดินต่อหรือไม่? ถ้าต้องใช้ การรอผลอาจเป็นสิ่งที่จำเป็นและเข้าใจง่ายที่สุด
consumer หยุดชั่วคราวแล้ว publisher ควรเดินต่อได้ไหม? ถ้าได้ ต้องมีกลไก enqueue job ที่เหมาะกับ durability ที่ต้องการ
ต้องการให้ทำ job เฉพาะอย่าง หรือแจ้งข้อเท็จจริง? ช่วยแยก command ออกจาก event และกำหนด ownership
รับข้อมูลที่มี delay เป็นรอบได้หรือไม่? ถ้าได้ การแลกเปลี่ยนผ่าน data set อาจเพียงพอ
หากไม่ได้ response หรือได้รับ message ซ้ำ จะเกิดอะไรขึ้น? ต้องออกแบบการตรวจผลและการ retry ให้สอดคล้องกับผลทางธุรกิจ

จุดเริ่มต้นของการเลือกจึงอยู่ที่ความหมายของ job และสิ่งที่ยอมให้เกิดขึ้นได้ เมื่อเรารู้ว่า job ไหนต้องรอ job ไหน enqueue ไว้ได้ และ consumer ต้องเข้าใจ message อย่างไร เราจึงค่อยเลือกเครื่องมือที่ทำให้ข้อตกลงเหล่านั้นเกิดขึ้นได้จริง