invariant ที่หนีออกจาก aggregate และคนที่ต้องกันมันแทน
ห้าบทที่ผ่านมาเดินมาที่บทนี้
โดเมนของเราตรวจถูก · ขอบ transaction อยู่ถูกที่ · กฎทิศทางบังคับด้วย build ได้ · แล้วสองคนก็ยังจองเวลาทับกันได้อยู่ดี
สองคนจองพร้อมกัน แล้วทั้งคู่ผ่านด่านโดเมน
หัวข้อที่มีชื่อว่า “สองคนจองพร้อมกัน แล้วทั้งคู่ผ่านด่านโดเมน”ลำดับทุกขั้นถูกกำหนดตายตัว ไม่มีการหน่วงเวลาและไม่มีการแข่งกันของ thread
เปิดสอง connection แล้วสั่งทีละขั้นจากโปรแกรมเดียว · ทั้งคู่ถามคำถามเดียวกับที่โดเมนถาม คือ “หมอคนนี้มีนัดซ้อนช่วงนี้ไหม”
SELECT count(*) FROM appointment WHERE doctor = ? AND start_min < ? AND ? < end_minแล้วเขียนถ้าคำตอบว่าไม่มี
INSERT INTO appointment (doctor, start_min, end_min) VALUES (?, ?, ?)ลำดับที่สั่งไปคือ A ถาม · B ถาม · A เขียน · B เขียน · A commit · B commit
A ถาม โดเมน = trueB ถาม โดเมน = trueA insert แล้วB insert แล้วA commit แล้วB commit แล้วคู่ที่ซ้อนกันในตาราง = 1คู่ที่ซ้อนกันในตารางคือ 1 · A จอง 9:00 ถึง 9:30 และ B จอง 9:10 ถึง 9:40 ของหมอคนเดียวกัน
sequenceDiagram participant A as session A participant DB as PostgreSQL participant B as session B A->>DB: ถาม มีนัดซ้อนไหม DB-->>A: ไม่มี B->>DB: ถาม มีนัดซ้อนไหม DB-->>B: ไม่มี A->>DB: insert 9:00 ถึง 9:30 B->>DB: insert 9:10 ถึง 9:40 A->>DB: commit B->>DB: commit Note over DB: แถวที่ซ้อนกัน 1 คู่
คำบรรยายภาพ: ทั้งสองฝั่งถามก่อนเขียน และทั้งสองฝั่งได้คำตอบที่ถูกต้อง ณ เวลาที่ถาม
ทำไมมันเกิดขึ้นได้ ทั้งที่ทั้งคู่ตรวจก่อนเขียน
หัวข้อที่มีชื่อว่า “ทำไมมันเกิดขึ้นได้ ทั้งที่ทั้งคู่ตรวจก่อนเขียน”คำตอบไม่ได้อยู่ที่ code ของใครผิด · มันอยู่ที่ขอบเขตของสิ่งที่ transaction หนึ่งมองเห็น
ที่ระดับปริยายของ PostgreSQL การอ่านเห็นเฉพาะสิ่งที่ commit แล้ว · ตอน B ถาม แถวของ A ยังไม่ commit B จึงมองไม่เห็นมันอย่างถูกต้อง
ไม่มีใครผิดสักคน · คำถามที่ทั้งคู่ถามได้คำตอบที่ตรงกับความจริงในตอนนั้นทั้งคู่ · สิ่งที่ผิดคือสมมติฐานว่าคำตอบนั้นจะยังจริงตอนเขียน
นี่คือข้อเดียวกับที่บทที่ 2 วางไว้ ในรูปที่รุนแรงที่สุด — โดเมนตรวจถูกเสมอ ตราบใดที่สิ่งที่ป้อนให้มันเป็นความจริง และตรงนี้มันเลิกเป็นความจริงระหว่างทาง
บทที่ 2 เสนอทางเลือกไว้ว่าให้ขยาย aggregate จากนัดหนึ่งใบ เป็นตารางทั้งวันของหมอหนึ่งคน · ถ้าโหลดทั้งวันมาแล้ว การตรวจว่าซ้อนกันก็อยู่ในอาณาเขตของ aggregate จริงๆ
แต่การวัดข้างบนคือการโหลดทั้งวันมาแล้วพอดี · คำถาม count(*) นั้นดูนัดของหมอคนนั้น
ทุกใบในช่วงที่สนใจ ซึ่งกว้างกว่าที่ aggregate ใบเดียวจะเห็น
มันยังพัง เพราะปัญหาไม่ได้อยู่ที่ขนาดของสิ่งที่อ่าน แต่อยู่ที่ช่วงเวลาระหว่างอ่านกับเขียน · ขยาย aggregate เท่าไรก็ไม่ปิดช่องนั้น ตราบใดที่ยังมีอีกคนทำแบบเดียวกันพร้อมกัน
ใครกันข้อนี้ได้
หัวข้อที่มีชื่อว่า “ใครกันข้อนี้ได้”คนเดียวที่เห็นทุก transaction พร้อมกัน คือฐานข้อมูล
exclusion constraintexclusion constraintกติกาของ PostgreSQL ที่ห้ามสองแถวมีช่วงเวลาทับกันบนหมอคนเดียวกัน · ต้องเปิด `btree_gist` ก่อน เพราะมันเทียบค่าเท่ากันกับค่าทับกันในตัวเดียวกัน คือกติกาที่ห้ามสองแถวมีค่าที่ “ทับกัน” ตามนิยามที่เรากำหนด · ต้องเปิด btree_gistbtree_gistextension ที่ทำให้ index แบบ GiST เทียบค่าด้วยเครื่องหมายเท่ากับได้ · ขาดมันแล้วประกาศ constraint ที่ผสมตัวระบุหมอกับช่วงเวลาไว้ในกติกาเดียวกันไม่ได้ ก่อน เพราะมันทำให้ index แบบ GiST เทียบค่าเท่ากันได้ ในกติกาเดียวกับที่เทียบค่าทับกัน
CREATE EXTENSION IF NOT EXISTS btree_gist;ALTER TABLE appointment ADD CONSTRAINT appointment_no_overlap EXCLUDE USING gist (doctor WITH =, int4range(start_min, end_min) WITH &&);อ่านบรรทัดสุดท้ายเป็นภาษาไทยได้ว่า ห้ามสองแถวที่หมอเท่ากัน และช่วงเวลาทับกัน
นี่ไม่ใช่การย้ายกติกาของธุรกิจไปไว้ในฐานข้อมูล · โดเมนยังตรวจข้อนี้อยู่เหมือนเดิม และยังต้องตรวจ เพราะมันคือที่ที่ตอบผู้ใช้ว่าทำไมจองไม่ได้
สิ่งที่ constraint ทำคือเป็นคนสุดท้ายที่พูด ในกรณีที่ด่านแรกมองไม่เห็น
โหมดความล้มเหลวมีสองแบบ ไม่ใช่แบบเดียว
หัวข้อที่มีชื่อว่า “โหมดความล้มเหลวมีสองแบบ ไม่ใช่แบบเดียว”ข้อที่คนไม่คาด · constraint ไม่ได้ปฏิเสธทันทีเสมอไป บางทีมันให้รอ
ใส่ constraint แล้ววัดสองสถานการณ์ ซึ่งต่างกันแค่ว่า A commit ไปแล้วหรือยัง
| สถานการณ์ | SQLSTATESQLSTATEรหัสห้าตัวอักษรที่บอกว่าคำสั่งล้มด้วยเหตุอะไร · คอร์สนี้ใช้มันแทนข้อความ เพราะข้อความเปลี่ยนตามรุ่นและตามภาษา แต่รหัสไม่เปลี่ยน | ความหมาย |
|---|---|---|
| A ยังไม่ commit แล้ว B เขียนทับ | 55P03 | canceling statement due to lock timeout |
| A commit แล้ว B ค่อยเขียน | 23P01 | conflicting key value violates exclusion constraint |
สองแถวนี้ต้องเขียน code รับคนละแบบ
23P01แปลว่าแพ้ขาด · ตอบผู้ใช้ไปได้เลยว่าเวลานี้ถูกจองไปแล้ว55P03แปลว่ายังไม่รู้ผล · A อาจ commit หรืออาจ rollback ก็ได้ · การตอบว่า “ถูกจองแล้ว” ตรงนี้อาจเป็นคำตอบที่ผิด
การจับ exception กว้างๆ แล้วตอบข้อความเดียวกันทั้งสองกรณี คือ bug ที่รอเวลา
ถ้าไม่ตั้ง lock_timeoutlock_timeoutเพดานของการรอ lock · ใส่ไว้เพื่อให้การรอที่ไม่มีวันจบกลายเป็นรหัสข้อผิดพลาดที่อ่านได้ ไม่ใช่เพื่อสร้างจังหวะ · มันคือฝาปิด ไม่ใช่การหน่วง ไว้ B จะรอ A ไปเรื่อยๆ ไม่มีกำหนด · การวัดจะค้าง ไม่ใช่ให้ผล
SET lock_timeout = '2000ms'บรรทัดนี้ไม่ได้สร้างจังหวะ และไม่ได้ทำให้เกิดการแข่งกัน · มันบอกว่า “รอได้ถึงแค่นี้ แล้วให้ยกเลิกคำสั่ง” ซึ่งเปลี่ยนการรอที่ไม่มีวันจบ ให้เป็นรหัสข้อผิดพลาดที่อ่านได้
ค่าที่ตั้งไม่ได้เปลี่ยนผล · ตั้งเท่าไรก็ได้ 55P03 เหมือนเดิม
เพราะสิ่งที่วัดคือรหัส ไม่ใช่ระยะเวลา
ตอนสร้าง constraint ครั้งแรก ผมส่ง SQL เข้า container ด้วย docker exec ที่ไม่มี -i
คำสั่งนั้นไม่ส่ง stdin เลย · SQL จึงไม่เคยถูกรัน แล้วคำสั่งจบด้วยรหัส 0 เหมือนสำเร็จ · การวัดรอบถัดมาจึงรายงานว่า constraint ไม่กันอะไรเลย ซึ่งอ่านเหมือนข้ออ้างผิด
จับได้เพราะไปถาม pg_constraint ตรงๆ แล้วพบว่าไม่มี constraint อยู่จริง
คำสั่งที่ไม่ทำอะไรเลย หน้าตาเหมือนคำสั่งที่สำเร็จ · ทุกครั้งที่ตั้งอะไรไว้แล้ว จะเอาผลไปใช้เป็นหลักฐาน ให้ถามระบบกลับว่าของนั้นอยู่จริงหรือเปล่า
สิ่งที่บทนี้ยังไม่ทำ
หัวข้อที่มีชื่อว่า “สิ่งที่บทนี้ยังไม่ทำ”สามอย่างที่ยังไม่มี และแต่ละอย่างมาถึงเมื่อมีเหตุผลให้ต้องมี
- ยังไม่ได้แปลง SQLSTATE เป็นคำตอบของโดเมน ชั้นที่แปลงควรอยู่ตรงไหน เป็นคำถามของโครงสร้าง · โดเมนไม่ควรรู้จักเลขห้าหลักของ PostgreSQL
- ยังไม่ได้แตะการแก้ของที่มีอยู่แล้ว บทนี้เป็นเรื่องของการเพิ่มแถวใหม่ · การที่สองคนแก้นัดใบเดียวกันเป็นคนละปัญหา และเป็นเรื่องของบทที่ 7
- ยังไม่มีเทสต์ของเส้นทางนี้ การทดสอบสิ่งที่ต้องใช้สอง connection แพงกว่าการทดสอบโดเมนมาก · บทที่ 8 นับให้ดูว่าแพงกว่าแค่ไหน
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้ตอบคำถามที่คอร์สตั้งไว้ตั้งแต่หน้าแรก
invariant ที่กว้างกว่าสิ่งที่ transaction เดียวมองเห็น บังคับในโดเมนไม่ได้ ไม่ว่าจะเขียนโดเมนดีแค่ไหน หรือขยาย aggregate ไปกว้างแค่ไหน
คนที่บังคับได้คือฐานข้อมูล เพราะมันเป็นคนเดียวที่เห็นทุกคนพร้อมกัน · และเมื่อมันบังคับ มันตอบมาสองแบบที่ต้องรับคนละแบบ
บทหน้าเป็นปัญหาพี่น้องของบทนี้ — สองคนแก้ของชิ้นเดียวกัน · คราวนี้ไม่มีแถวใหม่ ให้ชนกัน มีแต่แถวเดิมที่ถูกเขียนทับ และคำตอบคนละแบบกับบทนี้
ทุกตัวเลขและทุกรหัสมาจากการรันจริงบน PostgreSQL 18.6 โดยคุมลำดับทุกขั้น:
- วัดเมื่อ 2026-09-01 · สอง connection ถูกสั่งทีละขั้นจากโปรแกรมเดียว · ไม่มีการหน่วงเวลาและไม่มี thread แข่งกัน เหตุการณ์ที่ต้องพึ่งความเร็วเครื่อง คือเหตุการณ์ที่สอนไม่ได้
- รันซ้ำเองได้ด้วย
npm run verify:clinic-clean-arch - PostgreSQL — Constraints
(เข้าถึง 2026-09-01) —
EXCLUDEและข้อที่มันต้องการ index แบบที่รองรับตัวดำเนินการนั้น - PostgreSQL — btree_gist (เข้าถึง 2026-09-01) — เหตุที่ต้องเปิด extension นี้ก่อนผสมค่าเท่ากับกับช่วงที่ทับกัน
- PostgreSQL — Error codes
(เข้าถึง 2026-09-01) —
55P03 lock_not_availableและ23P01 exclusion_violation
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3ทั้งสอง session ถามฐานข้อมูลก่อนเขียน และทั้งคู่ได้คำตอบว่าจองได้ · ใครเขียน code ผิด