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

ความ​ขัด​แย้ง​ของ​ข้อมูล ไม่ใช่​ความ​ผิด​ของ server

บท​ที่ 2 ทิ้ง​ข้อ​หนึ่ง​ค้าง​ไว้ · คำขอ​ที่​จอง​ทับ​เวลา​กับ​ใบ​ที่​มี​อยู่​แล้ว ได้​รหัส 500 พร้อม​สี่​คีย์​แบบ​เดิม ทั้ง​ที่​เรา​เปิด​สวิตช์​ไป​แล้ว

บท​นี้​จะ​ไป​ให้​ถึง​ต้น​เหตุ​ของ​มัน แล้ว​แก้

ของ​ที่​คอร์ส​ก่อน​สร้าง​ไว้ และ​บท​นี้​ต้อง​ใช้

หัวข้อ​ที่​มีชื่อ​ว่า “ของ​ที่​คอร์ส​ก่อน​สร้าง​ไว้ และ​บท​นี้​ต้อง​ใช้”

ตาราง​หนึ่ง​ตาราง กับ​กติกา​หนึ่ง​ข้อ​ที่​ฐาน​ข้อมูล​บังคับ​เอง

สร้าง​ตาราง​ของ​คอร์ส​นี้
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE http_appointment_row (
id bigserial PRIMARY KEY,
doctor_id text NOT NULL,
start_minute int NOT NULL,
end_minute int NOT NULL,
idempotency_key text,
CONSTRAINT http_appointment_no_overlap
EXCLUDE USING gist (doctor_id WITH =, int4range(start_minute, end_minute) WITH &&)
);

EXCLUDE คือ​กติกา​ที่​บอก​ว่า​หมอ​คน​เดียวกัน​มี​ช่วง​เวลา​ที่​ทับ​กัน​ไม่​ได้ · คอร์ส​ก่อน​อธิบาย​ไว้​ครบ​แล้ว​ว่า​ทำไม​โดเมน​บังคับ​ข้อ​นี้​เอง​ไม่​ได้

⚠️ ชื่อ​ของ constraint ต้อง​ไม่​ซ้ำ​กับ​ของ​คอร์ส​ก่อน

EXCLUDE สร้าง index ขึ้น​มา​ข้าง​หลัง และ​ชื่อ​ของ index ต้อง​ไม่​ซ้ำ​กัน​ทั้ง​ส​คี​มา

ตอน​วัด​จริง​รอบ​แรก ผม​ใช้​ฐาน​ข้อมูล​เดียว​กับ​คอร์ส​ก่อน​แล้ว​ชน​เข้า​ให้ · CREATE TABLE ล้ม แต่​คำ​สั่ง​ที่​ผม​ใช้​ตรวจ​ถาม​หา constraint ด้วยชื่อ ซึ่ง​ไป​เจอ​ของ​คอร์ส​ก่อน​บน​ตาราง​คนละ​ตัว แล้ว​รายงาน​ว่า​สร้าง​สำเร็จ

คำ​สั่ง​ตรวจ​ที่​แยก​ของ​ใหม่​ออก​จาก​ของ​เก่า​ไม่​ได้ จะ​ผ่าน​ด้วย​เหตุผล​ผิด · ทาง​ที่​ถูก​คือ​ตรวจ​ที่​ตัว​ตาราง​ด้วย \d http_appointment_row

app/src/main/kotlin/clinic/http/AppointmentRow.kt
package clinic.http
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
@Entity
@Table(name = "http_appointment_row")
class AppointmentRow(
@Column(name = "doctor_id") var doctorId: String = "",
@Column(name = "start_minute") var startMinute: Int = 0,
@Column(name = "end_minute") var endMinute: Int = 0,
@Column(name = "idempotency_key") var idempotencyKey: String? = null,
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0
}
app/src/main/kotlin/clinic/http/AppointmentRows.kt
package clinic.http
import org.springframework.data.jpa.repository.JpaRepository
interface AppointmentRows : JpaRepository<AppointmentRow, Long> {
fun findByIdempotencyKey(key: String): AppointmentRow?
}

ใบ​แรก​คือ​ช่อง​ควบคุม ใบ​ที่​สอง​คือ​ของ​จริง

ใบ​แรก​ได้ 200 · ใบ​ที่​สอง​ได้ 500 · จำนวน​แถว​ใน​ตาราง​เหลือ 1 แถว

แถว​เดียว​นั้น​คือ​หลัก​ฐาน​ว่า​ฐาน​ข้อมูล​ทำงาน​ถูก​ต้อง​ทุก​อย่าง · สิ่ง​ที่​ผิด​ไม่ใช่​ฐาน​ข้อมูล มัน​คือ​สิ่ง​ที่​เรา​บอก​ผู้​เรียก

อ่าน exception chainexception chainสาย​ที่​ไล่​จาก exception ชั้น​นอก​ลง​ไป​หา​ต้น​เหตุ · อ่าน​สาย​นี้​แล้ว​จะ​รู้​ว่า​ควร​ดัก​ที่​ชั้น​ไหน · ดัก​ชั้น​ใน​เกิน​ไป​จะ​ผูก​กับ​ฐาน​ข้อมูล​ตัว​เดียว จาก​ชั้น​นอก​สุด​ลง​ไป​หา​ต้น​เหตุ

ไล่​จาก exception ชั้น​นอก​ลง​ไป​หา cause
org.springframework.dao.DataIntegrityViolationException
→ org.hibernate.exception.ConstraintViolationException
→ org.postgresql.util.PSQLException (SQLSTATE 23P01)

SQLSTATE 23P01SQLSTATE 23P01รหัส​ที่ PostgreSQL ตอบ​เมื่อ​คำขอ​ชน exclusion constraint · เป็น​หลัก​ฐาน​ว่า​ฐาน​ข้อมูล​กัน​ไว้​ได้​จริง · การ​สร้าง constraint ตัว​นั้น​เป็น​เรื่อง​ของ​บท​ที่ 6 คอร์ส​ก่อน คือ​รหัส​ที่ PostgreSQL ตอบ​เมื่อ​คำขอ​ชน exclusion constraint · มัน​เจาะจง​มาก และ​มัน​อยู่​ลึก​ที่สุด​ใน​สาย

คำถาม​คือ​เรา​ควร​ดัก​ที่​ชั้น​ไหน

ชั้นดัก​แล้ว​ได้​อะไรราคา
PSQLExceptionรู้​ถึง SQLSTATEผูก​กับ PostgreSQL ตัว​เดียว
ConstraintViolationExceptionรู้​ว่า​เป็น constraintผูก​กับ Hibernate
DataIntegrityViolationExceptionDataIntegrityViolationExceptionชนิด​ที่ Spring ห่อ​ความ​ผิด​พลาด​ของ​ฐาน​ข้อมูล​ไว้ · เป็น​จุด​เดียว​ที่​ดัก​ได้​โดย​ไม่​ต้อง​รู้จัก​ฐาน​ข้อมูล​ตัว​ไหน และ​เป็น​ที่​ที่​การ​แปลง​เป็น 409 เกิด​ขึ้นรู้​ว่า​ความ​ถูก​ต้อง​ของ​ข้อมูล​ถูก​ละเมิดไม่​ผูก​กับ​ตัว​ไหน​เลย

ชั้น​นอก​สุด​คือ​ชั้น​ที่ Spring แปล​ไว้​ให้​แล้ว · มัน​อยู่​ใน​ตระกูล DataAccessExceptionDataAccessExceptionต้น​ตระกูล​ของ​ชนิด​ข้าง​บน · มัน​ไม่ใช่ exception ของ Spring MVC ค่า​ตั้ง​ต้น​ของ `problemdetails` จึง​ครอบ​ไม่​ถึง และ​ปล่อย​ให้​ตก​ไป​ที่​ตัว​จัดการ​ค่า​ตั้ง​ต้น ซึ่ง​เป็น​ตระกูล​ที่​บท​ที่ 2 บอก​ว่า​สวิตช์ problemdetails ครอบ​ไม่​ถึง

ดัก​ตรง​นั้น​แล้ว​เปลี่ยน​ฐาน​ข้อมูล​ทีหลัง ตัว​จัดการ​ก็​ยัง​ทำงาน​เหมือน​เดิม

คราว​นี้​ไม่​ต้อง​ใส่ @Order เลย

app/src/main/kotlin/clinic/http/ConflictAdvice.kt
package clinic.http
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class ConflictAdvice {
@ExceptionHandler(DataIntegrityViolationException::class)
fun onConflict(e: DataIntegrityViolationException): ProblemDetail {
val body = ProblemDetail.forStatus(HttpStatus.CONFLICT)
body.title = "ช่วงเวลานี้ถูกจองไปแล้ว"
body.detail = "เลือกช่วงเวลาอื่นแล้วส่งเข้ามาใหม่"
body.setProperty("retryable", false)
return body
}
}

บท​ที่​แล้ว​ต้อง​ใส่ @Order(-1) เพราะ Spring มี​ตัว​จัดการ​ของ​ตัว​เอง​แข่ง​อยู่ · ชนิด​นี้​ไม่มี​ใคร​แข่ง ตัว​ของ​เรา​จึง​ได้​ทำงาน​ทันที

ลำดับ​ไม่ใช่​กฎ​ที่​ต้อง​ท่อง มัน​คือ​เครื่อง​มือ​สำหรับ​ตอน​ที่​มี​คน​แข่ง

รหัส​เปลี่ยน · body เปลี่ยน · จำนวน​แถว​ไม่​เปลี่ยน

ตอบ​กลับ​ของ​ใบ​ที่​สอง · 409 · application/problem+json
{"detail":"เลือกช่วงเวลาอื่นแล้วส่งเข้ามาใหม่","instance":"/bookings","retryable":false,"status":409,"title":"ช่วงเวลานี้ถูกจองไปแล้ว"}
ก่อน​แก้หลัง​แก้
รหัส500409
content-typeapplication/jsonapplication/problem+json
จำนวน​คีย์45
แถว​ใน​ตาราง11

409 Conflict409 Conflictรหัส​ที่​แปล​ว่า​คำขอ​ถูก​ต้อง​ตาม​รูป แต่​ชน​กับ​สิ่ง​ที่​มี​อยู่​แล้ว · บอก​ผู้​เรียก​ว่า​ลอง​ใหม่​แบบ​เดิม​ไม่มี​ทาง​สำเร็จ ซึ่ง​เป็น​ข้อมูล​ที่ 500 ไม่​ได้​บอก บอก​ผู้​เรียก​สอง​อย่าง​ที่ 500 บอก​ไม่​ได้​เลย · ข้อ​แรก​คือ​คำขอ​ของ​คุณ​ถูก​ต้อง​ตาม​รูป · ข้อ​ที่​สอง​คือ​มัน​ชน​กับ​สิ่ง​ที่​มี​อยู่​แล้ว

คีย์ retryable ที่​เรา​เติม​เข้าไป พูด​ข้อ​ที่​สาม​ออก​มา​ตรงๆ คือ​ลอง​ใหม่​แบบ​เดิม​ไม่​ช่วย

🎯 จำนวน​แถว​คือ​ช่อง​ควบคุม​ของ​บท​นี้

การ​แปลง​รหัส​เป็นการ​เปลี่ยน​สิ่ง​ที่​เรา​พูด ไม่ใช่​การ​เปลี่ยน​สิ่ง​ที่​เรา​ทำ

ถ้า​หลัง​แก้​แล้ว​จำนวน​แถว​เปลี่ยน​ไป แปล​ว่า​เรา​ไม่​ได้​แค่​แปล​รหัส เรา​เผลอ​เปลี่ยน​พฤติกรรม​ด้วย · ตัวเลข 1 ที่​เท่า​กัน​ทั้ง​สอง​ฝั่ง คือ​สิ่ง​ที่​ยืนยัน​ว่า​เรา​แตะ​แค่​คำ​พูด

ช่อง​ควบคุม​อีก​สอง​ช่อง​คือ คำขอ​ที่​ถูก​ต้อง​ยัง​ได้ 200 และ​คำขอ​ที่​ผิด​ยัง​ได้ 400 · ตัว​จัดการ​ตัว​ใหม่​ต้อง​ไม่​ไป​กลืน​ของ​เดิม

สี่​ข้อ · และ​ข้อ​สุดท้าย​คือ​ข้อ​ที่​ทำให้​เชื่อ​สาม​ข้อ​แรก​ได้

  • สาย​การ​แปลง​มี​สาม​ชั้น และ​ชั้น​นอก​สุด​คือ​ชั้น​ที่​ไม่​ผูก​กับ​ฐาน​ข้อมูล​ตัว​ไหน
  • SQLSTATE ที่​ได้​คือ 23P01 ซึ่ง​เป็น​รหัส​ของ exclusion constraint
  • แปลง​เป็น 409 ได้ พร้อม​คีย์​ที่​บอก​ผู้​เรียก​ว่า​ลอง​ใหม่​ไม่​ช่วย
  • จำนวน​แถว​เท่า​เดิม​ทั้ง​ก่อน​และ​หลัง เรา​เปลี่ยน​คำ​พูด ไม่​ได้​เปลี่ยน​พฤติกรรม

บท​ที่ 5 จะ​ถาม​คำถาม​ที่ 409 เปิด​ทาง​ไว้ คือ​ผู้​เรียก​ที่​ลอง​ใหม่​จริงๆ จะ​ได้​อะไร

🔬 ตัวเลข​ทุก​ตัว​ใน​บท​นี้​มา​จาก​ไหน
  • สาย​การ​แปลง​อ่าน​จาก​ใน​เทสต์​ที่​จับ exception เอง แล้ว​ไล่ cause ที​ละ​ชั้น · จำกัด​จำนวน​รอบ​ไว้ เพราะ exception บาง​ตัว​ชี้​กลับ​หา​ตัว​เอง
  • จำนวน​แถว​นับ​ด้วย​คำ​สั่ง​ที่​ฐาน​ข้อมูล​ตัว​จริง ไม่​ได้​นับ​จาก​ใน​โปรแกรม
  • Spring Boot 4.1.1 · Kotlin 2.4.10 · PostgreSQL 18 · pg driver 42.7.13
  • รัน​ซ้ำ​เอง​ได้​ด้วย npm run verify:clinic-http
  • PostgreSQL — Error Codes (เข้า​ถึง 2026-09-02) — 23P01 exclusion_violation
  • Spring — DataAccessException hierarchy (เข้า​ถึง 2026-09-02) — ชั้น​ที่ Spring แปล​ความ​ผิด​พลาด​ของ​ฐาน​ข้อมูล​ไว้​ให้

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

ข้อ 1 / 3

ทำไมจึงควรดักที่ DataIntegrityViolationException ไม่ใช่ที่ PSQLException