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

The Transactional Outbox

ไม่มีอะไรใหม่ OrderRepo.Create ของ The Order Repository → เขียน outbox row ของทุกคำสั่งซื้อใหม่อยู่ใน transaction เดียวกับตัวคำสั่งซื้อและรายการสินค้าอยู่แล้ว รายละเอียดจุดเดียวนั้นคือ transactional outbox pattern ทั้งหมด ซึ่ง implement เสร็จไปแล้ว บทนี้จึงอธิบายว่า ทำไม ถึงสำคัญ — ปัญหาที่ pattern นี้แก้ การรับประกันที่ให้และไม่ได้ให้ และ partial index ที่ทำให้ pattern นี้ใช้งานได้จริงเมื่อขยายสเกล Kafka → (Module 6) คือจุดที่ process แยกต่างหาก — relay — จะอ่านตารางนี้จริง ๆ แล้ว publish ไป Kafka ไม่มีอะไรตรงนี้ที่สร้างสิ่งนั้นขึ้นมา

นี่คือปัญหาในรูปแบบที่ตรงไปตรงมาที่สุด: CreateOrder ต้องทำสองอย่างเมื่อมีการสั่งซื้อ — บันทึกคำสั่งซื้อลง PostgreSQL และประกาศให้ส่วนที่เหลือของระบบรู้ด้วยการ publish order.created ไป Kafka สองอย่างนี้อยู่บนระบบที่แยกจากกันโดยสิ้นเชิง และไม่มีทางห่อการเขียนทั้งสองฝั่งไว้ในการดำเนินการแบบอะตอมิกเดียวได้ เพราะไม่มี transaction ข้ามระบบระหว่าง PostgreSQL กับ Kafka นี่คือ ปัญหา dual-write ซึ่งพังทั้งสองลำดับที่เป็นไปได้:

  • บันทึกก่อน แล้วค่อย publish: ถ้า process crash, เสีย network connection, หรือถูก Kubernetes kill ระหว่างการ commit ที่สำเร็จกับการเรียก publish ไป Kafka คำสั่งซื้อจะถูกบันทึกอย่างปลอดภัย แต่ event ไม่เคยถูกส่งเลย Payment ไม่มีวันรู้ว่าคำสั่งซื้อนี้มีอยู่ คำสั่งซื้อจะค้างที่ PENDING ตลอดไป — คำสั่งซื้อจริงที่มองไม่เห็นสำหรับส่วนที่เหลือของระบบอย่างเงียบ ๆ
  • Publish ก่อน แล้วค่อยบันทึก: ถ้า process crash หลังจาก Kafka ยืนยันการ publish แต่ก่อนที่ database transaction จะ commit consumer ตอนนี้เชื่อว่ามีคำสั่งซื้ออยู่ทั้งที่ไม่เคยถูกบันทึกจริง — event ผีที่ไม่ได้อธิบายอะไรเลย

ไม่มีลำดับไหนปลอดภัยด้วยตัวเอง และไม่ว่าจะจัดการ error รอบคอบแค่ไหนก็แก้ปัญหานี้ไม่ได้ — ปัญหาพื้นฐานคือ “บันทึกแล้ว” กับ “publish แล้ว” เป็นข้อเท็จจริงเกี่ยวกับสองระบบที่แยกกัน ที่ไม่มีทางทำให้เปลี่ยนแปลงพร้อมกันแบบอะตอมิกได้ ไม่ว่าจะเขียนตามลำดับไหนก็ตาม

Outbox pattern เลือกเลี่ยงปัญหานี้แทนที่จะแก้ตรง ๆ แทนที่จะพยายามทำให้การเขียนลง PostgreSQL กับ Kafka เป็นอะตอมิกร่วมกัน ให้เขียน เฉพาะลง PostgreSQL อย่างเดียว — ตัวคำสั่งซื้อ รายการสินค้า และแถวใหม่ในตาราง outbox ที่อธิบาย event ที่ต้อง publish ออกไปในภายหลัง

ทั้งสามอย่างเป็นแถวธรรมดาในฐานข้อมูลเดียว pgx.Tx ธรรมดาจึงทำให้เป็นอะตอมิกได้ เหมือนที่ The Order Repository → ทำไปแล้ว ไม่มี dual-write อีกต่อไป เพราะมีระบบเดียวที่เกี่ยวข้องกับ transaction

จากนั้น process แยกต่างหาก — relay ที่จะสร้างใน Kafka → — จะ poll ตาราง outbox หาแถวที่ยังไม่ publish ส่งแต่ละแถวไป Kafka แล้ว mark ว่า publish แล้ว relay ตัวนี้ crash, retry หรือตามงานไม่ทันได้โดยไม่เอาข้อมูลคำสั่งซื้อไปเสี่ยงเลย เพราะการ publish แยกขาดจากการเขียนต้นทางอย่างสมบูรณ์

Transactional outbox (relay อ่านตาราง) เทียบกับ dual-write ตรง ๆ (insert คำสั่งซื้อ แล้ว publish ไป Kafka ทันทีใน request เดียวกัน)

  • Pros: การสร้างคำสั่งซื้อกับ “event นี้จะถูก publish ในที่สุด” กลายเป็นอะตอมิก — เป็นไปไม่ได้ที่อย่างหนึ่งจะเกิดขึ้นโดยไม่มีอีกอย่าง; relay retry การ publish ได้ไม่จำกัดโดยไม่แตะข้อมูลคำสั่งซื้อเลย เพราะข้อมูลคำสั่งซื้อถูก commit อย่างปลอดภัยไปแล้วก่อนที่ relay จะมองแถวนั้นด้วยซ้ำ; CreateOrder ไม่มี runtime dependency ต่อการที่ Kafka ต้องเข้าถึงได้เลย — Kafka ล่มกลายเป็น backlog ของ outbox row ที่ยังไม่ publish ที่โตขึ้น ไม่ใช่การสร้างคำสั่งซื้อที่ล้มเหลว
  • Cons: การ publish ไม่ทันทีอีกต่อไป — มีความล่าช้าจริง แม้จะเล็กน้อยตามปกติ ระหว่าง “คำสั่งซื้อถูกสร้าง” กับ “event มองเห็นได้สำหรับ Kafka consumer” ขึ้นกับว่า relay poll ถี่แค่ไหน อีกทั้งต้องสร้าง component เพิ่มมาทั้งตัว (relay) แล้ว deploy และ monitor ด้วย ส่วนการ publish ตรง ๆ ต้องการแค่ Kafka client ที่อยู่ใน request path อยู่แล้ว นอกจากนี้ตาราง outbox เองก็ต้องมีนโยบายเก็บรักษาข้อมูล เพราะแถวที่ publish แล้วจะสะสมไปเรื่อย ๆ เว้นแต่จะมีอะไรมาลบหรือ archive ทิ้ง

At-least-once delivery ไม่ใช่ exactly-once — ผลที่ตามมาจากวิธีที่ relay เองล้มเหลวได้

  • Pros: การรับประกันว่า “event นี้จะถูก publish อย่างน้อยหนึ่งครั้ง” ทำได้ด้วยเครื่องมือธรรมดา (poll, publish, mark ว่า publish แล้ว) และเพียงพอที่จะสร้างระบบที่ถูกต้องบนฐานนี้ ตราบใดที่ consumer ทุกตัวเป็น idempotent; การพยายามรับประกัน delivery แบบ exactly-once ข้ามสองระบบที่เป็นอิสระต่อกันเป็นปัญหาที่ยากขึ้นชื่อ และแทบจะเป็นไปไม่ได้ในกรณีทั่วไป ดังนั้นการไม่พยายามทำแบบนั้นทำให้ relay เองยังคงเรียบง่าย
  • Cons: การรับประกันนี้โยนความซับซ้อนจริง ๆ ไปให้ consumer ทุกตัว แทนที่จะแก้ครั้งเดียวที่ relay ถ้า relay crash หลังจาก Kafka ยืนยันการ publish แต่ก่อนที่จะ mark outbox row เป็น published_at แถวนั้นจะยังดูเหมือนยังไม่ publish ตอน restart แล้วโดน publish ซ้ำ ดังนั้น order.created, payment.succeeded และทุก event อื่นในระบบนี้ต้องปลอดภัยเมื่อถูกประมวลผลสองครั้ง Architecture → ระบุข้อกำหนดนี้ไว้แล้ว และนี่คือกลไกที่ทำให้เป็นข้อบังคับ ไม่ใช่ทางเลือก

ไม่มีโค้ดใหม่ในบทนี้ — แค่สองส่วนที่เขียนไว้แล้วใน The Order Repository → มองผ่านมุมมองนี้

create table outbox ( id uuid primary key default gen_random_uuid(), aggregate_id uuid not null, event_type text not null, payload jsonb not null, created_at timestamptz not null default now(), published_at timestamptz );
create index on outbox (published_at) where published_at is null;

published_at คือกลไกทั้งหมด ทุกแถวเริ่มด้วยค่า null และงานของ relay (Module 6) สรุปได้ด้วยสอง operation บนคอลัมน์นี้คอลัมน์เดียว — หาแถวที่ค่ายังเป็น null แล้วเติมค่าลงไปเมื่อ publish แถวนั้นสำเร็จ

OrderRepo.Create insert outbox row หนึ่งแถวต่อคำสั่งซื้อใหม่หนึ่งครั้ง ด้วยรูปร่างนี้:

{
"order_id": "3a7c9e21-1e4d-4b8a-9c6e-2f8b1d5a7c90",
"customer_id": "cust-1",
"total_cents": 2598,
"items": [
{ "product_id": "8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21", "quantity": 2, "unit_price_cents": 1299 }
]
}

event_type คือ 'order.created', aggregate_id คือ id ของคำสั่งซื้อใหม่ — id เดียวกับที่ปรากฏอยู่ข้างในตัว payload เป็น order_id ซ้ำกันโดยตั้งใจ: aggregate_id คือคอลัมน์ที่ query ใช้กรองและทำ index เพราะเป็นคอลัมน์ uuid ธรรมดา ส่วน order_id ที่อยู่ใน JSON payload คือค่าที่ Kafka consumer อ่านจริงตอน deserialize event ที่ปลายทาง OrderRepo.UpdateStatus เขียนแถวรูปร่างเดียวกันสำหรับ order.confirmed/order.cancelled แค่มี payload เล็กกว่า (order_id กับ status ใหม่) — Order Saga → คือส่วนที่เรียก UpdateStatus ในที่สุดเมื่อผลลัพธ์ของ Payment กลับมา

ไม่มีอะไรในตาราง outbox เองที่รับประกันลำดับการ publish ข้ามคำสั่งซื้อคนละใบ relay เป็นคนตัดสินใจเรื่องนั้นผ่านวิธี query เช่น order by created_at เพื่อ publish แถวเก่าสุดก่อน

สิ่งที่สำคัญกว่าคือลำดับ ต่ออะกริเกต (per aggregate) ทุก event ของคำสั่งซื้อใบเดียวกันควรถึงมือ consumer ตามลำดับที่เกิดขึ้นจริง คือ order.created มาก่อน order.confirmed เสมอ ไม่มีทางสลับ Kafka → ทำได้ด้วยการใช้ aggregate_id เป็น Kafka partition key ทุก event ของคำสั่งซื้อใบเดียวกันจึงไปลง partition เดียวกัน และ Kafka รับประกันลำดับเฉพาะ ภายใน partition เท่านั้น ไม่ใช่ข้าม partition บทนี้แค่บันทึกการตัดสินใจนี้ไว้ ส่วนการ implement จริงใน relay เป็นงานของ Module 6

create index on outbox (published_at) where published_at is null;

Query หลักที่ relay ใช้ตลอดเวลาคือประมาณ select * from outbox where published_at is null order by created_at limit N รันต่อเนื่องไม่หยุด — ทุกแถวที่ยังไม่ publish ต้องหาให้เจอเร็ว ทุกครั้งที่ poll create index on outbox (published_at) แบบธรรมดาจะ index ทุกแถวในตารางตลอดไป รวมถึงแถวที่ publish ไปแล้วนับล้านแถวซึ่ง query นี้จะไม่แตะอีกเลยหลังติดสถานะเสร็จแล้ว

เงื่อนไข where published_at is null เปลี่ยนให้กลายเป็น partial index ที่เก็บเฉพาะแถวที่ relay ยังสนใจ ขนาดจึงโตตาม backlog ปัจจุบัน ไม่ใช่ตามประวัติทั้งหมดของตาราง index ที่เล็กและเร็ววันนี้ จะยังเล็กและเร็วในอีกหนึ่งปีข้างหน้า ไม่ว่าจะมีคำสั่งซื้อผ่านและ publish ไปแล้วกี่ล้านใบก็ตาม

สั่งซื้อสักหนึ่งรายการ (ถ้ายังไม่มีอะไรรันอยู่ ดูคำสั่ง grpcurl ที่ตรงกันได้จากส่วน Verify ของ The Order Repository →) แล้วดู outbox row ที่คำสั่งซื้อนั้นสร้างขึ้นโดยตรง:

Terminal window
psql "$ORDER_DB_URL" -c "select aggregate_id, event_type, published_at from outbox order by created_at desc limit 5;"
aggregate_id | event_type | published_at
---------------------------------------+---------------+--------------
3a7c9e21-1e4d-4b8a-9c6e-2f8b1d5a7c90 | order.created |

published_at ว่างเปล่า (null) — ยังไม่มีอะไร publish แถวนี้เลย เพราะยังไม่มีอะไรในคอร์สนี้สร้าง relay ขึ้นมา ที่เป็นไปตามที่คาดไว้ แถวนี้จะรออยู่ตรงนี้และค้นเจอด้วย partial index ทันทีที่ Kafka → สร้างตัวมาอ่าน

ตรวจสอบความเข้าใจของคุณ:

  • ทำไม request เดียวถึงบันทึกคำสั่งซื้อลง PostgreSQL กับ publish event ไป Kafka พร้อมกันแบบอะตอมิกไม่ได้?
  • การ commit outbox row อยู่ใน transaction เดียวกับคำสั่งซื้อรับประกันอะไร — และจงใจไม่รับประกันอะไร (คำใบ้: ความทันที)?
  • ถ้า relay crash หลังจาก Kafka ยืนยันการ publish แต่ก่อนที่จะอัปเดต published_at จะเกิดอะไรขึ้นตอน restart และทำไม consumer ทุกตัวต้องรับมือกับกรณีนี้ได้?
  • ถ้าไม่มี partial index จะเกิดอะไรขึ้นกับประสิทธิภาพของ query “หาแถวที่ยังไม่ publish” หลังจากคำสั่งซื้อที่ publish แล้วสะสมมาหนึ่งปีในตาราง?

จากนั้นยืนยันว่า module ยัง build ผ่าน — ไม่มีอะไรเปลี่ยน แต่ทุกบทในคอร์สนี้จบแบบเดียวกัน:

Terminal window
go build ./...

ไม่มี output แปลว่าสำเร็จ

ปัญหา dual-write — ไม่มีทางเขียนลง PostgreSQL กับ Kafka พร้อมกันแบบอะตอมิกในการดำเนินการเดียว — พังทั้งสองลำดับของ “บันทึกก่อน แล้วค่อย publish” และ “publish ก่อน แล้วค่อยบันทึก” ทั้งสองแบบเหลือช่วงเวลาที่สองระบบไม่ตรงกันว่า event ของคำสั่งซื้อเกิดขึ้นแล้วหรือยัง

Transactional outbox เลี่ยงปัญหานี้ไปทั้งหมด OrderRepo.Create เขียนตัวคำสั่งซื้อ รายการสินค้า และ outbox row ที่อธิบาย order.created อยู่ใน pgx.Tx เดียว จึงมีแค่ transaction ของระบบเดียวที่เกี่ยวข้อง จากนั้น relay แยกต่างหากที่สร้างใน Kafka → Module 6 จะ publish จากตารางนั้นตามจังหวะของตัวเอง แล้ว mark published_at ของแต่ละแถวเมื่อ Kafka ยืนยัน

relay ตัวนี้รับประกันได้แค่ delivery แบบ at-least-once เพราะ crash ระหว่าง “Kafka ยืนยันแล้ว” กับ “mark แถวว่า publish แล้ว” จะทำให้ publish event เดิมซ้ำ นั่นคือเหตุผลที่ consumer ทุกตัวของ order.created, payment.succeeded และ event อื่นในระบบนี้ต้อง idempotent ตามข้อกำหนดที่ Architecture → ระบุไว้ตั้งแต่ต้น ส่วน partial index บน outbox (published_at) where published_at is null ทำให้ query หลักของ relay เร็วอยู่เสมอ ไม่ว่าประวัติที่ publish ไปแล้วจะสะสมมากแค่ไหน นั่นคือ Module 4 เสร็จสมบูรณ์ — Order service สร้างคำสั่งซื้อจริงพร้อมราคาที่ถูกต้อง และบันทึก event ที่อธิบายคำสั่งซื้อนั้นได้อย่างน่าเชื่อถือ ต่อไป API Gateway → จะเปิด Catalog และ Order ให้ REST client เข้าถึงผ่าน grpc-gateway