☕ 5-Min Tech Story #20

The Dual-Write Problem: เซฟลง DB แล้วยิงเข้า Kafka... ปัญหาโลกแตกที่ทำลายความจริงใจของระบบ

⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 30 วินาที

ในสถาปัตยกรรม Microservices หรือ Event-Driven ยุคนี้ นี่คือโค้ด 2 บรรทัดที่ดูไร้พิษภัยที่สุด แต่คร่าชีวิตระบบการเงินมานับไม่ถ้วน:

orderRepository.save(order) // สเต็ป 1: บันทึกลง PostgreSQL
kafkaTemplate.send("order-created", order) // สเต็ป 2: ยิง Event บอกคนอื่น

คำถามง่ายๆ: ถ้าบรรทัดแรกทำงานผ่าน แต่ก่อนจะเริ่มบรรทัดที่สอง... ปลั๊กเซิร์ฟเวอร์โดนเตะหลุดพอดี จะเกิดอะไรขึ้น?

ออเดอร์ถูกบันทึกลงฐานข้อมูลเรียบร้อย... แต่ข้อความไม่เคยถูกส่งเข้า Kafka แผนกตัดของในคลังไม่รู้เรื่อง ลูกค้าจ่ายเงินแต่ไม่เคยได้ของ! และต่อให้คุณสลับเอา Kafka ขึ้นก่อน ถ้า Kafka ส่งผ่านแล้ว DB ดับ ยิ่งบรรลัยหนักกว่าเดิม เพราะคลังตัดของแจกฟรีแต่ในระบบไม่มีประวัติซื้อ!

💔 ฝันร้ายของสาย Distributed: ไม่มีความรักที่พร้อมกันสองที่

ปัญหานี้ในวงการเรียกว่า The Dual-Write Problem (ปัญหาเขียนสองฝั่งพร้อมกัน)

Database ของคุณ (เช่น PostgreSQL หรือ MySQL) กับ Message Broker ของคุณ (เช่น Apache Kafka) เป็นคนละเทคโนโลยีที่ไม่ได้แชร์ Transaction ร่วมกัน

คุณไม่สามารถเอา @Transactional ของ Spring ไปครอบทั้งการบันทึกฐานข้อมูลและการยิง Kafka เพื่อให้มัน Rollback พร้อมกันได้ในชีวิตจริง เพราะโลก Distributed System ไม่มี Atomicity ข้ามระบบแบบง่ายๆ

📬 ท่าไม้ตายระดับตำนาน: Transactional Outbox Pattern

เพื่อแก้ปัญหานี้ วิศวกรซอฟต์แวร์เลยคิดค้นวิธีที่เรียบง่ายแต่ทรงพลัง โดยเปรียบเทียบเหมือน "ตู้จดหมายหน้าบ้าน (Outbox)":

"อย่าเพิ่งพยายามส่งจดหมายข้ามอำเภอ (Kafka) ในตอนนี้... จงเขียนจดหมายแล้วหย่อนลง 'ตู้จดหมายในบ้านตัวเอง' (ตารางใน DB เดียวกัน) เสียก่อน เพราะอะไรที่อยู่ในบ้านเดียวกัน เราคุมได้ 100%!"

ขั้นตอนการทำงานเปลี่ยนเป็นแบบนี้:

  1. สร้างตารางเพิ่มขึ้นมา 1 ตารางชื่อว่า outbox_events ในฐานข้อมูลเดิม
  2. เวลาบันทึก Order ให้เขียนทั้งข้อมูล Order และข้อความ Event ลงตาราง outbox_events ใน Database Transaction เดียวกัน (ถ้าพังก็พังคู่ ถ้ารอดก็รอดคู่ 100%)
  3. มีตัวช่วยส่งจดหมาย (เช่น Background Job หรือเครื่องมือ Change Data Capture อย่าง Debezium) คอยมาแอบอ่านตาราง outbox_events แล้วหยิบข้อความไปยิงใส่ Kafka ให้ทีหลัง
  4. พอยิงเข้า Kafka สำเร็จ ค่อยมาทำเครื่องหมายว่า PROCESSED หรือลบออกจากตาราง Outbox
// ทำใน Database ก้อนเดิม สบายใจหายห่วง
@Transactional
fun createOrder(order: Order) {
  orderRepository.save(order)
  outboxRepository.save(OutboxEvent("ORDER_CREATED", order.toJson()))
}

💡 5-Min Takeaway

"อย่าพึ่งพาโชคชะตาเมื่อต้องบันทึกข้อมูลข้ามระบบเครือข่าย" โค้ดที่เรียกของสองอย่างต่อกันทาง Network มีโอกาสพังตรงกลางเสมอ การใช้ Transactional Outbox Pattern ช่วยเปลี่ยนงานเสี่ยงตายข้ามระบบ ให้กลายเป็นการันตีระดับ ACID ภายใน Database เดียวกัน แล้วค่อยใช้โมเดล At-least-once delivery ส่งต่อให้ระบบภายนอกอย่างปลอดภัย