01เริ่มจาก Requirement และ Invariant

ก่อนวาด architecture ให้ถามว่าคำว่า “ตั๋วไม่ซ้ำ” หมายถึงอะไร สำหรับระบบเลือกที่นั่ง invariant หลักคือ หนึ่ง `(event_id, seat_id)` มี active owner ได้ไม่เกินหนึ่งคน และ invariant นี้ต้องถูกบังคับที่ authoritative data store ไม่ใช่แค่ตรวจจาก cache หรือ UI

FUNCTIONAL
  • ค้นหารอบและดูที่นั่งว่าง
  • Hold ที่นั่งชั่วคราว
  • ชำระเงินและยืนยันตั๋ว
  • หมดอายุหรือยกเลิก hold
NON-FUNCTIONAL
  • ห้ามขายที่นั่งซ้ำ
  • รองรับ traffic spike
  • ตอบสนองเร็วและตรวจสอบย้อนหลังได้
  • ทนต่อ retry และ partial failure

02State Machine และ Data Model

AVAILABLEHELDPAYMENT_PENDINGCONFIRMED

การจองไม่ควรเปลี่ยนจากว่างเป็นขายแล้วทันที ให้มี hold ที่มีวันหมดอายุ เพื่อให้ผู้ใช้มีเวลาชำระเงิน State transition ทุกครั้งต้องตรวจ current state และเจ้าของ hold แบบ atomic

seat_inventoryevent_id · seat_id · status · hold_id · hold_expires_at · version
reservationsreservation_id · user_id · event_id · seat_id · status · idempotency_key
paymentspayment_id · reservation_id · provider_ref · status · amount
outbox_eventsevent_id · aggregate_id · event_type · payload · published_at

เก็บ current ownership ไว้ใน `seat_inventory` ส่วนประวัติการพยายามจองและ state transition อยู่ใน reservations/audit log จะทำให้ constraint หลักเรียบและยังตรวจสอบย้อนหลังได้

03มีวิธีป้องกันการจองซ้ำแบบใดบ้าง?

วิธีที่ 1

Database Unique Constraint

สร้าง unique constraint หรือ partial unique index บน event และ seat ให้ database เป็นผู้ตัดสินสุดท้าย หาก request สองตัว insert พร้อมกัน จะสำเร็จเพียงตัวเดียว อีกตัวได้ unique violation

เหมาะกับ:

ระบบส่วนใหญ่ที่ใช้ relational database และต้องการ correctness แบบเรียบง่าย

วิธีที่ 2

Atomic Conditional Update

Update ที่นั่งเมื่อสถานะยังว่างหรือ hold หมดอายุใน statement เดียว จำนวน row ที่ update เป็นผลการแย่งสิทธิ์ มีเพียง transaction เดียวที่ชนะ

เหมาะกับ:

ตาราง inventory มีหนึ่ง row ต่อที่นั่ง และต้องการ critical section สั้นที่สุด

วิธีที่ 3

Pessimistic Lock

ใช้ `SELECT ... FOR UPDATE` lock row ก่อนตรวจเงื่อนไขและเปลี่ยน state Transaction อื่นจะรอ เหมาะเมื่อการตัดสินใจมีหลายขั้นแต่ต้องระวัง lock wait และ deadlock

เหมาะกับ:

Business rule ซับซ้อนและ contention ไม่สูงมาก

วิธีที่ 4

Optimistic Locking

อ่านค่า `version` แล้ว update ด้วยเงื่อนไข `WHERE version = old_version` หากไม่มี row เปลี่ยนแปลว่ามีคนแก้ก่อน ให้ retry หรือแจ้งว่าที่นั่งถูกจองแล้ว

เหมาะกับ:

Conflict เกิดไม่บ่อยและไม่อยากถือ lock ระหว่างอ่าน

วิธีที่ 5

Redis Hold / Distributed Lock

ใช้ atomic `SET key token NX PX ttl` หรือ Lua script เพื่อทำ hold เร็วและลดโหลด DB แต่ final sale ยังต้องมี database constraint เพราะ lock อาจหมดอายุ, failover หรือเกิด network partition

เหมาะกับ:

Traffic สูงมากและต้องตอบ availability เร็ว โดยยอมรับความซับซ้อนเพิ่ม

วิธีที่ 6

Queue + Single Writer

ส่งคำขอผ่าน queue แล้ว partition ด้วย event/seat ให้ผู้ประมวลผลลำดับเดียวเป็นคนตัดสิน ลด write contention แต่ response เป็น asynchronous และต้องจัดการ lag

เหมาะกับ:

Flash sale ขนาดใหญ่มากที่ยอมให้ผู้ใช้รอผลหรือเข้าคิวเสมือน

ตัวเลือกที่แนะนำเป็นแกนหลัก

เริ่มด้วย atomic conditional update หรือ unique constraint ใน PostgreSQL เพราะ correctness อยู่ใกล้ข้อมูลที่สุด และเพิ่ม Redis/queue เมื่อมีผลวัดว่า DB เป็น bottleneck ไม่ควรเริ่มจาก distributed lock หาก transaction เดียวแก้ปัญหาได้

atomic-seat-hold.sql
UPDATE seat_inventory
SET status = 'HELD',
    hold_id = $3,
    hold_expires_at = now() + interval '5 minutes',
    version = version + 1
WHERE event_id = $1
  AND seat_id = $2
  AND (
    status = 'AVAILABLE'
    OR (status = 'HELD' AND hold_expires_at < now())
  )
RETURNING hold_id, hold_expires_at;

-- ได้ 1 row = จองชั่วคราวสำเร็จ
-- ได้ 0 row = มีคนถือหรือซื้อที่นั่งไปแล้ว
unique-active-reservation.sql
CREATE UNIQUE INDEX one_active_reservation_per_seat
ON reservations (event_id, seat_id)
WHERE status IN ('HELD', 'PAYMENT_PENDING', 'CONFIRMED');

-- เก็บประวัติรายการที่หมดอายุหรือยกเลิกได้
-- แต่มี active reservation ต่อที่นั่งได้เพียงหนึ่งรายการ
วิธีข้อดีข้อแลกเปลี่ยน
Unique constraint

Correctness แข็งแรงและเรียบง่าย

ต้อง handle conflict; hot row/index ยังเป็นคอขวดได้

Atomic update

Round trip น้อยและ critical section สั้น

Business rule ต้องเขียนใน predicate ให้ครบ

FOR UPDATE

อ่านง่ายเมื่อมีหลายเงื่อนไข

เกิด lock wait/deadlock และห้ามรอ external API ใน transaction

Optimistic version

ไม่ block reader/writer ระหว่างอ่าน

Conflict สูงแล้ว retry มากจน throughput ตก

Redis lock

เร็วและช่วยกรอง load ก่อนถึง DB

Lease expiry และ failure semantics ซับซ้อน ยังต้องมี DB guard

Single writer

Ordering ชัดและลด contention

เพิ่ม latency, queue lag และ operational complexity

04Architecture ที่เริ่มได้และ Scale ต่อได้

01Clientเลือกที่นั่ง + Idempotency-Key
02Booking APIAuth · validation · rate limit
03PostgreSQLAtomic hold + unique guard
04PaymentProvider + webhook

Read path สามารถใช้ Redis cache สำหรับ seat map ได้ แต่ผลจาก cache เป็นเพียงภาพประมาณการ เมื่อผู้ใช้กด hold ต้องผ่าน atomic write ที่ database ทุกครั้ง หลัง state เปลี่ยนให้ publish event ผ่าน transactional outbox เพื่อ update cache, notification และ analytics โดยไม่ทำ dual write ตรง ๆ

  1. 1
    POST /holds

    รับ event, seat และ Idempotency-Key; atomic update เป็น HELD พร้อม expiry

  2. 2
    เริ่ม Payment

    เปลี่ยน HELD → PAYMENT_PENDING ก่อนเรียก provider และผูก provider reference

  3. 3
    รับ Webhook

    ตรวจ signature และ deduplicate ด้วย provider event ID

  4. 4
    Confirm

    เปลี่ยน reservation เป็น CONFIRMED และออก ticket ใน transaction เดียว

05Payment และ Partial Failure

เราไม่สามารถครอบ transaction ของ database และ payment provider ด้วย ACID transaction เดียวได้ จึงใช้ Saga และ reconciliation ถ้าจ่ายสำเร็จแต่ confirm ไม่สำเร็จ ให้ retry จาก durable payment event หากจองที่นั่งไม่ได้จริงต้องมี compensation เช่น refund และส่งเข้าคิวตรวจสอบ

  • ก่อน redirect ไปจ่ายเงิน เปลี่ยน state เป็น PAYMENT_PENDING แบบ atomic เพื่อหยุด expiry worker
  • ใช้ provider reference และ webhook event ID เป็น unique key ป้องกัน callback ซ้ำ
  • อย่าเชื่อ redirect จาก browser เป็นหลักฐานการชำระเงิน ให้ยืนยันกับ signed webhook หรือ query provider
  • มี reconciliation job เทียบ payment ที่สำเร็จกับ reservation ที่ยังไม่ confirmed

06Failure Cases ที่ควรตอบให้ครบ

ผู้ใช้กดซ้ำ / network retry

Idempotency-Key คืน reservation เดิม ไม่สร้าง hold ใหม่

สองคนแย่งที่นั่งเดียวกัน

Atomic update หรือ unique constraint ทำให้ชนะเพียงคนเดียว

Service ตายหลัง DB commit

Client retry ด้วย key เดิมและได้รับผลเดิม

Hold หมดอายุ

Lazy expiry ใน atomic update ร่วมกับ background cleanup; ไม่พึ่ง cron อย่างเดียว

ชำระสำเร็จแต่ confirm ล้มเหลว

Webhook/outbox retry และ reconciliation; refund เมื่อกู้สถานะไม่ได้

Redis ล่ม

Fallback เข้า DB แบบ rate-limited; Redis ไม่ใช่ final correctness boundary

สำหรับ flash sale เพิ่ม virtual waiting room, per-user rate limit, bot protection และ partition ข้อมูลตาม event แต่ระวังว่าที่นั่งยอดนิยมยังเป็น hot key/row ต่อให้ shard ระบบแล้ว

07แนวตอบภายใน 3–5 นาที

อ่านเอกสารทางการ: PostgreSQL Explicit Locking
อ่านเอกสารทางการ: Redis Distributed Locks