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

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 ถาม โดเมน = true
B ถาม โดเมน = true
A 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 วาง​ไว้ ใน​รูป​ที่​รุนแรง​ที่สุด — โดเมน​ตรวจ​ถูก​เสมอ ตราบ​ใด​ที่​สิ่ง​ที่​ป้อน​ให้​มัน​เป็น​ความ​จริง และ​ตรง​นี้​มัน​เลิก​เป็น​ความ​จริง​ระหว่าง​ทาง

ขยาย aggregate ให้​กว้าง​ขึ้น ก็​ไม่​รอด

บท​ที่ 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 เขียน​ทับ55P03canceling statement due to lock timeout
A commit แล้ว B ค่อย​เขียน23P01conflicting key value violates exclusion constraint

สอง​แถว​นี้​ต้อง​เขียน code รับ​คนละ​แบบ

  • 23P01 แปล​ว่า​แพ้​ขาด · ตอบ​ผู้​ใช้​ไป​ได้​เลย​ว่า​เวลา​นี้​ถูก​จอง​ไป​แล้ว
  • 55P03 แปล​ว่า​ยัง​ไม่รู้​ผล · A อาจ commit หรือ​อาจ rollback ก็ได้ · การ​ตอบ​ว่า “ถูก​จอง​แล้ว” ตรง​นี้​อาจ​เป็น​คำ​ตอบ​ที่​ผิด

การ​จับ exception กว้างๆ แล้ว​ตอบ​ข้อความ​เดียวกัน​ทั้ง​สอง​กรณี คือ bug ที่​รอ​เวลา

`lock_timeout` เป็น​เพดาน​ของ​การ​รอ ไม่ใช่​การ​หน่วง​เวลา

ถ้า​ไม่​ตั้ง 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 ผิด