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

Outbox + ความ​เชื่อถือ​ได้​ของ event

ในบท 4 เรา​ต่อ context เข้า​ด้วย​กัน​ด้วย integration event ที่​ยิง​ผ่าน bus ที่​เขียน​เอง (IIntegrationEventBus) — Ordering ยืนยัน​ออเดอร์​เสร็จ​ก็​ประกาศ OrderConfirmedIntegrationEvent ออก​ไป​ให้ Kitchen ไป​เปิด​ตั๋ว​ทำ​อาหาร มัน​เวิร์ก​ตอน​ทุก​อย่าง​ราบรื่น แต่​บท​นี้​เรา​จะ​จี้​จุด​ที่​มัน​พัง​เงียบๆ: ถ้า​โพรเซสล่ม​ตรง​กลาง​ระหว่าง “บันทึก state” กับ “ส่ง event” ล่ะ? คำ​ตอบ​ของ​คำถาม​นี้​คือ​เส้น​แบ่ง​ระหว่าง​ระบบ​ที่​เชื่อถือ​ได้​กับ​ระบบ​ที่​ข้อมูล​ค่อยๆ เพี้ยน​โดย​ไม่มี​ใคร​รู้

📦 code ตัวอย่าง

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เปลี่ยน bus ที่​เขียน​เอง​ใน​บท 4 มา​ใช้ transactional outbox ของ WolverineFx บน EF Core และ​ทำ handler ฝั่ง Kitchen ให้​เป็น idempotent consumer สแนป​เพ็ต​ใน​บท​นี้​คือ​แกน​ของ code จริง​ใน module Ordering และ Kitchen

ลอง​ดู handler ที่​ยืนยัน​ออเดอร์​แบบ​ตรง​ไป​ตรง​มา: โหลด Order, เรียก Confirm(), บันทึก​ลง DB, แล้ว​ยิง integration event ให้ Kitchen รู้ ดู​เผินๆ ไม่มี​อะไร​ผิด

❌ version ดิบ: สอง write ที่​ไม่​ได้​อยู่​ใน transaction เดียวกัน
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 กับ​การ​ปล่อย​ข้อความ

ทาง​แก้​คลาสสิก​คือ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 มัน​เข้า​มา​แล้ว​เปลี่ยน​สาม​บรรทัด:

✅ Wolverine durable outbox: state + event commit พร้อม​กัน
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 อัตโนมัติ:

Program.cs — ผูก outbox/inbox เข้า​กับ DbContext เดียว​กับ​ข้อมูล​จริง
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 เสมอ ไม่ใช่​ก่อน

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) ว่า​ทำ​ไป​แล้ว​หรือ​ยัง:

Kitchen: idempotent consumer — ข้อความ​ซ้ำ​ไม่​ทำให้​เกิด​ตั๋ว​ซ้ำ
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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Domain Events — pattern การ​ประกาศ​สิ่ง​ที่​เกิด​ขึ้น​แล้ว ราก​ของ​ทั้ง domain event และ integration event ที่ outbox คอย​ส่ง​ให้​เชื่อถือ​ได้
  • Murphy’s Law — “อะไร​ที่​พัง​ได้ มัน​จะ​พัง” หลัก​คิด​เบื้องหลัง​ทำไม​ต้อง​ออกแบบ​เผื่อ​โพรเซสล่มกลาง​ระหว่าง2 write

เช็กความเข้าใจ — บทที่ 6

ข้อ 1 / 3

Dual-write problem ในบริบทของบทนี้คืออะไร?