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

สาม​คำขอ ฐาน​ข้อมูล​ถูก​ถาม​ครั้ง​เดียว แล้ว clinic-b ได้​แถว​ของ clinic-a

คอร์ส​คัด​กรอง​ผู้​เรียก​ของ​คลินิก จบ​ด้วย​กฎ​ข้อ​เดียว ผู้​เรียก​เห็น​ได้​เฉพาะ​แถว​ของผู้​เช่าtenantผู้​เช่า​หนึ่ง​ราย ซึ่ง​ใน​คอร์ส​นี้​คือ​คลินิก​หนึ่ง​แห่ง · ทุก​แถว​เป็น​ของ​ผู้​เช่า​ราย​ใด​ราย​หนึ่ง และ​สัญญา​ของ​คอร์ส​ที่ 35 บอก​ว่า​ผู้​เรียก​เห็น​ได้​เฉพาะ​แถว​ของ​ราย​ตัว​เองตัว​เอง

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

📦 clinic — โปร​เจ​กต์​เดิม​ของ​ซี​รีส์ ไม่​ได้​เริ่ม​ใหม่

คอร์ส​นี้​ต่อ​จาก​โปร​เจ​กต์​ของ clinic-security โดยตรง โดเมน repository และ​กฎ security เดิม​ทั้งหมด

ตัวเลข​ทุก​ตัว​ใน​คอร์ส​นี้​มา​จาก probe ที่​คุณ​รัน​เอง​ได้ สั่ง scripts/clinic-cache/up.sh แล้ว​ตาม​ด้วย python3 scripts/clinic-cache/probe.py

ทรง​ตั้ง​ต้น​คืน​แถว​ของ​ผู้​เช่า​ตัว​เอง​ให้​ทั้ง​สอง​ราย และ​ฐาน​ข้อมูล​ถูก​ถาม​สาม​ครั้ง​จาก​สาม​คำขอ

ใน​ฐาน​ข้อมูล​มี​สาม​แถว แบ่ง​เป็น​ของ​สอง​คลินิก

  • clinic-a มี​สอง​แถว คือ พิมพ์ใจ กับ สมชาย
  • clinic-b มี​หนึ่ง​แถว คือ วีระ
  • ทุก​การ​วัด​ยิง​สาม​คำขอ ตาม​ลำดับ clinic-a แล้ว clinic-b แล้ว clinic-a อีก​ครั้ง

ทรง​นี้​ชื่อ scoped ผู้​เรียก​ของ clinic-a ได้​สอง​แถว​ของ​ตัว​เอง ผู้​เรียก​ของ clinic-b ได้​หนึ่ง​แถว​ของ​ตัว​เอง และ​ฐาน​ข้อมูล​ถูก​ถาม 3 ครั้ง

รูป​ของ​โดเมน​ตัดสิน​ใจ​ไว้​แล้ว​ว่า​ผู้​เช่า​อยู่​ใน SecurityContext ไม่ใช่​ใน​รายการ parameter

รูป​ที่ probe วัด​ทุก​ทรง
@Component("currentTenant")
class CurrentTenant {
val id: String
get() = if (SecurityContextHolder.getContext().authentication?.name == "kaen-a")
"clinic-a" else "clinic-b"
}
@Service
class VisitService(private val repo: VisitRepo, private val tenant: CurrentTenant) {
fun visits(): List<Visit> = repo.findByTenantId(tenant.id)
}

visits() ไม่​รับ parameter สัก​ตัว ผู้​เช่า​เข้า​มา​ทาง SecurityContextSecurityContextที่​ที่ Spring Security เก็บ​ผู้​เรียก​ของ​คำขอ​ปัจจุบัน​ไว้ · ตัว​สร้าง​กุญแจ​ไม่​ได้​อ่าน​ที่​นี่ เว้น​แต่​ผู้​เขียน​สั่ง​ให้​อ่าน​เอง จึง​เป็น​จุด​ที่​สัญญา​ของ​สอง​ชั้น​มา​บรรจบ​กัน แล้ว​ถูก​อ่าน​ข้าง​ใน รูป​นี้​คือ​รูป​ที่​คอร์ส​ที่ 35 ส่ง​มอบ​มา และ​เป็น​รูป​ที่​บท​นี้​ไม่​แก้​สัก​ตัว​อักษร

ตัว​งาน​ข้าง​ใน visits() ไม่​ขยับ​สัก​ตัว​อักษร ไม่มี​การ​แก้​กฎ​ของ​โดเมน​และ​ไม่มี​การ​แก้​กฎ​ความ​ปลอดภัย

ทรง cached — ตัว​งาน​ข้าง​ใน​เหมือน​ทรง scoped ทุก​ตัว​อักษร
@Service
class VisitService(private val repo: VisitRepo, private val tenant: CurrentTenant) {
@Cacheable("visits")
fun visits(): List<Visit> = repo.findByTenantId(tenant.id)
}

@Cacheable@Cacheableannotation ที่​ประกาศ​ว่า​ผล​ของ method นี้​เก็บ​ไว้​ใช้​ซ้ำ​ได้ · ผู้​เรียก​คน​ถัด​ไป​ที่​ได้​กุญแจ​ตรง​กัน​จะ​ได้​ค่า​เดิม​กลับ​ไป โดย​ไม่มี​คำขอ​ไหน​ลง​ไป​ถึง​ฐาน​ข้อมูล ประกาศ​ว่า​ผล​ของ method นี้​เก็บ​ไว้​ใช้​ซ้ำ​ได้ ผู้​เรียก​ที่​ได้​กุญแจ​ตรง​กัน จะ​ได้​ค่า​เดิม​กลับ​ไป​จาก cachecacheชั้น​ที่​เก็บ​คำ​ตอบ​ของ​การ​เรียก​ครั้ง​ก่อน​ไว้ แล้ว​คืนคำ​ตอบ​เดิม​ให้การ​เรียก​ครั้ง​ถัด​ไป​ที่​ได้​กุญแจ​ตรง​กัน · คำถาม​ทั้ง​คอร์ส​คือ​กุญแจ​นั้น​นับ​ผู้​ถาม​ด้วย​หรือ​เปล่า โดย​ไม่มี​คำขอ​ไหน​ลง​ไป​ถึง​ฐาน​ข้อมูล

เทียบ​สอง​ทรง​ช่อง​ต่อ​ช่อง แล้ว​ดู​ว่า​ช่อง​ไหน​เปลี่ยน​และ​ช่อง​ไหน​เหมือน​เดิม

ช่อง​ที่​วัดทรง scopedทรง cached
clinic-a เห็นพิมพ์​ใจ, สมชายพิมพ์​ใจ, สมชาย
clinic-b เห็นวีระพิมพ์​ใจ, สมชาย
ฐาน​ข้อมูล​ถูก​ถาม3 ครั้ง1 ครั้ง
bean ของ CacheManagerไม่มีมี

ผู้​เรียก​ของ clinic-b ได้​แถว​ของ clinic-a กลับ​ไป​ทั้ง​สอง​แถว นี่​คือ cross-tenant leakcross-tenant leakการ​ที่​ผู้​เรียก​ของ​ผู้​เช่า​ราย​หนึ่ง​ได้​แถว​ของ​อีก​ราย​กลับ​ไป · วัด​แล้ว​ว่า​ใน​ทรง​ที่​เปิด cache ไว้ clinic-b ได้​สอง​แถว​ของ clinic-a พร้อม​รหัส 200 จึง​ไม่มี​รหัส​ข้อ​ผิด​พลาด​ให้​ดู​เลย และ​มัน​กลับ​มา​โดยที่​ไม่มี​ใคร​แก้​กฎ​ข้อ​ไหน​เลย

คำ​ตอบ​ที่​รั่ว​ไม่​ได้​ผิด​รูป มัน​มี​รูป​เดียว​กับ​คำ​ตอบ​ที่​ถูก​ต้อง และ​รหัส​เดียวกัน​ด้วย

คำขอ​ของ clinic-b ใน​ทรง cached คืน​รหัส 200 เท่ากับ​คำขอ​ที่​ไม่​ได้​รั่ว ไม่มี​รหัส​ข้อ​ผิด​พลาด​สัก​ตัว​ให้​ดู ใน​คำ​ตอบ​นั้น​ไม่มี​ช่อง​ไหน​บอก​ว่า​แถว​ที่​ได้​ไป​เป็น​ของ​ผู้​เช่า​ราย​อื่น

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

คำขอ​แรก​เดิน​ไป​ถึง​ฐาน​ข้อมูล ส่วน​อีก​สอง​คำขอ​จบ​ที่ cache และ​ไม่​เดิน​ต่อ

flowchart TB
  A["คำขอที่ 1 · ผู้เรียกของ clinic-a"] --> K{"กุญแจใบนี้อยู่ใน cache แล้วหรือยัง"}
  B["คำขอที่ 2 · ผู้เรียกของ clinic-b"] --> K
  C["คำขอที่ 3 · ผู้เรียกของ clinic-a อีกครั้ง"] --> K
  K -->|"ยังไม่มี · เฉพาะคำขอที่ 1"| DB[("ฐานข้อมูล · ถูกถามครั้งเดียว")]
  DB --> R1["พิมพ์ใจ, สมชาย"]
  R1 --> W["เก็บลง cache ชื่อ visits"]
  K -->|"มีแล้ว · คำขอที่ 2 และที่ 3"| R2["พิมพ์ใจ, สมชาย · 200"]

คำ​บรรยาย​ภาพ: สาม​คำขอ​เดิน​มา​ถึง​กุญแจ​ใบ​เดียวกัน คำขอ​แรก​จึง​ลง​ไป​ถึง​ฐาน​ข้อมูล ส่วน​คำขอ​ที่​สอง​กับ​ที่​สาม​ได้​คำ​ตอบ​ของ​คำขอ​แรก​กลับ​ไป

จาก​สาม​คำขอ ฐาน​ข้อมูล​ถูก​ถาม 1 ครั้ง ส่วน​ทรง scoped ถูก​ถาม 3 ครั้ง คำขอ​ที่​สาม​ซึ่ง​เป็น​ของ clinic-a ก็ได้​ค่า​จาก cache เช่น​กัน

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

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

กฎ​ของ​โดเมน​ไม่​ได้​เปลี่ยน กฎ​ความ​ปลอดภัย​ไม่​ได้​เปลี่ยน visits() ยัง​อ่าน​ผู้​เช่า​จาก SecurityContext เหมือน​เดิม​ทุก​คำขอ แต่​ตัว​งาน​ข้าง​ใน visits() ไม่​เคย​รัน​ใน​คำขอ​ที่​สอง

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

ก่อน​ถึง​คำถาม​นั้น บท​ที่ 2 ถาม​ข้อ​ที่มา​ก่อน คือ​ติด annotation ครบ​แล้ว​แอป​ส​ตาร์ต​หรือ​ยัง ส่วน​คำถาม​ข้าง​บน​เปิด​กล่อง​ดู​กัน​ใน​บท​ที่ 3


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Spring Framework — Annotation-based Caching (เข้า​ถึง 2026-09-04) — ที่มา​ของ​ความ​หมาย​ของ @Cacheable

  • ตัวเลข​ทุก​ตัว​ใน​บท​นี้​มา​จาก scripts/clinic-cache/probe.py ซึ่ง​สร้าง​โปร​เจ​กต์​ขึ้น​ใหม่ ที​ละ​ทรง​แล้ว​เทียบ​ผล​ที​ละ​ช่อง · ต้อง​สั่ง scripts/clinic-cache/up.sh ก่อน ซึ่ง​ยก container ของ​คอร์ส​นี้​เอง​ขึ้น​ที่ port 55436

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

ข้อ 1 / 3

ทรง `cached` ยิงสามคำขอตามลำดับ clinic-a แล้ว clinic-b แล้ว clinic-a อีกครั้ง ฐานข้อมูลถูกถามกี่ครั้ง