สองคนแก้แถวเดียวกัน · ผู้แพ้ถูกทิ้ง ไม่ได้ถูกรวม
บทที่แล้วเป็นเรื่องของสองแถวใหม่ที่ไม่ควรอยู่ด้วยกัน
บทนี้เป็นปัญหาพี่น้องของมัน — แถวเดิมใบเดียว ที่มีสองคนแก้พร้อมกัน · คราวนี้ไม่มีอะไรให้ชนกัน มีแต่ของที่ถูกเขียนทับ
ปัญหาที่ไม่มีอะไรฟ้อง
หัวข้อที่มีชื่อว่า “ปัญหาที่ไม่มีอะไรฟ้อง”ไม่มี constraint ไหนถูกละเมิด ไม่มี exception ถูกโยน และผลลัพธ์ก็ผิด
A กับ B เปิดนัดใบเดียวกันขึ้นมาแก้ · A เปลี่ยนหมายเหตุแล้วบันทึก · B เปลี่ยนหมายเหตุแล้วบันทึกตาม
ถ้าไม่มีใครกัน สิ่งที่ A เขียนจะหายไปเงียบๆ · ฐานข้อมูลไม่ได้ทำอะไรผิดเลย มันทำตามคำสั่งสองคำสั่งตามลำดับ · นี่คือ lost updatelost updateการที่งานเขียนของคนหนึ่งหายไปเพราะอีกคนเขียนทับ · สิ่งที่ `@Version` ทำคือเปลี่ยนความเงียบนั้นให้เป็นข้อผิดพลาด ไม่ใช่รวมของสองฝั่งเข้าด้วยกัน
ใส่ @Version แล้ววัดว่ามันทำอะไร
หัวข้อที่มีชื่อว่า “ใส่ @Version แล้ววัดว่ามันทำอะไร”ช่องเดียวในตาราง ที่เปลี่ยนคำสั่ง
UPDATEทุกคำสั่งที่วิ่งผ่าน entity ตัวนี้
@Entitydata class AppointmentRow( @Id val id: Long, val doctor: String, val note: String, @Version val version: Long = 0,)@Version@Versionช่องที่ Hibernate เพิ่มเข้าไปในเงื่อนไขของ `UPDATE` แล้วบวกหนึ่งทุกครั้งที่เขียนสำเร็จ · ถ้าไม่มีแถวไหนตรงเงื่อนไข แปลว่ามีคนอื่นเขียนไปก่อนแล้ว ทำให้ Hibernate เติมเงื่อนไขเข้าไปในคำสั่งเขียน · แทนที่จะเป็น
UPDATE ... WHERE id = ? มันกลายเป็น UPDATE ... WHERE id = ? AND version = ?
ถ้าไม่มีแถวไหนตรงเงื่อนไข แปลว่าเลขรุ่นขยับไปแล้ว ซึ่งแปลว่ามีคนอื่นเขียนไปก่อน
เดินทั้งลำดับให้ดู
หัวข้อที่มีชื่อว่า “เดินทั้งลำดับให้ดู”ลำดับทุกขั้นกำหนดตายตัว ไม่มีการหน่วงเวลา และไม่มี thread แข่งกัน
val tx = TransactionTemplate(tm) val a = tx.execute { repo.findById(1L).get() }!! val b = tx.execute { repo.findById(1L).get() }!! println("KV versionRead=" + a.version) tx.execute { repo.save(a.copy(note = "A แก้")) } try { tx.execute { repo.save(b.copy(note = "B แก้")) } println("KV loserWrote=true") } catch (e: Exception) { val root = generateSequence<Throwable>(e) { it.cause }.last() println("KV lockWrapper=" + e::class.java.name) println("KV lockRoot=" + root::class.java.name) }ผลที่ได้
version ที่ A อ่าน = 0version ที่ B อ่าน = 0A เขียนสำเร็จB ล้มversion สุดท้าย = 1note สุดท้าย = A แก้sequenceDiagram participant A as transaction A participant DB as PostgreSQL participant B as transaction B A->>DB: อ่านแถวที่ 1 DB-->>A: version 0 B->>DB: อ่านแถวที่ 1 DB-->>B: version 0 A->>DB: UPDATE WHERE id=1 AND version=0 DB-->>A: 1 แถว · version เป็น 1 B->>DB: UPDATE WHERE id=1 AND version=0 DB-->>B: 0 แถว Note over B: Hibernate โยน exception
คำบรรยายภาพ: ทั้งคู่อ่านเลขรุ่นเดียวกัน แต่มีคนเดียวที่เงื่อนไขยังตรงตอนเขียน
exception มีสองชั้น และการดักผิดชั้นไม่มีอะไรเตือน
หัวข้อที่มีชื่อว่า “exception มีสองชั้น และการดักผิดชั้นไม่มีอะไรเตือน”ข้อนี้คือกับดักที่แพงที่สุดของบทนี้
| ชั้น | ชนิด |
|---|---|
| ชั้นนอก ที่ Spring โยนออกมา | org.springframework.orm.ObjectOptimisticLockingFailureException |
| ต้นเหตุที่อยู่ข้างใน | org.hibernate.StaleObjectStateException |
code ที่เขียน catch (e: StaleObjectStateException) จะไม่ทำงานเลย
เพราะของที่วิ่งมาถึงคือตัวห่อ ไม่ใช่ต้นเหตุ
และไม่มีอะไรเตือน · build เขียว โปรแกรมรันได้ และ catch นั้นไม่เคยถูกเข้าสักครั้ง
จนกว่าจะมีคนไปอ่าน log แล้วสงสัยว่าทำไม exception หลุดออกมา
บรรทัดที่ใช้ในการวัดข้างบนไล่ cause ไปจนถึงตัวสุดท้าย
val root = generateSequence<Throwable>(e) { it.cause }.last()ทำแบบนี้ตอนเจอ exception ที่ไม่คุ้นเคยครั้งแรกเสมอ · แล้วค่อยตัดสินใจว่าจะดักชั้นไหน
ดักชั้นนอกเป็นค่าตั้งต้นที่ปลอดภัยกว่า เพราะมันคือสัญญาที่ Spring ให้ไว้ · ส่วนชั้นในเป็นรายละเอียดของ Hibernate ซึ่งเปลี่ยนได้เมื่อเปลี่ยนตัวจัดการ persistence
ผู้แพ้ถูกทิ้ง ไม่ได้ถูกรวม
หัวข้อที่มีชื่อว่า “ผู้แพ้ถูกทิ้ง ไม่ได้ถูกรวม”ข้อที่คนคาดผิดบ่อยที่สุดเกี่ยวกับ optimistic locking
หลังจบทั้งลำดับ · note สุดท้ายคือ A แก้ และเลขรุ่นสุดท้ายคือ 1
สิ่งที่ B พิมพ์ลงไปไม่มีอยู่ในฐานข้อมูลเลยสักตัวอักษร · optimistic lockingoptimistic lockingวิธีที่ไม่จองอะไรไว้ล่วงหน้า แต่ตรวจตอนเขียนว่าของที่อ่านมายังเป็นตัวเดิมไหม · ผู้แพ้ได้ `ObjectOptimisticLockingFailureException` ที่ห่อ `StaleObjectStateException` ไว้ข้างใน ไม่ได้รวมการแก้ของสองฝั่งเข้าด้วยกัน และไม่เคยอ้างว่าจะทำ
สิ่งเดียวที่มันทำคือ เปลี่ยนความเงียบให้เป็นข้อผิดพลาด · จากเดิมที่งานของ A หายไปโดยไม่มีใครรู้ กลายเป็น B ได้รับแจ้งว่าเขียนไม่สำเร็จ
พอ B ได้ exception แล้ว ต้องมีคนเลือกว่าจะทำอะไรต่อ · ตัวเลือกมีอย่างน้อยสามทาง
- บอกผู้ใช้แล้วให้เริ่มใหม่ ตรงไปตรงมาที่สุด และผู้ใช้เสียงานที่พิมพ์ไป
- อ่านใหม่แล้วลองซ้ำอัตโนมัติ ใช้ได้เมื่อการแก้ไม่ขึ้นกับค่าเดิม · ถ้าขึ้นกับค่าเดิม การลองซ้ำจะเขียนทับของคนอื่นอย่างเงียบๆ ซึ่งย้อนกลับไปที่ปัญหาเดิม
- แสดงทั้งสองฉบับให้คนเลือก แพงที่สุดในการเขียน แต่เป็นทางเดียวที่ไม่ทิ้งงานของใคร
@Version วางปัญหาไว้ตรงหน้าคุณ · มันไม่ได้แก้ให้
เทียบกับบทที่ 6 · คนละปัญหา คนละกลไก
หัวข้อที่มีชื่อว่า “เทียบกับบทที่ 6 · คนละปัญหา คนละกลไก”สองบทนี้ดูคล้ายกันจนคนเอาเครื่องมือของบทหนึ่งไปใช้กับอีกบทหนึ่งบ่อยๆ
| บทที่ 6 | บทที่ 7 | |
|---|---|---|
| สิ่งที่ชนกัน | สองแถวใหม่ที่ไม่ควรอยู่ด้วยกัน | แถวเดิมใบเดียวที่มีสองคนแก้ |
| คนที่กัน | EXCLUDE ที่ฐานข้อมูล | @Version ที่ entity |
| สิ่งที่ได้ตอนล้ม | 55P03 หรือ 23P01 | exception ที่ซ้อนกันสองชั้น |
| กติกาอยู่ที่ไหน | ในนิยามของตาราง | ในคำสั่งเขียนแต่ละคำสั่ง |
@Version กันจองซ้อนไม่ได้ เพราะสองการจองที่ทับเวลากันเป็นสองแถวคนละใบ
เลขรุ่นของใครของมัน ไม่มีอะไรให้ชน
และ EXCLUDE กันการเขียนทับไม่ได้ เพราะการแก้แถวเดิมไม่ได้สร้างค่าที่ทับกับใคร
สิ่งที่บทนี้ยังไม่ทำ
หัวข้อที่มีชื่อว่า “สิ่งที่บทนี้ยังไม่ทำ”สามอย่างที่ยังไม่มี และสองในสามจะไม่มีในคอร์สนี้เลย
- ยังไม่ได้เขียนนโยบายการลองซ้ำ คอร์สนี้บอกว่าต้องเลือก แต่ไม่เลือกให้ เพราะคำตอบขึ้นกับว่าการแก้นั้นขึ้นกับค่าเดิมหรือไม่
- ไม่มี pessimistic locking การจองแถวไว้ก่อนเป็นอีกทางที่ทำได้จริง และมีราคาคนละแบบ · คอร์สนี้ไม่แตะ
- ไม่มีการรวมสองฉบับเข้าด้วยกัน เรื่องนั้นเป็นงานของโดเมน ไม่ใช่ของ ORM
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้เพิ่มกลไกที่สอง และบอกให้ชัดว่ามันไม่ทำอะไร
คุณมีช่อง version ที่เปลี่ยนการเขียนทับเงียบๆ ให้เป็น exception
· รู้แล้วว่ามันซ้อนกันสองชั้นและต้องดักชั้นไหน
· และรู้แล้วว่าผู้แพ้ถูกทิ้ง ซึ่งเป็นการตัดสินใจที่ยังรอคุณอยู่
บทหน้าเป็นบทสุดท้าย — เราจะกลับไปดูโดเมนที่เขียนไว้ตั้งแต่บทที่ 2 แล้ววัดว่าการทดสอบมันโดยไม่สตาร์ต Spring ถูกกว่าแค่ไหน และแลกอะไรไป
ชนิดของ exception ทั้งสองชั้น และค่าสุดท้ายในตาราง มาจากการรันจริง:
- วัดเมื่อ 2026-09-01 · Spring Boot 4.1.1 · Kotlin 2.4.10 · PostgreSQL 18.6 · ลำดับทุกขั้นกำหนดตายตัว ไม่มีการหน่วงเวลาและไม่มี thread แข่งกัน
- รันซ้ำเองได้ด้วย
npm run verify:clinic-clean-archซึ่งอ่านชื่อชนิดของ exception จากe::class.java.nameตรงๆ ไม่ได้อ่านจากข้อความ - Jakarta Persistence 3.2
(เข้าถึง 2026-09-01) —
@Versionและชนิดที่ใช้เป็นช่องเลขรุ่นได้ - Spring Framework — DataAccessException hierarchy (เข้าถึง 2026-09-01) — เหตุที่ Spring ห่อ exception ของตัวจัดการ persistence ไว้อีกชั้น
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3หลังจบทั้งลำดับ `note` สุดท้ายคือ `A แก้` และเลขรุ่นคือ 1 · นั่นแปลว่าอะไร