ผู้เรียกแก้ชื่อบนของที่ได้มา แล้วคนถัดไปได้ REDACTED
บทที่ 3 ปิดรูข้ามผู้เช่าด้วยการใส่ผู้เช่าลงในกุญแจ ทรงที่บทนี้วัดจึงมีกุญแจที่นับผู้เช่าแล้ว
รูของบทนี้ไม่ได้อยู่ที่กุญแจ มันอยู่ที่ตัวของที่ cache ถืออยู่ และผู้เรียกคนแรกเป็นคนแก้มันเอง
ทรงที่บทนี้วัด
หัวข้อที่มีชื่อว่า “ทรงที่บทนี้วัด”กุญแจมีผู้เช่าอยู่ในนั้นแล้ว ทรงนี้จึงไม่มีรูข้ามผู้เช่าแบบบทที่ 1 ให้ดู
@Serviceclass VisitService(private val repo: VisitRepo, private val tenant: CurrentTenant) {
@Cacheable("visits", key = "@currentTenant.id") fun visits(): List<Visit> = repo.findByTenantId(tenant.id)}สิ่งที่ visits() คืนออกมาคือ List<Visit> และ Visit คือ entity ของ JPA ตัวเดียวกับที่ repository อ่านขึ้นมา
@Entityclass Visit( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null, var tenantId: String = "", var patient: String = "",)ทุกช่องของมันประกาศด้วย var ผู้เรียกที่ถือมันอยู่จึงเขียนทับได้ทุกช่อง
คำขอที่แก้ชื่อ แล้วไม่เขียนลงฐานข้อมูลเลย
หัวข้อที่มีชื่อว่า “คำขอที่แก้ชื่อ แล้วไม่เขียนลงฐานข้อมูลเลย”ปลายทางนี้ดึงรายการมาหนึ่งชุด แก้ชื่อแถวแรก แล้วส่งคืน ไม่มีบรรทัดไหนแตะ repository
/** แก้ชื่อบนอ็อบเจ็กต์ที่ได้มา · ไม่ได้เขียนลงฐานข้อมูลเลย */ @GetMapping("/visits/redact") fun redact(): Map<String, Any> { val rows = svc.visits() rows.firstOrNull()?.patient = "REDACTED" return shape(rows) }หัวใจอยู่ที่สองบรรทัดกลาง
val rows = svc.visits()rows.firstOrNull()?.patient = "REDACTED"ไม่มี save ไม่มี flush ไม่มี @Transactional งานทั้งหมดที่เกิดขึ้นคือการเขียนค่าลงช่องของอ็อบเจ็กต์ที่รับมา
ผู้เรียกคนถัดไปได้ REDACTED กับ สมชาย
หัวข้อที่มีชื่อว่า “ผู้เรียกคนถัดไปได้ REDACTED กับ สมชาย”คำขอถัดมาเป็นคำขอปกติของผู้เช่ารายเดิม และได้ชื่อที่ถูกแก้กลับไป
| ช่องที่วัด | ก่อนคำขอที่แก้ชื่อ | หลังคำขอที่แก้ชื่อ |
|---|---|---|
clinic-a เห็น | พิมพ์ใจ, สมชาย | REDACTED, สมชาย |
| ฐานข้อมูลถูกถาม | 3 | 3 |
| แถวแรกในฐานข้อมูล | พิมพ์ใจ | พิมพ์ใจ |
คำขอที่ได้ REDACTED กลับไปคือ cache hitcache hitการที่กุญแจของคำเรียกมีอยู่แล้ว จึงได้ค่าที่เก็บไว้กลับไปโดยไม่ถึงฐานข้อมูล · hit ที่ได้กุญแจผิด คือ hit ที่คืนคำตอบของคนอื่นด้วยรหัสสำเร็จ ที่ทำงานถูกต้องทุกขั้นตอน
มันหากุญแจของ clinic-a เจอ แล้วคืนค่าที่เก็บไว้ให้ตามหน้าที่
ค่าที่มันคืน คืออ็อบเจ็กต์ตัวเดียวกับที่ผู้เรียกคนก่อนถืออยู่ในมือตอนเขียนคำว่า REDACTED ลงไป
ฐานข้อมูลไม่ถูกถามเพิ่มเลย
หัวข้อที่มีชื่อว่า “ฐานข้อมูลไม่ถูกถามเพิ่มเลย”ตัวเลข 3 ทั้งก่อนและหลัง แปลว่าไม่มีคำขอไหนเดินลงไปเห็นค่าจริง
ก่อนคำขอที่แก้ชื่อ ฐานข้อมูลถูกถาม 3 ครั้ง หลังจากนั้นยังเป็น 3 ครั้ง เท่าเดิม ตัวนับนี้เป็นยอดสะสมของทั้งรอบ และค้างอยู่ที่ 3 มาตั้งแต่บทที่ 4
นี่คือสิ่งที่ทำให้ข้อนี้อยู่ได้โดยไม่มีใครสังเกต ไม่มีคำขอไหนเดินไปถึงฐานข้อมูลเพื่อเห็นว่าค่าจริงไม่ตรง และฐานข้อมูลก็ไม่มีอะไรผิดให้เห็น เพราะไม่มีใครเขียนอะไรลงไป
cache กับฐานข้อมูลเล่าคนละเรื่อง
หัวข้อที่มีชื่อว่า “cache กับฐานข้อมูลเล่าคนละเรื่อง”ในฐานข้อมูลแถวแรกยังเป็น พิมพ์ใจ ส่วนสิ่งที่ผู้เรียกได้รับคือ REDACTED
flowchart TB
C1["คำขอที่แก้ชื่อ · /visits/redact"] --> H["cache ชื่อ visits · กุญแจ clinic-a"]
H --> O["อ็อบเจ็กต์ Visit ตัวที่ cache ถืออยู่"]
C1 -.->|"เขียน REDACTED ทับบนตัวที่ได้มา"| O
C2["คำขอถัดไป · /visits ตามปกติ"] --> H
H -->|"cache hit · คืนตัวเดิมใบเดิม"| R["REDACTED, สมชาย"]
O -.->|"เป็นตัวเดียวกัน ไม่ใช่สำเนา"| R
H -.->|"ไม่มีคำขอไหนเดินต่อไปทางนี้"| DB[("ฐานข้อมูล · แถวแรกยังเป็น พิมพ์ใจ")]
คำบรรยายภาพ: คำขอที่แก้ชื่อเขียนทับบนอ็อบเจ็กต์ที่ cache ถืออยู่ คำขอถัดไปจึงได้ตัวเดิมใบเดิมกลับไป ขณะที่ฐานข้อมูลยังถือชื่อเดิมและไม่ถูกถามอีกเลย
สิ่งที่ผู้เรียกได้รับคือ stale valuestale valueค่าที่ยังอยู่ใน cache ทั้งที่ของจริงเปลี่ยนไปแล้ว หรือไม่เคยเป็นจริงเลย · วัดแล้วว่าผู้เรียกคนถัดไปได้ชื่อ REDACTED ขณะที่ในฐานข้อมูลยังเป็นชื่อเดิม แบบที่หนักกว่าค่าเก่า เพราะค่านี้ไม่เคยเป็นจริงในฐานข้อมูลสักครั้ง
ทำไมถึงเป็นอย่างนั้น และขอบเขตของข้ออ้างนี้
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นอย่างนั้น และขอบเขตของข้ออ้างนี้”
ConcurrentMapCacheManagerเก็บอ็อบเจ็กต์ตัวเดียวกับที่ยื่นออกไป ไม่ใช่สำเนาของมัน
ConcurrentMapCacheManager ใช้ ConcurrentHashMap เป็นที่เก็บ ตามที่เอกสารของ Boot ระบุไว้
สิ่งที่มันเก็บลงไปคือการอ้างถึงอ็อบเจ็กต์ตัวนั้น ไม่ใช่การคัดลอกเนื้อของมันออกมาไว้ต่างหาก
ข้ออ้างข้อนี้วัดบน ConcurrentMapCacheManager ซึ่งเป็นตัวที่ได้มาโดยไม่ต้องกำหนดอะไรเอง
อย่าขยายมันไปครอบ cache ทุกชนิด
cache ที่ serialize ค่าก่อนเก็บ จะคืนของที่ประกอบขึ้นใหม่ให้ผู้เรียกแต่ละราย นั่นคือสำเนา และการแก้ค่าที่ได้มาก็จะไม่ไปถึงของที่เก็บไว้ แต่คอร์สนี้ไม่ได้วัดทรงนั้น จึงไม่มีตัวเลขของมันมาอ้าง
ราคาของทางแก้
หัวข้อที่มีชื่อว่า “ราคาของทางแก้”ทางที่ปิดรูนี้คือเลิกคืน entity ออกจากชั้น service และราคาของมันคือของที่ต้องเพิ่มขึ้นมา
การคืน entity ออกจากชั้น service แปลว่ายื่นสิทธิ์แก้ของกลางให้ผู้เรียกไปด้วย
ผู้เรียกไม่ได้ทำอะไรผิดกฎเลย มันแก้ช่องที่ประกาศเป็น var บนอ็อบเจ็กต์ที่ตัวเองได้รับมาเท่านั้น
ทางที่ปิดคือคืนค่าที่แก้ไม่ได้ ซึ่งทำให้ไม่ต้องพึ่ง defensive copydefensive copyการคืนสำเนาแทนตัวอ็อบเจ็กต์ที่เก็บไว้ · ถ้าไม่ทำ ผู้เรียกที่แก้ค่าที่ได้ไป จะแก้ของที่อยู่ใน cache ไปด้วย ทั้งที่ไม่มีการเขียนลงฐานข้อมูลสักครั้ง ตอนคืนค่าเลย เพราะไม่มีช่องไหนให้ผู้เรียกเขียนทับตั้งแต่ต้น
data class VisitView(val patient: String)
@Cacheable("visits", key = "@currentTenant.id")fun visits(): List<VisitView> = repo.findByTenantId(tenant.id).map { VisitView(it.patient) }ราคาที่จ่ายมีสามข้อ
- ชั้น service ต้องมี type เพิ่มขึ้นมาอีกหนึ่งตัว พร้อมงานแปลงจาก entity ไปเป็นตัวนั้น
- ผู้เรียกที่เคยต้องการ entity จริงเพื่อเขียนต่อ ต้องหาทางอื่นไปเอามันมา
- ทุกช่องที่ผู้เรียกจะได้เห็น ต้องถูกเลือกด้วยมือ ช่องที่ลืมใส่จะหายไปเงียบๆ
ราคานี้จ่ายที่ชั้น service ครั้งเดียว ส่วนราคาของการไม่จ่าย คือผู้เรียกทุกรายที่มาทีหลังได้ค่าที่ผู้เรียกคนก่อนเขียนทับไว้
สิ่งที่บทนี้ส่งต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่บทนี้ส่งต่อ”บทนี้ทำให้ cache กับฐานข้อมูลไม่ตรงกันด้วยการแก้ของ บทถัดไปทำให้ไม่ตรงกันโดยไม่มีใครแก้อะไรเลย
ในบทนี้มีคนเขียนค่าใหม่ลงไปจริง แม้จะไม่ได้เขียนลงฐานข้อมูล บทที่ 6 วัดทรงที่หนักกว่านั้น คือ cache ถือแถวที่ไม่มี transaction ไหน commit เลย
-
Spring Boot — Caching (เข้าถึง 2026-09-04) — ที่มาของข้อที่ว่าเมื่อไม่เจอ provider อื่น ตัวที่ได้คือ implementation ที่ใช้
ConcurrentHashMapเป็นที่เก็บ -
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/clinic-cache/probe.py· สั่งscripts/clinic-cache/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55436 แล้วจึงรัน probe
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ปลายทาง `/visits/redact` แก้ชื่อบนอ็อบเจ็กต์ที่ได้มา แล้วไม่เขียนลงฐานข้อมูลเลย ผู้เรียกคนถัดไปของ clinic-a ได้อะไรกลับไป