20 ธ.ค. 2568·อ่าน 2 นาที

รูปแบบ Outbox ใน PostgreSQL สำหรับการเชื่อมต่อ API ที่เชื่อถือได้

เรียนรู้รูปแบบ 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 แทนการเรียก API โดยตรง?

ใช้รูปแบบ outbox เมื่อการกระทำของผู้ใช้แก้ไขฐานข้อมูลของคุณ และ ต้องกระตุ้นงานในระบบอื่น มันมีประโยชน์เมื่อการเชื่อมต่อช้า เครือข่ายไม่เสถียร หรือผู้ให้บริการภายนอกล้มเหลว ทำให้เกิดสถานะ “บันทึกในระบบเรา แต่ไม่ไปถึงพวกเขา”.

ทำไมการแทรก outbox ต้องอยู่ในธุรกรรมเดียวกับการเขียนธุรกิจ?

การเขียนแถวธุรกิจและแถว outbox ในธุรกรรมฐานข้อมูลเดียวให้การรับประกันชัดเจน: ทั้งคู่มีอยู่หรือไม่มีเลย นั่นป้องกันความล้มเหลวบางส่วนเช่น “การเรียก API สำเร็จแต่คำสั่งไม่ถูกบันทึก” หรือ “คำสั่งบันทึกแล้วแต่การเรียก API ไม่เกิดขึ้น”.

ตาราง outbox ควรมีฟิลด์อะไรบ้างเพื่อให้ใช้งานได้จริง?

ค่าพื้นฐานที่ใช้งานได้คือ id, aggregate_id, event_type, payload, status, created_at, available_at, attempts รวมถึงฟิลด์ล็อกเช่น locked_at และ locked_by. โครงสร้างนี้ทำให้การส่ง การตั้งเวลารีทราย และการจัดการการพร้อมกันเป็นเรื่องง่ายโดยไม่ทำให้ตารางซับซ้อนเกินไป.

ดัชนีไหนสำคัญที่สุดสำหรับตาราง outbox ใน PostgreSQL?

ดัชนีพื้นฐานที่ใช้กันบ่อยคือดัชนีบน (status, available_at, id) เพื่อให้ worker ดึงชุดเหตุการณ์ที่พร้อมส่งตามลำดับได้อย่างรวดเร็ว เพิ่มดัชนีอื่น ๆ เฉพาะเมื่อคุณมีการค้นหาตามฟิลด์เหล่านั้นจริง ๆ เพราะดัชนีเพิ่มจะชะลอการแทรกข้อมูล.

worker ควร poll ตาราง outbox หรือใช้ LISTEN/NOTIFY?

การ polling เป็นวิธีที่เรียบง่ายและคาดเดาได้สำหรับทีมส่วนใหญ่ เริ่มจากชุดเล็ก ๆ และช่วงเวลาสั้น ๆ แล้วปรับจูนตามโหลดและความหน่วง LISTEN/NOTIFY ลดความหน่วงได้บ้าง แต่มักเพิ่มความซับซ้อนและยังต้องมี fallback เมื่อการแจ้งเตือนหายหรือ worker รีสตาร์ท.

จะป้องกันไม่ให้สอง worker ส่งเหตุการณ์ outbox เดียวกันได้อย่างไร?

อ้างสิทธิ์แถวโดยใช้ล็อกระดับแถวเพื่อให้สอง worker ไม่ประมวลผลเหตุการณ์เดียวกันในเวลาเดียวกัน โดยทั่วไปใช้ SKIP LOCKED จากนั้นทำเครื่องหมายสถานะเป็น processing พร้อม locked_at และ locked_by ส่งแล้วตามด้วยการตั้งเป็น sent หรือคืนไปเป็น pending พร้อม available_at ในอนาคตเมื่อล้มเหลว.

กลยุทธ์การรีทรายที่ปลอดภัยที่สุดสำหรับการส่งจาก outbox คืออะไร?

ใช้ exponential backoff พร้อมขีดจำกัดสูงสุดของจำนวนครั้งในการพยายาม และรีทรายเฉพาะความผิดพลาดที่คาดว่าจะชั่วคราว เช่น timeouts เครือข่าย และ HTTP 429/5xx ส่วนความผิดพลาดที่มาจากข้อมูลหรือการกำหนดค่าผิด (เช่น 400, 401/403, 404) ควรถือเป็นข้อผิดพลาดสุดท้ายจนกว่าจะถูกแก้ไข.

รูปแบบ outbox รับประกันการส่งแบบ exactly-once หรือไม่?

รูปแบบ outbox ไม่ได้รับประกันการส่งแบบ exactly-once ด้วยตัวมันเอง สมมติว่าจะมีการส่งซ้ำได้เสมอ ให้ใช้คีย์ idempotency ที่คงที่ต่อปลายทางและต่อเหตุการณ์ และเก็บบันทึกการส่ง (เช่น ตาราง delivery) พร้อมข้อบังคับเอกลักษณ์ (destination, event_id) เพื่อที่แม้ worker จะแข่งกัน ก็จะมีเพียงหนึ่งรายการที่ชนะในการบันทึกการส่ง.

จะจัดการลำดับเหตุการณ์โดยไม่ทำให้ระบบช้าลงได้อย่างไร?

รักษาลำดับภายในกลุ่ม ไม่ใช่ทั่วระบบทั้งหมด ใช้คีย์จัดกลุ่มเช่น aggregate_id หรือ customer_id ประมวลผลทีละเหตุการณ์ต่อกลุ่ม และอนุญาตให้ทำงานขนานกันข้ามกลุ่มต่าง ๆ วิธีนี้ทำให้ลูกค้าช้าที่สุดไม่บล็อกทุกคน.

ควรทำอย่างไรกับเหตุการณ์ “พิษ” ที่ล้มเหลวซ้ำ ๆ?

หลังจากจำนวนครั้งที่กำหนด ให้ทำเครื่องหมายเหตุการณ์เป็น failed เก็บสรุปข้อผิดพลาดสั้น ๆ และหยุดประมวลผลเหตุการณ์ถัดไปของกลุ่มนั้นจนกว่าจะมีคนแก้ไขสาเหตุราก นี่ช่วยจำกัดผลกระทบและป้องกันการวนรีทรายไม่รู้จบ ในขณะเดียวกันกลุ่มอื่น ๆ ยังคงเคลื่อนไหวได้.

ง่ายต่อการเริ่มต้น
สร้างบางสิ่งที่ น่าทึ่ง

ทดลองกับ AppMaster ด้วยแผนฟรี
เมื่อคุณพร้อม คุณสามารถเลือกการสมัครที่เหมาะสมได้

เริ่ม