Outbox + ความเชื่อถือได้ของ event
ในบท 4 เราต่อ context เข้าด้วยกันด้วย integration event ที่ยิงผ่าน bus ที่เขียนเอง (IIntegrationEventBus) — Ordering ยืนยันออเดอร์เสร็จก็ประกาศ OrderConfirmedIntegrationEvent ออกไปให้ Kitchen ไปเปิดตั๋วทำอาหาร มันเวิร์กตอนทุกอย่างราบรื่น แต่บทนี้เราจะจี้จุดที่มันพังเงียบๆ: ถ้าโพรเซสล่มตรงกลางระหว่าง “บันทึก state” กับ “ส่ง event” ล่ะ? คำตอบของคำถามนี้คือเส้นแบ่งระหว่างระบบที่เชื่อถือได้กับระบบที่ข้อมูลค่อยๆ เพี้ยนโดยไม่มีใครรู้
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เปลี่ยน bus ที่เขียนเองในบท 4 มาใช้ transactional outbox ของ WolverineFx บน EF Core และทำ handler ฝั่ง Kitchen ให้เป็น idempotent consumer สแนปเพ็ตในบทนี้คือแกนของ code จริงใน module Ordering และ Kitchen
bus ที่เขียนเองมีช่องโหว่ตรงไหน
หัวข้อที่มีชื่อว่า “bus ที่เขียนเองมีช่องโหว่ตรงไหน”ลองดู handler ที่ยืนยันออเดอร์แบบตรงไปตรงมา: โหลด Order, เรียก Confirm(), บันทึกลง DB, แล้วยิง integration event ให้ Kitchen รู้ ดูเผินๆ ไม่มีอะไรผิด
public class ConfirmOrderHandler{ private readonly OrderingDbContext _db; private readonly IIntegrationEventBus _bus; // bus ที่เขียนเองจากบท 4
public async Task Handle(ConfirmOrder cmd, CancellationToken ct) { var order = await _db.Orders.FindAsync(cmd.OrderId); order.Confirm();
await _db.SaveChangesAsync(ct); // (1) state ถูก commit ลง DB แล้ว // 💥 ถ้าโพรเซสล่มตรงนี้: DB มี order = Confirmed แต่ Kitchen ไม่เคยได้ event await _bus.PublishAsync( // (2) เพิ่งจะส่ง event ข้ามขอบเขต new OrderConfirmedIntegrationEvent(order.Id.Value, DateTimeOffset.UtcNow), ct); }}นี่คือDual-write problemDual-write Problemปัญหาที่เกิดเมื่อ code ต้องเขียนลงสองที่ให้สำเร็จพร้อมกัน (บันทึกลง DB + ส่ง event) แต่ทั้งสองไม่ได้อยู่ใน transaction เดียวกัน — ถ้าล่มระหว่างกลาง อาจบันทึกสำเร็จแต่ event หาย หรือ event ออกไปแต่ DB ไม่บันทึกArchitecture — code ต้องเขียนให้สำเร็จสองที่ที่ไม่ได้อยู่ใน transaction เดียวกัน (DB หนึ่งครั้ง + bus อีกหนึ่งครั้ง) แต่ไม่มีอะไรมัดสองการกระทำนี้เข้าด้วยกัน ผลคือมีหน้าต่างเวลาที่ระบบพังได้สองแบบ:
- commit แล้ว แต่ยิงไม่ทัน — บรรทัด (1) ผ่าน แต่โพรเซสล่ม/รีสตาร์ต/เน็ตหลุดก่อนบรรทัด (2) จบ DB บอกว่าออเดอร์ยืนยันแล้ว แต่ Kitchen ไม่มีวันรู้ ตั๋วไม่ถูกเปิด อาหารไม่ถูกทำ ลูกค้าจ่ายเงินแล้วนั่งรอเก้อ
- ยิงแล้ว แต่ commit ไม่ผ่าน — ถ้าสลับลำดับเอา
PublishAsyncขึ้นก่อนSaveChangesแล้ว save ดันล้ม (deadlock, constraint, disk เต็ม) Kitchen จะเปิดตั๋วให้ออเดอร์ที่สุดท้ายไม่มีอยู่จริงใน Ordering
ไม่ว่าจะเรียงทางไหน state กับข้อความก็หลุดจากกันได้เสมอ ตราบใดที่มันเป็น2 write แยกกัน สลับลำดับไม่ได้ช่วย มันแค่ย้ายว่าจะพังทางไหน ปัญหาไม่ได้อยู่ที่ลำดับ แต่อยู่ที่ การขาด atomicity ระหว่างการเปลี่ยน state กับการปล่อยข้อความ
Outbox: เอาข้อความไปนั่งใน transaction เดียวกับ state
หัวข้อที่มีชื่อว่า “Outbox: เอาข้อความไปนั่งใน transaction เดียวกับ state”ทางแก้คลาสสิกคือTransactional OutboxTransactional Outboxpattern แก้ dual-write problem: เขียน event ลงตาราง outbox ใน transaction เดียวกับข้อมูลจริง แล้วให้ relay แยกต่างหากส่ง event ออกไปหลัง commit เท่านั้น — WolverineFx ทำสิ่งนี้ให้บน EF Core ผ่าน IDbContextOutbox<T>Architecture แทนที่จะยิง event ออก broker ทันที เราเขียน event นั้น ลงตารางในฐานข้อมูลเดียวกับข้อมูลจริง ภายใน transaction เดียวกัน แล้วมีตัวส่ง (relay) แยกต่างหากคอยอ่านตารางนั้นแล้วปล่อยข้อความออกไป หลัง commit สำเร็จเท่านั้น
กุญแจอยู่ตรงนี้: การเปลี่ยน state (Order = Confirmed) กับการบันทึกความตั้งใจจะส่ง event (แถวใน outbox) กลายเป็น write ก้อนเดียวที่ commit พร้อมกันหรือ rollback พร้อมกัน ถ้าล่มก่อน commit ก็ไม่เหลือทั้งคู่ — ไม่มีสภาพครึ่งๆ กลางๆ ให้ต้องตามแก้ ถ้า commit ผ่านแล้ว event ก็ถูกการันตีว่าอยู่ในตารางรอส่งแน่นอน จะส่งช้าไปหน่อย (relay ยังไม่ตื่น) ก็ยังดีกว่าหายไปเลย
WolverineFx ทำ pattern นี้ให้บน EF Core ผ่าน IDbContextOutbox<T> เราไม่ต้องปั้นตาราง outbox หรือเขียน relay เอง แค่ inject มันเข้ามาแล้วเปลี่ยนสามบรรทัด:
public class ConfirmOrderHandler{ public async Task Handle( ConfirmOrder cmd, IDbContextOutbox<OrderingDbContext> outbox) { // 1) แก้ aggregate ผ่าน DbContext ที่ outbox ถืออยู่ var order = await outbox.DbContext.Orders.FindAsync(cmd.OrderId); order.Confirm();
// 2) ต่อคิว integration event ลง outbox — ยังไม่ยิงออกจริง await outbox.PublishAsync(new OrderConfirmedIntegrationEvent( order.Id.Value, DateTimeOffset.UtcNow));
// 3) บันทึก entity + แถว outbox แบบ atomic แล้ว flush ข้อความหลัง commit เท่านั้น await outbox.SaveChangesAndFlushMessagesAsync(); }}SaveChangesAndFlushMessagesAsync() คือหัวใจ — มันเซฟการเปลี่ยน entity กับข้อความขาออกลงในธุรกรรมเดียวกัน แล้วค่อยผลัก (flush) ข้อความไปหาปลายทาง หลังจาก DB transaction commit สำเร็จ ถ้า commit ล้ม ข้อความก็ไม่ถูกส่ง เพราะมันยังไม่เคยหลุดออกจากธุรกรรม
การเปิดใช้ทำครั้งเดียวตอน bootstrap โดยผูก DbContext เข้ากับ Wolverine แล้วสั่งให้ห่อ handler ด้วย transaction อัตโนมัติ:
builder.UseWolverine(opts =>{ var cs = builder.Configuration.GetConnectionString("ordering")!;
// ทำให้ตาราง outbox/inbox ของ Wolverine ไปอยู่ใน DbContext เดียวกับ Order opts.Services.AddDbContextWithWolverineIntegration<OrderingDbContext>( x => x.UseNpgsql(cs));
// เก็บ envelope (ข้อความขาเข้า/ขาออก) ลง Postgres ตัวเดียวกับ state opts.PersistMessagesWithPostgresql(cs, "wolverine");
// ห่อทุก handler ด้วย transaction อัตโนมัติ: state + outbox เขียนพร้อมกันเสมอ opts.Policies.AutoApplyTransactions();});พอตาราง outbox อยู่ในฐานข้อมูลเดียวกับ Order การเขียนทั้งสองจึงอยู่ใต้ transaction เดียวกันได้จริง ไม่ใช่แค่ในทางทฤษฎี
flowchart TB
subgraph TX["transaction เดียว — commit/rollback พร้อมกัน"]
direction TB
S["เขียน state ของ Order<br/>สถานะ = Confirmed"]
O["INSERT แถวลงตาราง outbox<br/>OrderConfirmedIntegrationEvent"]
S --> O
end
TX ==>|"COMMIT สำเร็จ"| RELAY["relay ของ Wolverine<br/>อ่านแถว outbox หลัง commit แล้วยิงออก"]
RELAY ==>|"PublishAsync"| KIT["Kitchen: สร้าง Ticket<br/>idempotent consumer กันข้อความซ้ำ"]
CRASH["โพรเซสล่มก่อน COMMIT"] -.->|"rollback ทั้งก้อน"| NONE["ไม่เหลือทั้ง state และแถว outbox<br/>ไม่มีสภาพครึ่ง ๆ กลาง ๆ"]
classDef tx fill:#c7d2fe,stroke:#3730a3,color:#1a1a1f;
classDef ok fill:#bbf7d0,stroke:#166534,color:#1a1a1f;
classDef bad fill:#fca5a5,stroke:#7f1d1d,color:#1a1a1f;
class S,O tx;
class RELAY,KIT ok;
class CRASH bad;
class NONE tx;
คำบรรยายภาพ: กล่องน้ำเงินคือ transaction เดียว — การเขียน state ของ Order กับการ INSERT แถว event ลงตาราง outbox commit พร้อมกันหรือ rollback พร้อมกัน ถ้าโพรเซสล่มก่อน COMMIT (ลูกศรประแดง) ก็ไม่เหลือทั้งสองอย่าง ระบบจึงไม่เคยตกอยู่ในสภาพ “state เปลี่ยนแล้วแต่ event หาย” หลัง commit สำเร็จ relay ของ Wolverine (เขียว) จึงค่อยอ่านแถว outbox แล้วปล่อยข้อความไปให้ Kitchen — การส่งเกิด หลัง commit เสมอ ไม่ใช่ก่อน
at-least-once: ทำไม “พอดีหนึ่งครั้ง” ถึงไม่มีจริง
หัวข้อที่มีชื่อว่า “at-least-once: ทำไม “พอดีหนึ่งครั้ง” ถึงไม่มีจริง”outbox แก้ฝั่งผู้ส่งให้ event ไม่หาย แต่มันเปิดปัญหาใหม่ฝั่งผู้รับ: relay อาจส่งข้อความเดิมซ้ำ ลองนึกภาพ relay ส่ง OrderConfirmedIntegrationEvent ให้ Kitchen สำเร็จ แต่โพรเซสล่มก่อนจะได้ทำเครื่องหมายในตาราง outbox ว่า “ส่งแล้ว” พอรีสตาร์ต relay เห็นแถวนั้นยังไม่ถูกทำเครื่องหมาย ก็ส่งซ้ำอีกรอบ Kitchen เลยได้ข้อความเดียวกันสองครั้ง
นี่ไม่ใช่ bug แต่เป็นธรรมชาติของการส่งข้อความข้ามเครือข่ายที่เรียกว่าAt-least-once deliveryAt-least-once Deliveryการรับประกันของ message broker ว่าข้อความจะถูกส่งถึงผู้รับ 'อย่างน้อยหนึ่งครั้ง' แต่อาจซ้ำได้ในกรณีล่ม/retry ผู้รับจึงต้องเป็น idempotent consumer เพื่อรองรับความซ้ำนี้Architecture — broker การันตีแค่ว่าข้อความจะ “ถึงอย่างน้อยหนึ่งครั้ง” ไม่ใช่ “พอดีหนึ่งครั้ง” เหตุผลที่ exactly-once ทำไม่ได้จริงในทางปฏิบัติคือ ผู้ส่งไม่มีทางแยกออกว่า ack ที่หายไปนั้นเกิดจากผู้รับยังไม่ได้รับ หรือรับแล้วแต่ ack หายระหว่างทางกลับ เมื่อแยกไม่ออก ผู้ส่งมีทางเลือกแค่สองทาง: retry (เสี่ยงส่งซ้ำ = at-least-once) หรือไม่ retry (เสี่ยงข้อความหาย = at-most-once) จะเอาทั้งไม่ซ้ำและไม่หายพร้อมกันไม่ได้
ในระบบที่ข้อมูลสำคัญ การ “หาย” แย่กว่าการ “ซ้ำ” เสมอ เราจึงเลือก at-least-once แล้วไปจัดการความซ้ำที่ฝั่งผู้รับ นั่นคือทำให้ผู้รับเป็นIdempotent ConsumerIdempotent Consumerผู้รับ event ที่ออกแบบให้ประมวลผลข้อความเดิมซ้ำได้โดยผลลัพธ์เหมือนเดิม (ไม่ใช่ผลซ้ำซ้อน) จำเป็นเพราะ at-least-once delivery การันตีแค่ 'ส่งถึงอย่างน้อยหนึ่งครั้ง' ไม่ใช่ 'พอดีหนึ่งครั้ง'Architecture — ประมวลผลข้อความเดิมกี่ครั้งก็ให้ผลลัพธ์เท่ากับประมวลผลครั้งเดียว ไม่เกิดตั๋วซ้ำ ไม่เก็บเงินซ้ำ
Wolverine ช่วยชั้นแรกให้ฟรี: มันบันทึก id ของข้อความขาเข้าไว้ในตาราง inbox (transactional inbox) ข้อความที่ id ซ้ำจะถูกกรองทิ้งก่อนถึง handler ด้วยซ้ำ แต่การพึ่ง framework อย่างเดียวยังไม่พอในเชิง domain — ควรมี guard ระดับข้อมูลของเราเองด้วย โดยเช็คจากกุญแจธรรมชาติของงาน (ที่นี่คือ OrderId) ว่าทำไปแล้วหรือยัง:
public class WhenOrderConfirmed{ public async Task Handle( OrderConfirmedIntegrationEvent evt, KitchenDbContext db) { // guard ระดับ domain: order นี้มีตั๋วแล้วหรือยัง var alreadyHasTicket = await db.Tickets .AnyAsync(t => t.OrderId == evt.OrderId); if (alreadyHasTicket) return; // เคยเปิดตั๋วแล้ว — ข้อความซ้ำจึงไม่เกิดผลซ้ำ
db.Tickets.Add(Ticket.Open(evt.OrderId)); await db.SaveChangesAsync(); }}สังเกตว่า guard นี้อ้างกับ สถานะจริงของ domain (มีตั๋วของ OrderId นี้ไหม) ไม่ใช่แค่เชื่อว่า framework จะกันซ้ำให้ สองชั้นนี้เสริมกัน: inbox ของ Wolverine กันซ้ำจาก id ที่ตรงกันเป๊ะ ส่วน guard ของเรากันซ้ำในเชิงความหมาย เผื่อกรณีข้อความคนละ id แต่สื่อเหตุการณ์เดียวกัน หรือเผื่อวันที่เราเปลี่ยน transport
OrderConfirmedIntegrationEvent ยังเป็นIntegration EventIntegration Eventevent ที่สื่อสาร 'ข้าม' Bounded Context เป็น contract แบบ serializable ที่มีแต่ id/ค่าพื้นฐาน (ไม่มี domain object) เช่น OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) ต่างจาก Domain Event ที่อยู่ในโพรเซสเดียวArchitecture ที่แบนราบ — พก OrderId กับ ConfirmedAt ที่เป็น Guid/DateTimeOffset ล้วน ไม่ได้พา Order aggregate ข้ามขอบเขตมาด้วย Kitchen จึงสร้าง Ticket ของตัวเองจาก id ที่ได้รับ ไม่แตะ model ของ Ordering เลย ที่มันต่างจากDomain EventDomain Eventสิ่งที่เกิดขึ้นแล้วภายใน context เดียว ทำงาน in-process และอ้างอิง domain object ได้ตรง ๆ เช่น OrderConfirmed ภายใน Ordering — เมื่อจะส่งข้ามขอบเขต context ต้องแปลงเป็น Integration Event ก่อนเสมอ ห้ามส่ง domain event ข้ามไปตรง ๆTactical Design อย่าง OrderConfirmed ในบท 4 ก็ตรงนี้: domain event อยู่ในโพรเซสเดียวและอ้าง object ได้ตรงๆ ส่วน integration event ต้อง serialize ข้ามขอบเขตได้และพกแต่ค่าพื้นฐาน — outbox คือกลไกที่ทำให้ integration event ตัวนั้นเดินทางถึงปลายทางอย่างเชื่อถือได้
สรุปเส้นทางความเชื่อถือได้
หัวข้อที่มีชื่อว่า “สรุปเส้นทางความเชื่อถือได้”รวมสามชิ้นเข้าด้วยกันจะได้สายส่งที่ทนล่ม: outbox มัด state กับ event ให้ atomic ปิด dual-write problem ฝั่งผู้ส่ง; at-least-once ยอมรับความจริงว่าข้ามเครือข่ายแล้วข้อความซ้ำได้เพื่อไม่ให้หาย; idempotent consumer รับความซ้ำนั้นได้โดยไม่เกิดผลข้างเคียงซ้ำฝั่งผู้รับ ทั้งสามไม่ได้ทำให้ระบบ “ไม่มีวันพลาด” แต่ทำให้ทุกความพลาดที่เกิดขึ้นได้ นำไปสู่สถานะที่สอดคล้องเสมอ ไม่ใช่ state ที่เพี้ยนเงียบๆ
บทนี้ยังอยู่ในโลก modular monolith — outbox กับ relay รันในโพรเซสเดียวกัน ใช้ฐานข้อมูลเดียวกัน บทถัดๆ ไปเราจะชั่งใจว่าเมื่อไรควรแตก context ออกเป็น service จริง แล้วข้ามเครือข่ายด้วย RabbitMQ ซึ่งเป็นจุดที่ at-least-once เลิกเป็นทฤษฎีและกลายเป็นสิ่งที่เกิดขึ้นทุกวัน — โครง outbox + idempotency ที่วางในบทนี้คือสิ่งที่ทำให้การข้ามเส้นนั้นไม่พังทันทีที่เน็ตกระตุก
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Domain Events — pattern การประกาศสิ่งที่เกิดขึ้นแล้ว รากของทั้ง domain event และ integration event ที่ outbox คอยส่งให้เชื่อถือได้
- Murphy’s Law — “อะไรที่พังได้ มันจะพัง” หลักคิดเบื้องหลังทำไมต้องออกแบบเผื่อโพรเซสล่มกลางระหว่าง2 write
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3Dual-write problem ในบริบทของบทนี้คืออะไร?