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

คำขอ​คืน 500 แล้ว​รายการ​ถัด​มา​คืน 3 แถว ขณะ​ที่​ฐาน​ข้อมูล​มี 2 แถว

บท​ที่ 5 จบ​ตรง​ที่ cache กับ​ฐาน​ข้อมูล เล่า​คนละ​เรื่อง​กัน เพราะ​ผู้​เรียก​แก้​ของ​ที่​ยื่น​ออก​ไป

บท​นี้​ไป​ถึง​เรื่อง​เดียวกัน​โดย​ไม่​ต้อง​มี​ใคร​แก้​อะไร​เลย มี​แค่​งาน​ชุด​หนึ่ง​ที่​ล้ม​กลาง​ทาง

งาน​ชุด​หนึ่ง​เขียน​แถว​ใหม่ ล้าง cache แล้ว​อ่าน​กลับ ทั้ง​สาม​อย่าง​อยู่​ใน transaction เดียว

ทรง​ที่​วัด​ใน​บท​นี้​ใส่​ผู้​เช่า​ลง​ใน​กุญแจ​ไป​แล้ว​ตาม​ทาง​แก้​ของ บท​ที่ 3 ดัง​นั้น​รู​ที่​กำลัง​จะ​เจอ ไม่ใช่​ความ​ผิด​ของ​กุญแจ

ตัว​ล้าง​ที่​ผูก​กับ​ผู้​เช่า
@CacheEvict("visits", key = "@currentTenant.id")
fun evict() {
}

evict() อยู่​ใน VisitService ตัว​เดียว​กับ visits() ตัว​ล้าง​กับ​ตัว​อ่าน​จึง​ชี้​ไป​ที่​กุญแจ สูตร​เดียวกัน

@CacheEvict@CacheEvictannotation ที่​สั่ง​ล้าง​รายการ​ใน cache · จังหวะ​ที่​มัน​ล้าง ไม่​ได้​ผูก​กับ​จังหวะ​ที่ transaction สำเร็จ ทั้ง​สอง​อย่าง​จึง​จบ​คนละ​เวลา​กัน​ได้ สั่ง​ล้าง​รายการ​ของ​กุญแจ​ใบ​นั้น​ออก​จาก cache ส่วน @Transactional@Transactionalannotation ที่​คลุม​งาน​หนึ่ง​ชุด​ไว้​ใน transaction เดียว · เดิน​ผ่าน proxy ตัว​เดียว​กับ​ที่ cache ใช้ แต่​ไม่​ได้​ใช้​จังหวะ​จบ​ร่วม​กัน​กับ​มัน ข้าง​ล่าง​คลุม​งาน​ทั้ง​ชุด​ไว้​ใน transaction เดียว ทั้ง​คู่​ทำงาน​ผ่าน proxy และ​อยู่​คนละ bean กัน คำ​เรียก svc.evict() ข้าง​ล่าง​จึง​ไป​ถึง​ชั้น​นั้น​จริง ไม่ใช่​คำ​เรียก​เพื่อน​บ้าน​แบบ บท​ที่ 4

❌ version ดิบ · เขียน ล้าง อ่าน แล้ว​ล้ม​ทั้ง​ชุด
@Transactional
fun addThenReadThenFail(patient: String) {
repo.save(Visit(tenantId = tenant.id, patient = patient))
svc.evict()
svc.visits()
throw IllegalStateException("kaen-rollback")
}

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

exception วิ่ง​ออก​จาก transaction ฐาน​ข้อมูล​จึง​ย้อน​แถว​ที่​เพิ่ง​เขียน​กลับ​ไป

IllegalStateException วิ่ง​ทะลุ​ออก​จาก method ที่​ติด @Transactional ผล​คือ rollbackrollbackการ​ยกเลิก​งาน​ทั้ง​ชุด​เมื่อ transaction ล้ม · ฐาน​ข้อมูล​ย้อน​กลับ​ได้ แต่​สิ่ง​ที่​ถูก​อ่าน​เข้า cache ระหว่าง​ทาง​ไม่​ย้อน​ตาม · วัด​แล้ว​ว่า​คำขอ​คืน 500 แล้ว​รายการ​ถัด​มา​คืน 3 แถว ขณะ​ที่​ฐาน​ข้อมูล​มี 2 แถว งาน​ทั้ง​ชุด และ​คำขอ​นั้น​คืน​รหัส 500 ให้​ผู้​เรียก

ตรง​นี้​ยัง​ไม่มี​อะไร​น่า​แปลก รหัส 500 คือ​สิ่ง​ที่​ควร​ได้ และ​แถว​ที่​เขียน​ไป​ควร​หาย​ไป​พร้อม​กัน

ยิง /visits ต่อ​จาก​คำขอ​ที่​ล้ม แล้ว​เทียบ​กับ​การ​ถาม​ฐาน​ข้อ​มูล​ตรงๆ

ช่อง​ที่​วัดผล​ที่​ได้
รหัส​ของ​คำขอ​ที่​ล้ม500
/visits ของ clinic-a หลัง​จาก​นั้น3 แถว รวม ผีไม่มี transaction
ถาม​ฐาน​ข้อ​มูล​ตรงๆ ว่า​มี​แถว​ของ clinic-a กี่​แถว2 แถว

แถว​ชื่อ ผีไม่มี transaction ไม่​เคย​มี​อยู่​ใน​ฐาน​ข้อมูล ไม่มี transaction ไหน commit มัน แต่​ผู้​เรียก​ที่​ถาม​ผ่าน​แอป​ได้​มัน​กลับ​ไป

คำ​ตอบ 3 แถว​ไม่​ได้​มา​จาก​การ​อ่าน​ผิด มัน​มา​จาก cache ล้วน โดย​ไม่มี​คำถาม​ไป​ถึง​ฐาน​ข้อมูล​เลย

ตัว​นับ query ขยับ​หนึ่ง​ครั้ง​จาก​การ​อ่าน​ที่​เกิด​ข้าง​ใน transaction ที่​ล้ม แล้ว​หยุด​นิ่ง คำขอ​ที่​ยิง /visits หลัง rollback ไม่​ทำให้​มัน​ขยับ​อีก ฐาน​ข้อมูล​ไม่​ถูก​ถาม​เพิ่ม สิ่ง​ที่​ตอบ​ผู้​เรียก​คือ​ช่อง​ใน cache ทั้ง​ช่อง

flowchart TB
  subgraph TX["ข้างใน transaction ที่ยังไม่ commit"]
    W["save · แถว ผีไม่มี transaction"] --> E["evict · ล้างช่องของ clinic-a"]
    E --> R["อ่านใหม่ · ได้ 3 แถว"]
    R --> S["เขียน 3 แถวลงช่องของ clinic-a ใน cache"]
    S --> X["throw · rollback ทั้งชุด"]
  end
  X --> DB[("ฐานข้อมูล · ย้อนกลับเหลือ 2 แถวของ clinic-a")]
  X --> C["cache · ยังถือ 3 แถว"]
  C --> N["คำขอถัดไป · ได้ 3 แถว โดยไม่ถามฐานข้อมูล"]

คำ​บรรยาย​ภาพ: การ​อ่าน​ที่​เกิด​ข้าง​ใน transaction เขียน​ผล​ลง​ช่อง​ของ cache ตอน​ที่ method คืน​ค่า พอ rollback มา​ถึง ฐาน​ข้อมูล​ย้อน​กลับ​ได้ ส่วน​ช่อง​นั้น​ไม่มี​ใคร​สั่ง​ให้​ย้อน​ตาม

ConcurrentMapCacheManager ไม่มี​อะไร​ผูก​กับ​จังหวะ commit มัน​เขียน​ลง​ช่อง​ตอน​ที่ method คืน​ค่า

ConcurrentMapCacheManager เก็บ​ค่า​ไว้​ใน​หน่วย​ความ​จำ​ของ process เดียว มัน​ไม่​ได้​ลง​ทะเบียน​อะไร​ไว้​กับ transaction manager และ​ไม่มี​จังหวะ​จบ​ร่วม​กับ​ฐาน​ข้อมูล

ผล​คือ​ช่อง​ใน cache กับ​แถว​ใน​ฐาน​ข้อมูล​จบ​คนละ​จังหวะ​กัน การ​เขียน​ลง​ช่อง​เกิด​ตอน​ที่ method คืน​ค่า ส่วน​แถว​จะ​จริง​หรือ​ไม่​จริง​ขึ้น​กับ commit

การ​อ่าน​ที่​เกิด​ข้าง​ใน transaction ที่​ยัง​ไม่ commit จึง​เขียน​ผล​ลง​ช่อง​ที่​คน​ข้าง​นอก​อ่าน​ได้ คน​ข้าง​นอก​ไม่​ได้​อยู่​ใน transaction นั้น และ​ไม่มี​ทาง​รู้​ว่า​ค่าที่​ได้​มายัง​ไม่​ผ่าน​การ commit

สวิตช์​นั้น​เลือก​ว่า​จะ​ล้าง​ก่อน​หรือ​หลัง​ตัว​งาน ส่วน​สิ่ง​ที่​พัง​คือ​การ​เติม​กลับ

เอกสาร​ของ Spring อธิบาย​สวิตช์ beforeInvocation ของ @CacheEvict ไว้ มัน​ตัดสิน​ว่า การ​ล้าง​เกิด​ก่อน​ตัว​งาน​หรือ​หลัง​ตัว​งาน ซึ่ง​สำคัญ​จริง​เมื่อ​ตัว​งาน​อาจ​โยน exception

แต่​ใน​ทรง​นี้​การ​ล้าง​ไม่​ได้​เป็น​ปัญหา​สัก​ทาง เลือก​ทาง​ไหน​ช่อง​ก็​ถูก​ล้าง​ไป​แล้ว​ทั้ง​คู่

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

ผูก​การ​เติม​กลับ​ไว้​กับ commit ได้ แต่​ต้อง​ยอม​ให้​ชั้น​นี้​เลิก​โปร่งใส

ทาง​ที่​ปิด​รู​นี้​คือ​ไม่​ให้การ​เติม​กลับ​เกิด​ก่อน commit สรุป​เป็น​ทาง​เลือก​ได้​สาม​ทาง

  • ไม่​อ่าน​ผ่าน​ชั้น​ที่​จำ​คำ​ตอบ​เลย​ขณะ​อยู่​ใน transaction ที่​เขียน​ข้อมูล
  • ย้าย​การ​ล้าง​และ​การ​เติม​กลับ​ออก​ไป​อยู่​หลัง​จุด​ที่ transaction สำเร็จ​แล้ว
  • ใช้​ตัว​จัดการ cache ที่​ลง​ทะเบียน​งาน​ของ​ตัว​เอง​ไว้​กับ transaction manager

ราคา​ที่​ทั้ง​สาม​ทาง​จ่าย​เหมือน​กัน​คือ ผู้​เขียน​งาน​ต้อง​รู้​ว่า​ตัว​เอง​อยู่​ใน transaction หรือ​ไม่ ชั้น​ที่​เคย​ติด​ได้​ด้วย annotation เดียว กลาย​เป็น​ชั้น​ที่​ต้อง​ออกแบบ​ร่วม​กับ​ขอบเขต​ของ transaction

ราคา​ข้อ​ที่​สอง​คือ​ช่วง​หลัง commit ที่​ช่อง​ยัง​ว่าง​อยู่ ผู้​เรียก​คน​แรก​หลัง​จาก​นั้น​ต้อง​ลง​ไป​ถาม ฐาน​ข้อมูล​เอง ซึ่ง​เป็น​ราคา​ที่​ยอม​จ่าย​ได้​ง่าย​กว่า​การ​คืน​แถว​ที่​ไม่​เคย​มี​อยู่

รู​ที่​วัด​ได้​สาม​บท​ติด​กัน ล้วน​มอง​ไม่​เห็น​จาก​รหัส​ของ​คำ​ตอบ

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

คำถาม​ถัด​ไป​คือ​ด่าน​ของ​ชุด​เทสต์ ทรง​ที่​ทำให้​ด่าน​ของ​ทีม​แดง​จริง​คือ​ทรง cached ของ​บท​ที่ 1 ซึ่ง​กุญแจ​ยัง​ไม่​นับ​ผู้​เช่า บท​ที่ 7 เล่า​เรื่อง​นั้น​ให้​จบ​ทั้ง​เรื่อง ตั้งแต่​ตอน​ที่​ด่าน​แดง จนถึง​บรรทัด​เดียว​ที่​ทำให้​มัน​กลับ​มา​เขียว​โดยที่​รู​ยัง​อยู่​ครบ


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Spring Framework — Annotation-based Caching (เข้า​ถึง 2026-09-04) — ที่มา​ของ​สวิตช์ beforeInvocation บน @CacheEvict

  • ตัวเลข​ทุก​ตัว​ใน​บท​นี้​มา​จาก scripts/clinic-cache/probe.py ซึ่ง​ยิง​คำขอ​ที่​ล้ม แล้ว​ถาม​ฐาน​ข้อ​มูล​ตรงๆ อีก​ครั้ง​เพื่อ​เทียบ · ต้อง​สั่ง scripts/clinic-cache/up.sh ก่อน ซึ่ง​ยก container ของ​คอร์ส​นี้​เอง​ขึ้น​ที่ port 55436

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

ข้อ 1 / 3

หลังคำขอที่คืนรหัส 500 ยิง `/visits` ของ clinic-a ต่อ ได้อะไรกลับมา