รูปแบบ Outbox ใน PostgreSQL สำหรับการเชื่อมต่อ API ที่เชื่อถือได้
เรียนรู้รูปแบบ outbox เพื่อเก็บเหตุการณ์ใน PostgreSQL แล้วส่งไปยัง API ภายนอกด้วยการรีทราย การจัดลำดับ และการป้องกันการซ้ำซ้อน

ทำไมการเชื่อมต่อถึงล้มเหลวบ่อยทั้งที่แอปของคุณทำงานได้\n\nเป็นเรื่องปกติที่จะเห็นการกระทำที่ “สำเร็จ” ในแอปของคุณขณะที่การเชื่อมต่อข้างหลังล้มเหลวโดยเงียบ ๆ การเขียนลงฐานข้อมูลรวดเร็วและเชื่อถือได้ แต่การเรียก API ภายนอกไม่ใช่แบบนั้น นี่จึงสร้างโลกสองใบ: ระบบของคุณบอกว่าการเปลี่ยนแปลงเกิดขึ้น แต่ระบบภายนอกไม่เคยได้รับแจ้ง\n\nตัวอย่างทั่วไป: ลูกค้าสั่งซื้อ สินค้า แอปของคุณบันทึกคำสั่งใน PostgreSQL แล้วพยายามแจ้งผู้ให้บริการขนส่ง แต่ถ้าผู้ให้บริการตอบช้า 20 วินาทีและคำขอของคุณยกเลิก คำสั่งยังถูกบันทึก แต่การสร้างการจัดส่งไม่เกิดขึ้น\n\nผู้ใช้จะพบพฤติกรรมที่สับสนและไม่สอดคล้อง เหตุการณ์ที่หายไปจะดูเหมือน “ไม่มีอะไรเกิดขึ้น” เหตุการณ์ซ้ำจะกลายเป็น “ทำไมฉันถูกเรียกเก็บเงินสองครั้ง?” ทีมซัพพอร์ตก็ลำบากเช่นกันเพราะยากจะบอกว่าสาเหตุเกิดจากแอปเรา เครือข่าย หรือพาร์ทเนอร์\n\nการรีทรายช่วยได้ แต่เพียงอย่างเดียวยังไม่รับประกันความถูกต้อง หากคุณรีทรายหลัง timeout คุณอาจส่งเหตุการณ์เดียวกันสองครั้งเพราะไม่รู้ว่าพาร์ทเนอร์ได้รับคำขอแรกหรือไม่ หากรีทรายผิดลำดับ คุณอาจส่ง “Order shipped” ก่อน “Order paid”\n\nปัญหาเหล่านี้มักมาจากการประมวลผลพร้อมกัน: หลาย worker ทำงานขนาน หลายเซิร์ฟเวอร์เขียนพร้อมกัน และคิวแบบ "best effort" ที่เวลาจะเปลี่ยนภายใต้โหลด โหมดความล้มเหลวเหล่านี้คาดเดาได้: API ล่มหรือช้า เครือข่ายทิ้งคำขอ กระบวนการล่มในจังหวะไม่ดี และการรีทรายสร้างการส่งซ้ำเมื่อไม่มีอะไรบังคับให้เป็น idempotent\n\nรูปแบบ outbox ถูกสร้างขึ้นเพราะความล้มเหลวเหล่านี้เป็นเรื่องปกติ\n\n## รูปแบบ outbox คืออะไร แบบเข้าใจง่าย\n\nรูปแบบ outbox ทำงานเรียบง่าย: เมื่อแอปของคุณทำการเปลี่ยนแปลงที่สำคัญ (เช่น สร้างคำสั่งซื้อ) มันจะเขียนบันทึก “เหตุการณ์ที่จะส่ง” เล็ก ๆ ลงในตารางฐานข้อมูลพร้อมกับการเปลี่ยนแปลงธุรกิจในธุรกรรมเดียว หากการ commit ของฐานข้อมูลสำเร็จ คุณจะรู้ว่าข้อมูลธุรกิจและแถวเหตุการณ์อยู่ด้วยกัน\n\nจากนั้น worker แยกต่างหากจะอ่านตาราง outbox และส่งเหตุการณ์เหล่านั้นไปยัง API ภายนอก หาก API ช้า หยุดให้บริการ หรือ timeout คำขอหลักของผู้ใช้ยังสำเร็จเพราะไม่ได้รอการเรียกภายนอก\n\nวิธีนี้หลีกเลี่ยงสถานะที่ยุ่งยากเมื่อคุณเรียก API ภายใน request handler:\n\n- คำสั่งถูกบันทึก แต่การเรียก API ล้มเหลว\n- การเรียก API สำเร็จ แต่แอปของคุณล้มเหลวก่อนบันทึกคำสั่ง\n- ผู้ใช้ลองใหม่ แล้วคุณส่งสิ่งเดียวกันสองครั้ง\n\nรูปแบบ outbox ช่วยลดเหตุการณ์สูญหาย ความล้มเหลวบางส่วน (ฐานข้อมูลโอเค API ภายนอกไม่โอเค) การส่งซ้ำโดยไม่ได้ตั้งใจ และการรีทรายที่ปลอดภัยขึ้น (คุณสามารถลองใหม่ทีหลังโดยไม่เดา)\n\nมันไม่ได้แก้ทุกอย่าง หากเพย์โหลดผิด กฎธุรกิจผิด หรือ API ภายนอกปฏิเสธข้อมูล คุณยังต้องมีการตรวจสอบความถูกต้อง การจัดการข้อผิดพลาดที่ดี และวิธีตรวจสอบและแก้ไขเหตุการณ์ที่ล้มเหลว\n\n## ออกแบบตาราง outbox ใน PostgreSQL\n\nตาราง outbox ที่ดีควรน่าเบื่อโดยตั้งใจ ควรเขียนง่าย อ่านง่าย และยากต่อการใช้งานผิดวิธี\n\nนี่คือสคีมาพื้นฐานที่ใช้งานได้จริงเพื่อปรับใช้:\n\n```sql
create table outbox_events (
id bigserial primary key,
aggregate_id text not null,
event_type text not null,
payload jsonb not null,
status text not null default 'pending',
created_at timestamptz not null default now(),
available_at timestamptz not null default now(),
attempts int not null default 0,
locked_at timestamptz,
locked_by text,
meta jsonb not null default '{}'::jsonb
);
```\n\n### การเลือก ID\n\nการใช้ bigserial (หรือ bigint) ทำให้การจัดลำดับง่ายและดัชนีทำงานเร็ว UUID เหมาะสำหรับความเป็นเอกลักษณ์ข้ามระบบ แต่ไม่เรียงตามลำดับการสร้างเสมอ ซึ่งอาจทำให้การ polling คาดเดาไม่ได้และดัชนีหนักขึ้น\n\nทางออกที่ใช้กันบ่อยคือ: ให้เก็บ id เป็น bigint เพื่อการจัดลำดับ และเพิ่ม event_uuid แยกต่างหากหากต้องการตัวระบุคงที่เพื่อแชร์ระหว่างบริการ\n\n### ดัชนีที่สำคัญ\n\nworker ของคุณจะคิวรีรูปแบบเดิมซ้ำ ๆ ตลอดวัน ระบบส่วนใหญ่ต้องการ:\n\n- ดัชนีเช่น (status, available_at, id) เพื่อดึงเหตุการณ์รอดำเนินการถัดไปตามลำดับ\n- ดัชนีบน (locked_at) หากคุณวางแผนจะหมดอายุล็อกที่ค้างอยู่\n- ดัชนีเช่น (aggregate_id, id) หากคุณบางครั้งต้องส่งตาม aggregate ต่อเนื่องกัน\n\n### ทำให้ payload คงที่\n\nเก็บ payload ให้เล็กและคาดเดาได้ เก็บสิ่งที่ผู้รับต้องการจริง ๆ ไม่ใช่ทั้งแถวของคุณ เพิ่มเวอร์ชันชัดเจน (เช่น ใน meta) เพื่อให้คุณพัฒนาโครงสร้างฟิลด์ได้อย่างปลอดภัย\n\nใช้ meta เพื่อการเดินทางและบริบทการดีบัก เช่น tenant ID, correlation ID, trace ID และ dedup key บริบทเพิ่มเติมนี้จะคุ้มค่าเมื่อต้องตอบว่า “เกิดอะไรขึ้นกับคำสั่งนี้?”\n\n## การเก็บเหตุการณ์อย่างปลอดภัยพร้อมการเขียนธุรกิจ\n\nกฎที่สำคัญที่สุดเรียบง่าย: เขียนข้อมูลธุรกิจและแถว outbox ในธุรกรรมฐานข้อมูลเดียว หากธุรกรรม commit ทั้งคู่จะอยู่ด้วยกัน หาก rollback ทั้งคู่จะไม่มีอยู่\n\nตัวอย่าง: ลูกค้าสร้างคำสั่ง ในธุรกรรมเดียวคุณ insert แถวคำสั่ง แถวรายการสินค้า และแถว outbox เช่น order.created หากขั้นตอนใดล้มเหลว คุณไม่ต้องการให้เหตุการณ์ “created” หลุดไปยังโลกภายนอก\n\n### หนึ่งเหตุการณ์หรือหลายเหตุการณ์?\n\nเริ่มจากหนึ่งเหตุการณ์ต่อการกระทำธุรกิจเมื่อเป็นไปได้ ง่ายต่อการเข้าใจและประมวลผลถูกกว่า แยกเป็นหลายเหตุการณ์ก็ต่อเมื่อผู้บริโภคต่างกันต้องการเวลาในการทำงานหรือ payload ต่างกันจริง ๆ (เช่น order.created สำหรับการจัดส่ง และ payment.requested สำหรับการเรียกเก็บเงิน) การสร้างหลายเหตุการณ์จากการคลิกเดียวจะเพิ่มการรีทราย ปัญหาการจัดลำดับ และการจัดการการซ้ำซ้อน\n\n### ควรเก็บ payload อะไร?\n\nโดยทั่วไปคุณเลือกระหว่าง:\n\n- Snapshot: เก็บฟิลด์สำคัญในเวลานั้น (ยอดคำสั่ง สกุลเงิน รหัสลูกค้า) วิธีนี้หลีกเลี่ยงการอ่านเพิ่มภายหลังและทำให้ข้อความคงที่\n- Reference ID: เก็บเพียง ID ของคำสั่งแล้วให้ worker โหลดรายละเอียดภายหลัง วิธีนี้ทำให้ outbox เล็ก แต่เพิ่มการอ่านและข้อมูลอาจเปลี่ยนหากคำสั่งถูกแก้ไข\n\nแนวทางที่ใช้งานได้จริงคือเก็บตัวระบุพร้อม snapshot ขนาดเล็กของค่าที่สำคัญ เพื่อให้ผู้รับทำงานได้เร็วและช่วยการดีบัก\n\nรักษาเขตของธุรกรรมให้กระชับ อย่าเรียก API ภายนอกภายในธุรกรรมเดียวกัน\n\n## การส่งเหตุการณ์ไปยัง API ภายนอก: วงจรของ worker\n\nเมื่อเหตุการณ์อยู่ใน outbox แล้ว คุณต้องมี worker ที่อ่านและเรียก API ภายนอก นี่คือส่วนที่เปลี่ยนรูปแบบให้เป็นการเชื่อมต่อที่เชื่อถือได้\n\nการ polling มักเป็นตัวเลือกที่เรียบง่ายที่สุด LISTEN/NOTIFY ลดความหน่วงได้ แต่เพิ่มชิ้นส่วนและยังต้องมี fallback เมื่อตัวแจ้งเตือนหายหรือ worker รีสตาร์ท สำหรับทีมส่วนใหญ่ การ polling แบบสม่ำเสมอด้วยชุดขนาดเล็กจะง่ายต่อการรันและดีบัก\n\n### การอ้างสิทธิ์แถวอย่างปลอดภัย\n\nworker ควรอ้างสิทธิ์แถวเพื่อให้สอง worker ไม่ประมวลผลเหตุการณ์เดียวกันพร้อมกัน ใน PostgreSQL วิธีที่นิยมคือเลือกชุดโดยใช้ล็อกแถวและ SKIP LOCKED แล้วทำเครื่องหมายเป็นกำลังประมวลผล\n\nโฟลว์สถานะที่ใช้งานได้จริงคือ:\n\n- pending: พร้อมส่ง\n- processing: ถูกล็อกโดย worker (ใช้ locked_by และ locked_at)\n- sent: ส่งเรียบร้อย\n- failed: หยุดหลังจากพยายามครบครั้งสูงสุด (หรือนำไปตรวจด้วยมือ)\n\nเก็บชุดเล็ก ๆ เพื่อถนอมฐานข้อมูล ชุด 10–100 แถว ทุก 1–5 วินาที เป็นจุดเริ่มต้นที่ดี\n\nเมื่อการเรียกสำเร็จ ให้ทำเครื่องหมายแถวเป็น sent เมื่อล้มเหลว ให้เพิ่ม attempts ตั้ง available_at ไปยังเวลาข้างหน้า (backoff) ล้างล็อก และคืนไปเป็น pending\n\n### การล็อกที่ช่วยดี (โดยไม่รั่วไหลความลับ)\n\nบันทึกที่ดีทำให้ข้อผิดพลาดจัดการได้ บันทึก id ของ outbox, event type, ชื่อปลายทาง, จำนวนครั้งที่พยายาม, เวลา และสถานะ HTTP หรือชนิดข้อผิดพลาด หลีกเลี่ยง body ของคำขอ เฮดเดอร์การยืนยันตัวตน และการตอบกลับเต็มรูปแบบ หากต้องการ correlation ให้เก็บ request ID ที่ปลอดภัยหรือแฮชแทนข้อมูลเพย์โหลดดิบ\n\n## กฎการจัดลำดับที่ใช้ได้จริงในระบบจริง\n\nหลายทีมเริ่มจาก “ส่งตามลำดับที่เราสร้าง” ข้อจำกัดคือ “ลำดับเดียวกัน” มักไม่ใช่ระดับโลก หากบังคับคิวเดียวช้า ลูกค้าหนึ่งคนหรือ API ผุ้ให้บริการช้าอาจกั้นทุกคนได้\n\nกฎปฏิบัติ: รักษาลำดับต่อกลุ่ม ไม่ใช่ทั้งระบบ เลือกคีย์จัดกลุ่มที่สอดคล้องกับมุมมองของระบบภายนอก เช่น customer_id, account_id, หรือ aggregate_id เช่น order_id แล้วรับประกันลำดับภายในแต่ละกลุ่มในขณะที่ส่งหลายกลุ่มพร้อมกัน\n\n### worker ขนานโดยไม่ทำลายลำดับ\n\nรัน worker หลายตัว แต่มั่นใจว่าไม่มีสอง worker ประมวลผลกลุ่มเดียวกันพร้อมกัน วิธีที่ใช้กันคือส่งเหตุการณ์ที่ยังไม่ได้ส่งที่เก่าที่สุดสำหรับ aggregate_id แล้วอนุญาตการขนานข้าม aggregate ต่างกัน\n\nกฎการอ้างสิทธิ์ที่เรียบง่าย:\n\n- ส่งเฉพาะเหตุการณ์ pending ที่เก่าที่สุดต่อกลุ่ม\n- อนุญาตขนานข้ามกลุ่ม ไม่ใช่ภายในกลุ่ม\n- อ้างสิทธิ์เหตุการณ์หนึ่ง ส่งมัน อัปเดตสถานะ แล้วไปต่อ\n\n### เมื่อเหตุการณ์หนึ่งกีดขวางที่เหลือ\n\nสักวันหนึ่งจะมีเหตุการณ์ “พิษ” หนึ่งรายการที่ล้มเหลวนานเป็นชั่วโมง หากคุณบังคับลำดับต่อกลุ่มอย่างเข้มงวด เหตุการณ์ถัดไปในกลุ่มนั้นต้องรอ แต่กลุ่มอื่น ๆ ควรดำเนินต่อ\n\nข้อประนีประนอมที่ใช้งานได้คือจำกัดการรีทรายต่อเหตุการณ์ เมื่อถึงขีดจำกัด ให้ทำเครื่องหมายเป็น failed และหยุดแค่กลุ่มนั้นจนกว่าจะมีคนแก้ไข นี่ทำให้ลูกค้าหนึ่งรายที่มีปัญหาไม่ชะลอทุกคน\n\n## รีทรายโดยไม่ทำให้แย่ลง\n\nการรีทรายเป็นจุดที่การตั้งค่า outbox ดีจะกลายเป็นเชื่อถือได้หรือสร้างเสียงรบกวน เป้าหมายคือ: ลองใหม่เมื่อมีโอกาสสำเร็จ และหยุดเร็วเมื่อไม่มีทาง\n\nใช้ exponential backoff และขีดจำกัดสูงสุด เช่น: 1 นาที, 2 นาที, 4 นาที, 8 นาที แล้วหยุด (หรือไปต่อด้วยดีเลย์สูงสุดเช่น 15 นาที) กำหนดจำนวนครั้งสูงสุดเสมอเพื่อไม่ให้เหตุการณ์หนึ่งอุดระบบตลอดไป\n\nไม่ใช่ทุกความล้มเหลวที่ควรรีทราย กําหนดกฎให้ชัดเจน:\n\n- รีทราย: timeouts เครือข่าย การรีเซ็ตการเชื่อมต่อ DNS hiccups และการตอบ HTTP 429 หรือ 5xx\n- ไม่รีทราย: HTTP 400 (bad request), 401/403 (ปัญหา auth), 404 (endpoint ผิด) หรือข้อผิดพลาดการตรวจสอบความถูกต้องที่ตรวจพบก่อนส่ง\n\nเก็บสถานะรีทรายในแถว outbox เพิ่ม attempts ตั้ง available_at สำหรับการลองครั้งถัดไป และบันทึกสรุปข้อผิดพลาดสั้น ๆ ที่ปลอดภัย (รหัสสถานะ ชนิดข้อผิดพลาด ข้อความตัดทอน) อย่าเก็บเพย์โหลดเต็มหรือข้อมูลไวต่อความลับในฟิลด์ข้อผิดพลาด\n\nการจัดการ rate limit ต้องพิเศษ หากได้รับ HTTP 429 ให้เคารพ Retry-After ถ้ามี มิฉะนั้นถอยออกมากขึ้นเพื่อหลีกเลี่ยงการโจมตีด้วยการรีทรายซ้อนกัน\n\n## พื้นฐานการกำจัดการซ้ำและการทำให้เป็น idempotent\n\nถ้าคุณสร้างการเชื่อมต่อที่เชื่อถือได้ ให้คาดว่าจะส่งเหตุการณ์เดียวกันซ้ำได้ Worker อาจล้มหลังการเรียก HTTP แต่ก่อนบันทึกความสำเร็จ การ timeout อาจซ่อนความสำเร็จ การรีทรายอาจทับซ้อนกับความพยายามช้าครั้งแรก รูปแบบ outbox ลดเหตุการณ์ที่สูญหาย แต่ไม่ได้ป้องกันการซ้ำด้วยตัวเอง\n\nแนวทางที่ปลอดภัยคือ idempotency: การส่งซ้ำหลายครั้งให้ผลเหมือนการส่งครั้งเดียว เมื่อเรียก API ภายนอก ให้ใส่คีย์ idempotency ที่คงที่สำหรับเหตุการณ์นั้นและปลายทางนั้น APIs หลายตัวรองรับผ่านเฮดเดอร์ หากไม่รองรับ ให้ใส่คีย์ใน body ของคำขอ\n\nคีย์ง่าย ๆ คือ ปลายทางบวก ID เหตุการณ์ สำหรับเหตุการณ์ ID evt_123 ให้ใช้เช่น destA:evt_123 เสมอ\n\nฝั่งคุณ ป้องกันการส่งซ้ำโดยเก็บบันทึกการส่งภายนอกและบังคับกฎเอกลักษณ์เช่น (destination, event_id) แม้สอง worker แข่งกัน ก็มีเพียงคนเดียวที่สร้างบันทึก “เรากำลังส่งนี้” ได้สำเร็จ\n\n### เว็บฮุกก็ซ้ำได้เช่นกัน\n\nหากคุณรับ webhook callback (เช่น “การส่งยืนยัน” หรือ “สถานะอัปเดต”) จัดการเหมือนกัน ผู้ให้บริการจะรีทรายและคุณอาจเห็นเพย์โหลดซ้ำเก็บ ID ของ webhook ที่ประมวลผลแล้ว หรือลงรหัสแฮชจาก message ID ของผู้ให้บริการและปฏิเสธสำเนา\n\n### เก็บข้อมูลนานเท่าไร\n\nเก็บแถว outbox จนกว่าจะบันทึกความสำเร็จ (หรือความล้มเหลวสุดท้ายที่ยอมรับได้) เก็บบันทึกการส่งให้นานกว่า เพราะเป็นบันทึกตรวจสอบเมื่อมีคนถามว่า “เราส่งไหม?”\n\nแนวทางที่ใช้กันบ่อย:\n\n- แถว outbox: ลบหรือเก็บถาวรหลังจากสำเร็จบวกหน้าต่างความปลอดภัยสั้น ๆ (วัน)\n- บันทึกการส่ง: เก็บเป็นสัปดาห์หรือเดือน ขึ้นกับการปฏิบัติตามและความต้องการฝ่ายสนับสนุน\n- คีย์ idempotency: เก็บอย่างน้อยนานเท่าช่วงที่รีทรายสามารถเกิดขึ้น (และนานกว่าสำหรับการซ้ำ webhook)\n\n## ขั้นตอนทีละขั้น: การใช้งานรูปแบบ outbox\n\nตัดสินใจว่าจะเผยแพร่อะไร เก็บเหตุการณ์ให้เล็ก มุ่งเป้า และสามารถเล่นซ้ำได้กฎที่ดีคือข้อเท็จจริงทางธุรกิจหนึ่งข้อต่อเหตุการณ์ โดยมีข้อมูลพอให้ผู้รับปฏิบัติได้\n\n### สร้างพื้นฐาน\n\nเลือกชื่อเหตุการณ์ชัดเจน (เช่น order.created, order.paid) และเวอร์ชัน payload (เช่น v1, v2) การเวอร์ชันช่วยให้เพิ่มฟิลด์ทีหลังโดยไม่ทำให้ผู้บริโภคเก่าพัง\n\nสร้างตาราง outbox ใน PostgreSQL และเพิ่มดัชนีสำหรับคิวรีที่ worker จะเรียกบ่อย โดยเฉพาะ (status, available_at, id)\n\nอัปเดต flow การเขียนของคุณให้การเปลี่ยนแปลงธุรกิจและการแทรก outbox เกิดขึ้นในธุรกรรมเดียว นี่คือการรับประกันหลัก\n\n### เพิ่มการส่งและการควบคุม\n\nแผนการใช้งานง่าย ๆ:\n\n- กำหนดชนิดเหตุการณ์และเวอร์ชัน payload ที่คุณจะสนับสนุนระยะยาว\n- สร้างตาราง outbox และดัชนี\n- แทรกแถว outbox ควบคู่กับการเปลี่ยนแปลงข้อมูลหลัก\n- สร้าง worker ที่อ้างสิทธิ์แถว ส่งไปยัง API ภายนอก แล้วอัปเดตสถานะ\n- เพิ่มการตั้งเวลารีทรายแบบ backoff และสถานะ failed เมื่อพยายามครบครั้ง\n\nเพิ่มเมตริกพื้นฐานเพื่อให้คุณสังเกตปัญหาเร็ว: lag (อายุของเหตุการณ์ที่ยังไม่ได้ส่งเก่าสุด), อัตราการส่ง, และอัตราความล้มเหลว\n\n## ตัวอย่างง่าย: ส่งเหตุการณ์คำสั่งไปยังบริการภายนอก\n\nลูกค้าสร้างคำสั่งในแอปของคุณ มีสองสิ่งที่ต้องเกิดภายนอก: ผู้ให้บริการบิลต้องเรียกเก็บเงิน และผู้ให้บริการขนส่งต้องสร้างการจัดส่ง\n\nด้วยรูปแบบ outbox คุณจะไม่เรียก API เหล่านั้นภายในคำขอเช็คเอาต์ คุณจะบันทึกคำสั่งและแถว outbox ในธุรกรรม PostgreSQL เดียวกัน ดังนั้นคุณจะไม่เจอ “คำสั่งบันทึก แต่ไม่ได้แจ้ง” (หรือกลับกัน)\n\nแถว outbox ทั่วไปสำหรับเหตุการณ์คำสั่งอาจมี aggregate_id (order ID), event_type เช่น order.created, และ payload แบบ JSONB ที่มียอด สินค้า และรายละเอียดปลายทาง\n\nจากนั้น worker จะหยิบแถว pending และเรียกบริการภายนอก (ตามลำดับที่กำหนดหรือโดยการปล่อยเหตุการณ์แยกเช่น payment.requested และ shipment.requested) หากผู้ให้บริการหนึ่งล่ม worker จะบันทึกการพยายาม ตั้งเวลา retry โดยเลื่อน available_at และไปต่อ คำสั่งยังคงอยู่และเหตุการณ์จะถูกรีทรายทีหลังโดยไม่บล็อกการเช็คเอาต์ใหม่\n\nการจัดลำดับมักเป็น “ต่อคำสั่ง” หรือ “ต่อลูกค้า” บังคับให้เหตุการณ์ที่มี aggregate_id เดียวกันถูกประมวลผลทีละรายการเพื่อให้ order.paid ไม่มาถึงก่อน order.created\n\nการกำจัดการซ้ำช่วยป้องกันไม่ให้คุณเรียกเก็บเงินซ้ำหรือสร้างการจัดส่งสองครั้ง ส่งคีย์ idempotency เมื่อผู้ให้บริการรองรับ และเก็บบันทึกการส่งปลายทางเพื่อให้การรีทรายหลัง timeout ไม่ทำให้เกิดการกระทำซ้ำอีกครั้ง\n\n## การตรวจสอบด่วนก่อนเปิดใช้งาน\n\nก่อนจะวางใจให้การเชื่อมต่อกระทำการที่เกี่ยวข้องกับเงิน แจ้งลูกค้า หรือซิงค์ข้อมูล ทดสอบมุมขอบ: การล่มของโปรเซส รีทราย การส่งซ้ำ และ worker หลายตัว\n\nการตรวจสอบที่จับความล้มเหลวบ่อย:\n\n- ยืนยันว่าแถว outbox ถูกสร้างในธุรกรรมเดียวกับการเปลี่ยนแปลงธุรกิจ\n- ตรวจสอบว่า sender ปลอดภัยเมื่อรันหลายอินสแตนซ์ สอง worker ไม่ควรส่งเหตุการณ์เดียวกันพร้อมกัน\n- หากการจัดลำดับสำคัญ ให้นิยามกฎเป็นประโยคเดียวและบังคับด้วยคีย์ที่มั่นคง\n- สำหรับแต่ละปลายทาง ตัดสินใจว่าจะป้องกันการซ้ำอย่างไรและพิสูจน์ว่า “เราส่งแล้ว” ได้อย่างไร\n- กำหนดทางออก: หลัง N ครั้ง ย้ายเหตุการณ์เป็น failed เก็บสรุปข้อผิดพลาดล่าสุด และมีการกระทำ reprocess ที่เรียบง่าย\n\nเช็คความเป็นจริง: Stripe อาจรับคำขอแต่ worker ของคุณล้มก่อนบันทึกความสำเร็จ หากไม่มี idempotency การรีทรายอาจทำให้เกิดการกระทำซ้ำได้ ด้วย idempotency และบันทึกการส่งที่บันทึกไว้ การรีทรายจะปลอดภัย\n\n## ขั้นตอนต่อไป: นำไปใช้งานโดยไม่รบกวนแอปของคุณ\n\nการเปิดใช้งานคือจุดที่โครงการ outbox มักสำเร็จหรือหยุดนิ่ง เริ่มแบบเล็ก ๆ เพื่อดูพฤติกรรมจริงโดยไม่เสี่ยงทั้งเลเยอร์การเชื่อมต่อของคุณ\n\nเริ่มจากการเชื่อมต่อหนึ่งรายการและชนิดเหตุการณ์หนึ่งตัวอย่าง ส่งเฉพาะ order.created ไปยัง API ผู้ขายเดียวในขณะที่ระบบที่เหลือยังคงเป็นแบบเดิม วิธีนี้ให้ baseline ที่ชัดเจนสำหรับ throughput latency และอัตราความล้มเหลว\n\nทำให้ปัญหาปรากฏเร็ว เพิ่มแดชบอร์ดและการแจ้งเตือนสำหรับ outbox lag (เหตุการณ์รอเท่าไหร่ และเก่าสุดเท่าไร) และอัตราความล้มเหลว (ติดอยู่ในการรีทรายกี่รายการ) หากคุณสามารถตอบว่า “เราตามไม่ทันตอนนี้ไหม?” ใน 10 วินาที คุณจะจับปัญหาได้ก่อนผู้ใช้\n\nมีแผน reprocess ที่ปลอดภัยก่อนเหตุการณ์แรก ตัดสินใจว่า “reprocess” หมายถึงอะไร: รีส่งเพย์โหลดเดิม สร้างเพย์โหลดจากข้อมูลปัจจุบัน หรือส่งเพื่อตรวจด้วยมือ เอกสารว่าเคสไหนปลอดภัยที่จะส่งซ้ำและเคสไหนต้องคนตรวจ\n\nถ้าคุณสร้างสิ่งนี้ด้วยแพลตฟอร์มแบบ no-code เช่น AppMaster (appmaster.io) โครงสร้างเดียวกันยังใช้ได้: เขียนข้อมูลธุรกิจและแถว outbox พร้อมกันใน PostgreSQL แล้วรันกระบวนการแยกเพื่อส่ง รีทราย และทำเครื่องหมายเหตุการณ์เป็น sent หรือ failed.
คำถามที่พบบ่อย
ใช้รูปแบบ outbox เมื่อการกระทำของผู้ใช้แก้ไขฐานข้อมูลของคุณ และ ต้องกระตุ้นงานในระบบอื่น มันมีประโยชน์เมื่อการเชื่อมต่อช้า เครือข่ายไม่เสถียร หรือผู้ให้บริการภายนอกล้มเหลว ทำให้เกิดสถานะ “บันทึกในระบบเรา แต่ไม่ไปถึงพวกเขา”.
การเขียนแถวธุรกิจและแถว outbox ในธุรกรรมฐานข้อมูลเดียวให้การรับประกันชัดเจน: ทั้งคู่มีอยู่หรือไม่มีเลย นั่นป้องกันความล้มเหลวบางส่วนเช่น “การเรียก API สำเร็จแต่คำสั่งไม่ถูกบันทึก” หรือ “คำสั่งบันทึกแล้วแต่การเรียก API ไม่เกิดขึ้น”.
ค่าพื้นฐานที่ใช้งานได้คือ id, aggregate_id, event_type, payload, status, created_at, available_at, attempts รวมถึงฟิลด์ล็อกเช่น locked_at และ locked_by. โครงสร้างนี้ทำให้การส่ง การตั้งเวลารีทราย และการจัดการการพร้อมกันเป็นเรื่องง่ายโดยไม่ทำให้ตารางซับซ้อนเกินไป.
ดัชนีพื้นฐานที่ใช้กันบ่อยคือดัชนีบน (status, available_at, id) เพื่อให้ worker ดึงชุดเหตุการณ์ที่พร้อมส่งตามลำดับได้อย่างรวดเร็ว เพิ่มดัชนีอื่น ๆ เฉพาะเมื่อคุณมีการค้นหาตามฟิลด์เหล่านั้นจริง ๆ เพราะดัชนีเพิ่มจะชะลอการแทรกข้อมูล.
การ polling เป็นวิธีที่เรียบง่ายและคาดเดาได้สำหรับทีมส่วนใหญ่ เริ่มจากชุดเล็ก ๆ และช่วงเวลาสั้น ๆ แล้วปรับจูนตามโหลดและความหน่วง LISTEN/NOTIFY ลดความหน่วงได้บ้าง แต่มักเพิ่มความซับซ้อนและยังต้องมี fallback เมื่อการแจ้งเตือนหายหรือ worker รีสตาร์ท.
อ้างสิทธิ์แถวโดยใช้ล็อกระดับแถวเพื่อให้สอง worker ไม่ประมวลผลเหตุการณ์เดียวกันในเวลาเดียวกัน โดยทั่วไปใช้ SKIP LOCKED จากนั้นทำเครื่องหมายสถานะเป็น processing พร้อม locked_at และ locked_by ส่งแล้วตามด้วยการตั้งเป็น sent หรือคืนไปเป็น pending พร้อม available_at ในอนาคตเมื่อล้มเหลว.
ใช้ exponential backoff พร้อมขีดจำกัดสูงสุดของจำนวนครั้งในการพยายาม และรีทรายเฉพาะความผิดพลาดที่คาดว่าจะชั่วคราว เช่น timeouts เครือข่าย และ HTTP 429/5xx ส่วนความผิดพลาดที่มาจากข้อมูลหรือการกำหนดค่าผิด (เช่น 400, 401/403, 404) ควรถือเป็นข้อผิดพลาดสุดท้ายจนกว่าจะถูกแก้ไข.
รูปแบบ outbox ไม่ได้รับประกันการส่งแบบ exactly-once ด้วยตัวมันเอง สมมติว่าจะมีการส่งซ้ำได้เสมอ ให้ใช้คีย์ idempotency ที่คงที่ต่อปลายทางและต่อเหตุการณ์ และเก็บบันทึกการส่ง (เช่น ตาราง delivery) พร้อมข้อบังคับเอกลักษณ์ (destination, event_id) เพื่อที่แม้ worker จะแข่งกัน ก็จะมีเพียงหนึ่งรายการที่ชนะในการบันทึกการส่ง.
รักษาลำดับภายในกลุ่ม ไม่ใช่ทั่วระบบทั้งหมด ใช้คีย์จัดกลุ่มเช่น aggregate_id หรือ customer_id ประมวลผลทีละเหตุการณ์ต่อกลุ่ม และอนุญาตให้ทำงานขนานกันข้ามกลุ่มต่าง ๆ วิธีนี้ทำให้ลูกค้าช้าที่สุดไม่บล็อกทุกคน.
หลังจากจำนวนครั้งที่กำหนด ให้ทำเครื่องหมายเหตุการณ์เป็น failed เก็บสรุปข้อผิดพลาดสั้น ๆ และหยุดประมวลผลเหตุการณ์ถัดไปของกลุ่มนั้นจนกว่าจะมีคนแก้ไขสาเหตุราก นี่ช่วยจำกัดผลกระทบและป้องกันการวนรีทรายไม่รู้จบ ในขณะเดียวกันกลุ่มอื่น ๆ ยังคงเคลื่อนไหวได้.


