สามคำขอ ฐานข้อมูลถูกถามครั้งเดียว แล้ว clinic-b ได้แถวของ clinic-a
คอร์สคัดกรองผู้เรียกของคลินิก จบด้วยกฎข้อเดียว ผู้เรียกเห็นได้เฉพาะแถวของผู้เช่าtenantผู้เช่าหนึ่งราย ซึ่งในคอร์สนี้คือคลินิกหนึ่งแห่ง · ทุกแถวเป็นของผู้เช่ารายใดรายหนึ่ง และสัญญาของคอร์สที่ 35 บอกว่าผู้เรียกเห็นได้เฉพาะแถวของรายตัวเองตัวเอง
บทนี้ไม่แก้กฎนั้นสักตัวอักษร มันเพิ่มชั้นที่จำคำตอบไว้เข้ามาหนึ่งชั้น แล้ววัดว่ากฎเดิมยังยืนอยู่ไหม
คอร์สนี้ต่อจากโปรเจกต์ของ 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 ครั้ง
ผู้เช่าไม่ได้เดินทางมาเป็น parameter
หัวข้อที่มีชื่อว่า “ผู้เช่าไม่ได้เดินทางมาเป็น parameter”รูปของโดเมนตัดสินใจไว้แล้วว่าผู้เช่าอยู่ใน
SecurityContextไม่ใช่ในรายการ parameter
@Component("currentTenant")class CurrentTenant { val id: String get() = if (SecurityContextHolder.getContext().authentication?.name == "kaen-a") "clinic-a" else "clinic-b"}
@Serviceclass VisitService(private val repo: VisitRepo, private val tenant: CurrentTenant) { fun visits(): List<Visit> = repo.findByTenantId(tenant.id)}visits() ไม่รับ parameter สักตัว ผู้เช่าเข้ามาทาง SecurityContextSecurityContextที่ที่ Spring Security เก็บผู้เรียกของคำขอปัจจุบันไว้ · ตัวสร้างกุญแจไม่ได้อ่านที่นี่ เว้นแต่ผู้เขียนสั่งให้อ่านเอง จึงเป็นจุดที่สัญญาของสองชั้นมาบรรจบกัน แล้วถูกอ่านข้างใน
รูปนี้คือรูปที่คอร์สที่ 35 ส่งมอบมา และเป็นรูปที่บทนี้ไม่แก้สักตัวอักษร
ติด annotation บน method เดิม แล้ววัดใหม่
หัวข้อที่มีชื่อว่า “ติด annotation บน method เดิม แล้ววัดใหม่”ตัวงานข้างใน
visits()ไม่ขยับสักตัวอักษร ไม่มีการแก้กฎของโดเมนและไม่มีการแก้กฎความปลอดภัย
@Serviceclass 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 จึงไม่มีรหัสข้อผิดพลาดให้ดูเลย และมันกลับมาโดยที่ไม่มีใครแก้กฎข้อไหนเลย
รหัสยังเป็น 200
หัวข้อที่มีชื่อว่า “รหัสยังเป็น 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 อีกครั้ง ฐานข้อมูลถูกถามกี่ครั้ง