01เริ่มจาก Requirement และ Invariant
ก่อนวาด architecture ให้ถามว่าคำว่า “ตั๋วไม่ซ้ำ” หมายถึงอะไร สำหรับระบบเลือกที่นั่ง invariant หลักคือ หนึ่ง `(event_id, seat_id)` มี active owner ได้ไม่เกินหนึ่งคน และ invariant นี้ต้องถูกบังคับที่ authoritative data store ไม่ใช่แค่ตรวจจาก cache หรือ UI
- ค้นหารอบและดูที่นั่งว่าง
- Hold ที่นั่งชั่วคราว
- ชำระเงินและยืนยันตั๋ว
- หมดอายุหรือยกเลิก hold
- ห้ามขายที่นั่งซ้ำ
- รองรับ traffic spike
- ตอบสนองเร็วและตรวจสอบย้อนหลังได้
- ทนต่อ retry และ partial failure
02State Machine และ Data Model
การจองไม่ควรเปลี่ยนจากว่างเป็นขายแล้วทันที ให้มี hold ที่มีวันหมดอายุ เพื่อให้ผู้ใช้มีเวลาชำระเงิน State transition ทุกครั้งต้องตรวจ current state และเจ้าของ hold แบบ atomic
event_id · seat_id · status · hold_id · hold_expires_at · versionreservation_id · user_id · event_id · seat_id · status · idempotency_keypayment_id · reservation_id · provider_ref · status · amountevent_id · aggregate_id · event_type · payload · published_atเก็บ current ownership ไว้ใน `seat_inventory` ส่วนประวัติการพยายามจองและ state transition อยู่ใน reservations/audit log จะทำให้ constraint หลักเรียบและยังตรวจสอบย้อนหลังได้
03มีวิธีป้องกันการจองซ้ำแบบใดบ้าง?
Database Unique Constraint
สร้าง unique constraint หรือ partial unique index บน event และ seat ให้ database เป็นผู้ตัดสินสุดท้าย หาก request สองตัว insert พร้อมกัน จะสำเร็จเพียงตัวเดียว อีกตัวได้ unique violation
เหมาะกับ:ระบบส่วนใหญ่ที่ใช้ relational database และต้องการ correctness แบบเรียบง่าย
Atomic Conditional Update
Update ที่นั่งเมื่อสถานะยังว่างหรือ hold หมดอายุใน statement เดียว จำนวน row ที่ update เป็นผลการแย่งสิทธิ์ มีเพียง transaction เดียวที่ชนะ
เหมาะกับ:ตาราง inventory มีหนึ่ง row ต่อที่นั่ง และต้องการ critical section สั้นที่สุด
Pessimistic Lock
ใช้ `SELECT ... FOR UPDATE` lock row ก่อนตรวจเงื่อนไขและเปลี่ยน state Transaction อื่นจะรอ เหมาะเมื่อการตัดสินใจมีหลายขั้นแต่ต้องระวัง lock wait และ deadlock
เหมาะกับ:Business rule ซับซ้อนและ contention ไม่สูงมาก
Optimistic Locking
อ่านค่า `version` แล้ว update ด้วยเงื่อนไข `WHERE version = old_version` หากไม่มี row เปลี่ยนแปลว่ามีคนแก้ก่อน ให้ retry หรือแจ้งว่าที่นั่งถูกจองแล้ว
เหมาะกับ:Conflict เกิดไม่บ่อยและไม่อยากถือ lock ระหว่างอ่าน
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 เร็ว โดยยอมรับความซับซ้อนเพิ่ม
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 เดียวแก้ปัญหาได้
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 = มีคนถือหรือซื้อที่นั่งไปแล้วCREATE UNIQUE INDEX one_active_reservation_per_seat
ON reservations (event_id, seat_id)
WHERE status IN ('HELD', 'PAYMENT_PENDING', 'CONFIRMED');
-- เก็บประวัติรายการที่หมดอายุหรือยกเลิกได้
-- แต่มี active reservation ต่อที่นั่งได้เพียงหนึ่งรายการCorrectness แข็งแรงและเรียบง่าย
ต้อง handle conflict; hot row/index ยังเป็นคอขวดได้
Round trip น้อยและ critical section สั้น
Business rule ต้องเขียนใน predicate ให้ครบ
อ่านง่ายเมื่อมีหลายเงื่อนไข
เกิด lock wait/deadlock และห้ามรอ external API ใน transaction
ไม่ block reader/writer ระหว่างอ่าน
Conflict สูงแล้ว retry มากจน throughput ตก
เร็วและช่วยกรอง load ก่อนถึง DB
Lease expiry และ failure semantics ซับซ้อน ยังต้องมี DB guard
Ordering ชัดและลด contention
เพิ่ม latency, queue lag และ operational complexity
04Architecture ที่เริ่มได้และ Scale ต่อได้
Read path สามารถใช้ Redis cache สำหรับ seat map ได้ แต่ผลจาก cache เป็นเพียงภาพประมาณการ เมื่อผู้ใช้กด hold ต้องผ่าน atomic write ที่ database ทุกครั้ง หลัง state เปลี่ยนให้ publish event ผ่าน transactional outbox เพื่อ update cache, notification และ analytics โดยไม่ทำ dual write ตรง ๆ
- 1POST /holds
รับ event, seat และ Idempotency-Key; atomic update เป็น HELD พร้อม expiry
- 2เริ่ม Payment
เปลี่ยน HELD → PAYMENT_PENDING ก่อนเรียก provider และผูก provider reference
- 3รับ Webhook
ตรวจ signature และ deduplicate ด้วย provider event ID
- 4Confirm
เปลี่ยน 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 ที่ควรตอบให้ครบ
Idempotency-Key คืน reservation เดิม ไม่สร้าง hold ใหม่
Atomic update หรือ unique constraint ทำให้ชนะเพียงคนเดียว
Client retry ด้วย key เดิมและได้รับผลเดิม
Lazy expiry ใน atomic update ร่วมกับ background cleanup; ไม่พึ่ง cron อย่างเดียว
Webhook/outbox retry และ reconciliation; refund เมื่อกู้สถานะไม่ได้
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 ↗