ความขัดแย้งของข้อมูล ไม่ใช่ความผิดของ 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 คือกติกาที่บอกว่าหมอคนเดียวกันมีช่วงเวลาที่ทับกันไม่ได้
· คอร์สก่อนอธิบายไว้ครบแล้วว่าทำไมโดเมนบังคับข้อนี้เองไม่ได้
EXCLUDE สร้าง index ขึ้นมาข้างหลัง และชื่อของ index ต้องไม่ซ้ำกันทั้งสคีมา
ตอนวัดจริงรอบแรก ผมใช้ฐานข้อมูลเดียวกับคอร์สก่อนแล้วชนเข้าให้
· CREATE TABLE ล้ม แต่คำสั่งที่ผมใช้ตรวจถามหา constraint ด้วยชื่อ
ซึ่งไปเจอของคอร์สก่อนบนตารางคนละตัว แล้วรายงานว่าสร้างสำเร็จ
คำสั่งตรวจที่แยกของใหม่ออกจากของเก่าไม่ได้ จะผ่านด้วยเหตุผลผิด
· ทางที่ถูกคือตรวจที่ตัวตารางด้วย \d http_appointment_row
package clinic.http
import jakarta.persistence.Columnimport jakarta.persistence.Entityimport jakarta.persistence.GeneratedValueimport jakarta.persistence.GenerationTypeimport jakarta.persistence.Idimport 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}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 ชั้นนอกลงไปหาต้นเหตุ · อ่านสายนี้แล้วจะรู้ว่าควรดักที่ชั้นไหน · ดักชั้นในเกินไปจะผูกกับฐานข้อมูลตัวเดียว จากชั้นนอกสุดลงไปหาต้นเหตุ
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เลย
package clinic.http
import org.springframework.dao.DataIntegrityViolationExceptionimport org.springframework.http.HttpStatusimport org.springframework.http.ProblemDetailimport org.springframework.web.bind.annotation.ExceptionHandlerimport org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdviceclass 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 เปลี่ยน · จำนวนแถวไม่เปลี่ยน
{"detail":"เลือกช่วงเวลาอื่นแล้วส่งเข้ามาใหม่","instance":"/bookings","retryable":false,"status":409,"title":"ช่วงเวลานี้ถูกจองไปแล้ว"}| ก่อนแก้ | หลังแก้ | |
|---|---|---|
| รหัส | 500 | 409 |
| content-type | application/json | application/problem+json |
| จำนวนคีย์ | 4 | 5 |
| แถวในตาราง | 1 | 1 |
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