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

Transactional Messaging

เซอร์วิสแทบไม่เคยทำงานเพียงลำพัง เมื่อ Order service ยืนยันคำสั่งซื้อ จะเกิดสองอย่างพร้อมกัน คือเขียนแถวลง database ของตัวเอง และบอกระบบส่วนที่เหลือว่าคำสั่งซื้อนี้มีอยู่แล้ว — โดยทั่วไปคือการ publish ข้อความหรือ event ไปยัง broker เพื่อให้ Inventory, Payment และ Notification service ตอบสนองได้ ทั้ง saga, event sourcing และ event-driven read model ต่างพึ่งพาขั้นตอนที่สองนี้ให้เกิดขึ้นอย่างเชื่อถือได้

แทบทุก microservice ที่น่าสนใจจึงทำสองอย่างนี้คู่กันบน code path เดียวกัน คือ commit การเปลี่ยนแปลงภายใน แล้ว publish ข้อความบอกเรื่องนั้นออกไป

ปัญหาคือฐานข้อมูลกับ message broker เป็นสองระบบอิสระที่มีสอง transaction อิสระ ไม่มี commit ร่วมกัน นั่นทำให้เกิดช่องว่าง และไม่ว่าคุณจะเลือกลำดับแบบไหน การ crash ในช่องว่างนั้นก็ทำให้ระบบเสียหาย

ถ้าคุณ publish ก่อนแล้วค่อยเขียนลงฐานข้อมูล การ crash หลัง publish จะทิ้งข้อความที่อ้างว่ามีบางอย่างเกิดขึ้นทั้งที่จริงไม่เกิด — นั่นคือ event แบบ ghost ถ้าคุณเขียนลงฐานข้อมูลก่อนแล้วค่อย publish การ crash หลัง commit แต่ก่อน publish จะทิ้งการเปลี่ยนแปลงที่โลกภายนอกไม่เคยได้ยิน — นั่นคือ event แบบ lost คุณไม่สามารถห่อทั้งสองอย่างไว้ใน transaction เดียวได้ เพราะ broker ไม่ได้เข้าร่วมใน database transaction ของคุณ (และ two-phase commit ข้ามระบบทั้งสองนั้นช้า เปราะบาง และมักไม่ได้รับการรองรับ)

นี่คือ ปัญหา dual-write: การเขียนสองครั้งที่ต้องเกิดทั้งคู่หรือไม่เกิดเลย ข้ามสองระบบที่ไม่สามารถใช้ transaction ร่วมกันได้

sequenceDiagram
  participant S as Service
  participant DB as Database
  participant B as Message Broker
  S->>DB: BEGIN, UPDATE order, COMMIT
  Note over S,B: crash happens HERE
  S--xB: publish OrderConfirmed (never sent)
  Note over DB,B: DB says confirmed, broker never heard — lost event
อันตรายของ dual-write — การ crash ระหว่าง commit และ publish ทำให้ event หายไป

ทุกแพตเทิร์นในโมดูลนี้ตั้งอยู่บนความเข้าใจเดียวกัน: เลิกพยายามเขียนลงสองระบบแบบ atomic แทนที่จะทำเช่นนั้น ให้ทำให้ข้อความเป็นส่วนหนึ่งของ local database transaction เดียวกันกับที่บันทึกการเปลี่ยนแปลงทางธุรกิจ แล้วแยกงานส่งข้อความไปให้ broker ออกไปเป็นขั้นตอนต่างหากที่ retry ได้ การ commit ของฐานข้อมูลกลายเป็น single source of truth ส่วนการส่งข้อความกลายเป็นผลที่ตามมา ไม่ใช่การเขียนครั้งที่สองที่มาแข่งกัน

ทั้งสี่แพตเทิร์นต่อยอดซึ่งกันและกัน:

  • Transactional Outbox — เขียนข้อความขาออกลงในตาราง outbox ใน local transaction เดียวกัน กับการเปลี่ยนแปลงทางธุรกิจ ตอนนี้ทั้งคู่สำเร็จด้วยกันหรือ roll back ด้วยกัน จากนั้น relay แยกต่างหากจะส่งข้อความที่บันทึกไว้ให้ broker ภายหลัง
  • Transaction Log Tailing — สร้าง relay ด้วยการอ่าน commit log ของฐานข้อมูลเอง (change data capture) เพื่อให้การ insert ลง outbox ไหลไปยัง broker โดยไม่ต้อง polling และแทบไม่เพิ่ม latency
  • Polling Publisher — สร้าง relay ด้วยการ query ตาราง outbox เป็นระยะเพื่อหาแถวที่ยังไม่ถูกส่งแล้ว publish ออกไป จัดการง่ายกว่า log tailing แต่แลกมาด้วย latency และภาระจากการ polling
  • Idempotent Consumer — เนื่องจาก relay ส่งแบบ at-least-once ข้อความเดียวกันจึงมาถึงได้สองครั้ง consumer ต้องตรวจจับและดูดซับ duplicate เพื่อให้การประมวลผลข้อความสองครั้งมีผลเหมือนกับการประมวลผลครั้งเดียว

เราเริ่มจากตารางที่ทำให้การเขียนเป็น atomic คือ Transactional Outbox จากนั้นดูสองวิธีในการ drain ตารางนั้น ได้แก่ Transaction Log Tailing และ Polling Publisher แล้วปิดท้ายที่ฝั่งรับ ซึ่งการส่งแบบ at-least-once บังคับให้ consumer ต้องเป็น idempotent แต่ละบทเรียนมีรูปแบบเดียวกัน: สถานการณ์ที่นำไปสู่แพตเทิร์น แรงที่ตึงเครียดกัน สิ่งที่แพตเทิร์นทำ ไดอะแกรม code ตัวอย่างที่รันได้ และการมองอย่างตรงไปตรงมาว่าแพตเทิร์นนั้นมีต้นทุนอะไร

  • Saga — พึ่งพาการ publish event ที่เชื่อถือได้ในทุกขั้นตอน
  • Event Sourcing — อีกวิธีหนึ่งในการทำให้ event เป็นการเขียนเดียวกันกับการเปลี่ยนแปลง state
ปัญหา dual-write คืออะไร?
ถ้าเซอร์วิส commit ลงฐานข้อมูลแล้ว crash ก่อน publish จะเกิดอะไรขึ้น?
ความเข้าใจร่วมที่อยู่เบื้องหลังทุกแพตเทิร์นในโมดูลนี้คืออะไร?