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

ผู้​เรียก​แก้​ชื่อ​บน​ของ​ที่​ได้​มา แล้ว​คน​ถัด​ไป​ได้ REDACTED

บท​ที่ 3 ปิด​รู​ข้าม​ผู้​เช่า​ด้วย​การ​ใส่​ผู้​เช่า​ลง​ใน​กุญแจ ทรง​ที่​บท​นี้​วัด​จึง​มี​กุญแจ​ที่​นับ​ผู้​เช่า​แล้ว

รู​ของ​บท​นี้​ไม่​ได้​อยู่​ที่​กุญแจ มัน​อยู่​ที่​ตัว​ของ​ที่ cache ถือ​อยู่ และ​ผู้​เรียก​คน​แรก​เป็น​คน​แก้​มัน​เอง

กุญแจ​มี​ผู้​เช่า​อยู่​ใน​นั้น​แล้ว ทรง​นี้​จึง​ไม่มี​รู​ข้าม​ผู้​เช่า​แบบ​บท​ที่ 1 ให้​ดู

VisitService — เท่ากับ​ทรง​ที่​บท​ที่ 3 ปิด​รู​ไว้
@Service
class 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 อ่าน​ขึ้น​มา

entity ที่​เดิน​ออก​จาก​ชั้น service ไป​ถึง​ผู้​เรียก
@Entity
class Visit(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null,
var tenantId: String = "",
var patient: String = "",
)

ทุก​ช่อง​ของ​มัน​ประกาศ​ด้วย var ผู้​เรียก​ที่​ถือ​มัน​อยู่​จึง​เขียน​ทับ​ได้​ทุก​ช่อง

คำขอ​ที่​แก้​ชื่อ แล้ว​ไม่​เขียน​ลง​ฐาน​ข้อมูล​เลย

หัวข้อ​ที่​มีชื่อ​ว่า “คำขอ​ที่​แก้​ชื่อ แล้ว​ไม่​เขียน​ลง​ฐาน​ข้อมูล​เลย”

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

❌ version ดิบ — ปลาย​ทาง​ที่​ผู้​เรียก​แก้​ของ​ที่​ได้​มา
/** แก้ชื่อบนอ็อบเจ็กต์ที่ได้มา · ไม่ได้เขียนลงฐานข้อมูลเลย */
@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 งาน​ทั้งหมด​ที่​เกิด​ขึ้น​คือ​การ​เขียน​ค่า​ลง​ช่อง​ของ​อ็อบเจ็กต์​ที่​รับ​มา

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

ช่อง​ที่​วัดก่อน​คำขอ​ที่​แก้​ชื่อหลัง​คำขอ​ที่​แก้​ชื่อ
clinic-a เห็นพิมพ์​ใจ, สมชายREDACTED, สมชาย
ฐาน​ข้อมูล​ถูก​ถาม33
แถว​แรก​ใน​ฐาน​ข้อมูลพิมพ์​ใจพิมพ์​ใจ

คำขอ​ที่​ได้ REDACTED กลับ​ไป​คือ cache hitcache hitการ​ที่​กุญแจ​ของ​คำ​เรียก​มี​อยู่​แล้ว จึง​ได้​ค่าที่​เก็บ​ไว้​กลับ​ไป​โดย​ไม่​ถึง​ฐาน​ข้อมูล · hit ที่​ได้​กุญแจ​ผิด คือ hit ที่​คืนคำ​ตอบ​ของ​คน​อื่น​ด้วย​รหัส​สำเร็จ ที่​ทำงาน​ถูก​ต้อง​ทุก​ขั้น​ตอน มัน​หา​กุญแจ​ของ clinic-a เจอ แล้ว​คืน​ค่าที่​เก็บ​ไว้​ให้​ตาม​หน้าที่

ค่าที่​มัน​คืน คือ​อ็อบเจ็กต์​ตัว​เดียว​กับ​ที่​ผู้​เรียก​คน​ก่อน​ถือ​อยู่​ใน​มือ​ตอน​เขียน​คำ​ว่า REDACTED ลงไป

ตัวเลข 3 ทั้ง​ก่อน​และ​หลัง แปล​ว่า​ไม่มี​คำขอ​ไหน​เดิน​ลง​ไป​เห็น​ค่า​จริง

ก่อน​คำขอ​ที่​แก้​ชื่อ ฐาน​ข้อมูล​ถูก​ถาม 3 ครั้ง หลัง​จาก​นั้น​ยัง​เป็น 3 ครั้ง เท่า​เดิม ตัว​นับ​นี้​เป็น​ยอด​สะสม​ของ​ทั้ง​รอบ และ​ค้าง​อยู่​ที่ 3 มา​ตั้งแต่​บท​ที่ 4

นี่​คือ​สิ่ง​ที่​ทำให้​ข้อ​นี้​อยู่​ได้​โดย​ไม่มี​ใคร​สังเกต ไม่มี​คำขอ​ไหน​เดิน​ไป​ถึง​ฐาน​ข้อมูล​เพื่อ​เห็น​ว่า​ค่า​จริง​ไม่​ตรง และ​ฐาน​ข้อมูล​ก็​ไม่มี​อะไร​ผิด​ให้​เห็น เพราะ​ไม่มี​ใคร​เขียน​อะไร​ลง​ไป

ใน​ฐาน​ข้อมูล​แถว​แรก​ยัง​เป็น พิมพ์​ใจ ส่วน​สิ่ง​ที่​ผู้​เรียก​ได้​รับ​คือ 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 ไป​ด้วย ทั้ง​ที่​ไม่มี​การ​เขียน​ลง​ฐาน​ข้อมูล​สัก​ครั้ง ตอน​คืน​ค่า​เลย เพราะ​ไม่มี​ช่อง​ไหน​ให้​ผู้​เรียก​เขียน​ทับ​ตั้งแต่​ต้น

รูป​ของ​ทาง​แก้ · รูป​นี้​ไม่​ได้​อยู่​ใน probe ของ​คอร์ส จึง​ไม่มี​ตัวเลข​กำกับ
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 ได้อะไรกลับไป