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

สอง​คน​แก้​แถว​เดียวกัน · ผู้​แพ้​ถูก​ทิ้ง ไม่​ได้​ถูก​รวม

บท​ที่​แล้ว​เป็น​เรื่อง​ของสอง​แถว​ใหม่​ที่​ไม่​ควร​อยู่​ด้วย​กัน

บท​นี้​เป็น​ปัญหา​พี่​น้อง​ของ​มัน — แถว​เดิม​ใบ​เดียว ที่​มี​สอง​คน​แก้​พร้อม​กัน · คราว​นี้​ไม่มี​อะไร​ให้​ชน​กัน มี​แต่​ของ​ที่​ถูก​เขียน​ทับ

ไม่มี constraint ไหน​ถูก​ละเมิด ไม่มี exception ถูก​โยน และ​ผลลัพธ์​ก็​ผิด

A กับ B เปิด​นัด​ใบ​เดียวกัน​ขึ้น​มา​แก้ · A เปลี่ยน​หมายเหตุ​แล้ว​บันทึก · B เปลี่ยน​หมายเหตุ​แล้ว​บันทึก​ตาม

ถ้า​ไม่มี​ใคร​กัน สิ่ง​ที่ A เขียน​จะ​หาย​ไป​เงียบๆ · ฐาน​ข้อมูล​ไม่​ได้​ทำ​อะไร​ผิด​เลย มัน​ทำ​ตาม​คำ​สั่ง​สอง​คำ​สั่ง​ตาม​ลำดับ · นี่​คือ lost updatelost updateการ​ที่​งาน​เขียน​ของ​คน​หนึ่ง​หาย​ไป​เพราะ​อีก​คน​เขียน​ทับ · สิ่ง​ที่ `@Version` ทำ​คือ​เปลี่ยน​ความ​เงียบ​นั้น​ให้​เป็น​ข้อ​ผิด​พลาด ไม่ใช่​รวม​ของ​สอง​ฝั่ง​เข้า​ด้วย​กัน

ช่อง​เดียว​ใน​ตาราง ที่​เปลี่ยน​คำ​สั่ง UPDATE ทุก​คำ​สั่ง​ที่​วิ่ง​ผ่าน entity ตัว​นี้

app/src/main/kotlin/clinic/app/App.kt
@Entity
data 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 แข่ง​กัน

app/src/main/kotlin/clinic/app/App.kt
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 อ่าน = 0
version ที่ B อ่าน = 0
A เขียนสำเร็จ
B ล้ม
version สุดท้าย = 1
note สุดท้าย = 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 หลุด​ออก​มา

วิธี​อ่าน​ชั้น​ของ exception ให้​ถูก คือ​ไล่ `cause` จน​สุด

บรรทัด​ที่​ใช้​ใน​การ​วัด​ข้าง​บน​ไล่ 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บท​ที่ 7
สิ่ง​ที่​ชน​กันสอง​แถว​ใหม่​ที่​ไม่​ควร​อยู่​ด้วย​กันแถว​เดิม​ใบ​เดียว​ที่​มี​สอง​คน​แก้
คน​ที่​กันEXCLUDE ที่​ฐาน​ข้อมูล@Version ที่ entity
สิ่ง​ที่​ได้​ตอน​ล้ม55P03 หรือ 23P01exception ที่​ซ้อน​กัน​สอง​ชั้น
กติกา​อยู่​ที่ไหนใน​นิยาม​ของ​ตารางใน​คำ​สั่ง​เขียน​แต่ละ​คำ​สั่ง

@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 · นั่นแปลว่าอะไร