คำขอคืน 500 แล้วรายการถัดมาคืน 3 แถว ขณะที่ฐานข้อมูลมี 2 แถว
บทที่ 5 จบตรงที่ cache กับฐานข้อมูล เล่าคนละเรื่องกัน เพราะผู้เรียกแก้ของที่ยื่นออกไป
บทนี้ไปถึงเรื่องเดียวกันโดยไม่ต้องมีใครแก้อะไรเลย มีแค่งานชุดหนึ่งที่ล้มกลางทาง
รูปที่ code จริงเขียนกัน
หัวข้อที่มีชื่อว่า “รูปที่ code จริงเขียนกัน”งานชุดหนึ่งเขียนแถวใหม่ ล้าง 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
@Transactionalfun addThenReadThenFail(patient: String) { repo.save(Visit(tenantId = tenant.id, patient = patient)) svc.evict() svc.visits() throw IllegalStateException("kaen-rollback")}บรรทัดที่สามคือบรรทัดที่คนเขียนเพิ่มด้วยเจตนาดี งานเพิ่งแก้ข้อมูล จึงอ่านกลับมาดูให้แน่ใจ ก่อนทำงานต่อ
คำขอคืน 500 และงานทั้งชุดถูกยกเลิก
หัวข้อที่มีชื่อว่า “คำขอคืน 500 และงานทั้งชุดถูกยกเลิก”exception วิ่งออกจาก transaction ฐานข้อมูลจึงย้อนแถวที่เพิ่งเขียนกลับไป
IllegalStateException วิ่งทะลุออกจาก method ที่ติด @Transactional ผลคือ rollbackrollbackการยกเลิกงานทั้งชุดเมื่อ transaction ล้ม · ฐานข้อมูลย้อนกลับได้ แต่สิ่งที่ถูกอ่านเข้า cache ระหว่างทางไม่ย้อนตาม · วัดแล้วว่าคำขอคืน 500 แล้วรายการถัดมาคืน 3 แถว ขณะที่ฐานข้อมูลมี 2 แถว
งานทั้งชุด และคำขอนั้นคืนรหัส 500 ให้ผู้เรียก
ตรงนี้ยังไม่มีอะไรน่าแปลก รหัส 500 คือสิ่งที่ควรได้ และแถวที่เขียนไปควรหายไปพร้อมกัน
รายการถัดมามี 3 แถว ฐานข้อมูลมี 2
หัวข้อที่มีชื่อว่า “รายการถัดมามี 3 แถว ฐานข้อมูลมี 2”ยิง
/visitsต่อจากคำขอที่ล้ม แล้วเทียบกับการถามฐานข้อมูลตรงๆ
| ช่องที่วัด | ผลที่ได้ |
|---|---|
| รหัสของคำขอที่ล้ม | 500 |
/visits ของ clinic-a หลังจากนั้น | 3 แถว รวม ผีไม่มี transaction |
ถามฐานข้อมูลตรงๆ ว่ามีแถวของ clinic-a กี่แถว | 2 แถว |
แถวชื่อ ผีไม่มี transaction ไม่เคยมีอยู่ในฐานข้อมูล ไม่มี transaction ไหน commit มัน
แต่ผู้เรียกที่ถามผ่านแอปได้มันกลับไป
ฐานข้อมูลไม่ถูกถามอีกหลัง rollback
หัวข้อที่มีชื่อว่า “ฐานข้อมูลไม่ถูกถามอีกหลัง rollback”คำตอบ 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 มาถึง ฐานข้อมูลย้อนกลับได้ ส่วนช่องนั้นไม่มีใครสั่งให้ย้อนตาม
ชั้นที่จำคำตอบไม่รู้จัก transaction
หัวข้อที่มีชื่อว่า “ชั้นที่จำคำตอบไม่รู้จัก transaction”
ConcurrentMapCacheManagerไม่มีอะไรผูกกับจังหวะ commit มันเขียนลงช่องตอนที่ method คืนค่า
ConcurrentMapCacheManager เก็บค่าไว้ในหน่วยความจำของ process
เดียว มันไม่ได้ลงทะเบียนอะไรไว้กับ transaction manager และไม่มีจังหวะจบร่วมกับฐานข้อมูล
ผลคือช่องใน cache กับแถวในฐานข้อมูลจบคนละจังหวะกัน การเขียนลงช่องเกิดตอนที่ method คืนค่า ส่วนแถวจะจริงหรือไม่จริงขึ้นกับ commit
การอ่านที่เกิดข้างใน transaction ที่ยังไม่ commit จึงเขียนผลลงช่องที่คนข้างนอกอ่านได้ คนข้างนอกไม่ได้อยู่ใน transaction นั้น และไม่มีทางรู้ว่าค่าที่ได้มายังไม่ผ่านการ commit
beforeInvocation ไม่ได้ตอบข้อนี้
หัวข้อที่มีชื่อว่า “beforeInvocation ไม่ได้ตอบข้อนี้”สวิตช์นั้นเลือกว่าจะล้างก่อนหรือหลังตัวงาน ส่วนสิ่งที่พังคือการเติมกลับ
เอกสารของ 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 ต่อ ได้อะไรกลับมา