คำเรียกที่เกิดใน class เดียวกัน ไม่เคยไปถึง proxy
บทที่ 3 ปิดเรื่องกุญแจไปแล้ว กุญแจมีผู้เช่าอยู่ข้างในแล้ว และคำขอของสองผู้เช่าไม่ตกช่องเดียวกันอีก
บทนี้ไม่แตะกุญแจสักตัวอักษร มันเพิ่มทางเข้าใหม่หนึ่งทาง แล้ววัดว่าชั้นที่จำคำตอบยังทำงานอยู่ไหม คำตอบคือไม่ และไม่มีอะไรในแอปบอกเรื่องนี้เลย
ทางเข้าใหม่ที่เรียกเพื่อนบ้านของตัวเอง
หัวข้อที่มีชื่อว่า “ทางเข้าใหม่ที่เรียกเพื่อนบ้านของตัวเอง”เพิ่ม method หนึ่งตัวที่เรียก
visits()ตรงๆ จากใน class เดียวกัน ไม่แก้visits()เลย
visits() ยังเป็นตัวเดิม พร้อมกุญแจที่บทที่ 3 ใส่ให้ ส่วนของใหม่คือบรรทัดล่าง
@Serviceclass VisitService(private val repo: VisitRepo, private val tenant: CurrentTenant) {
@Cacheable("visits", key = "@currentTenant.id") fun visits(): List<Visit> = repo.findByTenantId(tenant.id)
/** เรียกเพื่อนบ้านใน class เดียวกัน · คำเรียกนี้ไม่ผ่าน proxy */ fun visitsViaSelf(): List<Visit> = visits()}ปลายทาง HTTP ของทางใหม่นั้นก็ตรงไปตรงมา มันเรียก visitsViaSelf() แทน visits()
@GetMapping("/visits/self")fun viaSelf(): Map<String, Any> = shape(svc.visitsViaSelf())อ่านสองบล็อกข้างบนแล้วไม่มีอะไรน่าสงสัย visitsViaSelf() เรียก visits()
และ visits() ติด @Cacheable ไว้ครบ
ยิงสองครั้งผ่านทางใหม่ แล้วนับ
หัวข้อที่มีชื่อว่า “ยิงสองครั้งผ่านทางใหม่ แล้วนับ”ยิง
/visits/selfสองครั้ง ฐานข้อมูลถูกถาม 2 ครั้ง และกุญแจใน cache ยังเป็น[]
| ช่องที่วัด | หลังยิง /visits/self สองครั้ง | หลังยิง /visits อีกหนึ่งครั้ง |
|---|---|---|
| ฐานข้อมูลถูกถาม | 2 ครั้ง | 3 ครั้ง |
| กุญแจใน cache | [] | [clinic-a] |
สองคำขอ สองครั้งที่ลงไปถึงฐานข้อมูล นั่นคือตัวเลขของแอปที่ไม่มี cache เลย
ช่องที่สองบอกมากกว่านั้นอีก กุญแจใน cache เป็นรายการว่าง แปลว่าไม่ใช่แค่ไม่ได้ค่าเดิมกลับมา แต่ไม่เคยมีใครเขียนอะไรลงไปเลยสักใบ
นี่ไม่ใช่ cache misscache missการที่กุญแจของคำเรียกไม่มีอยู่ใน cache จึงต้องลงไปถามของจริง · การนับ miss คือวิธีเดียวที่บอกว่าชั้นนี้ทำงานอยู่ หรือถูกข้ามไปทั้งชั้น ที่ตามด้วยการเติมค่ากลับ ถ้าเป็น miss จริง คำขอแรกจะเขียนกุญแจไว้ให้คำขอที่สอง ที่วัดได้คือชั้นนี้ถูกข้ามไปทั้งชั้น
ยิงทางปกติหนึ่งครั้ง แล้วกุญแจโผล่
หัวข้อที่มีชื่อว่า “ยิงทางปกติหนึ่งครั้ง แล้วกุญแจโผล่”เปลี่ยนไปยิง
/visitsหนึ่งครั้ง ฐานข้อมูลถูกถามเพิ่มเป็น 3 และกุญแจปรากฏขึ้นใน cache
คำขอที่สามยิงเข้า /visits ซึ่งเป็นทางเดิมของบทก่อนหน้า ผลที่ได้เปลี่ยนสองช่องพร้อมกัน
- ฐานข้อมูลถูกถามเพิ่มเป็น 3 ครั้ง ซึ่งคาดไว้อยู่แล้ว เพราะยังไม่มีใครเก็บอะไรไว้ให้
- กุญแจใน cache กลายเป็น
[clinic-a]จากที่เป็น[]มาตลอดสองคำขอแรก
ของสองอย่างนี้ต่างกันแค่ทางเข้า visits() ตัวเดียวกัน กุญแจสูตรเดียวกัน ผู้เช่าคนเดียวกัน
เข้าทางหนึ่งแล้วกลไกทำงาน เข้าอีกทางแล้วไม่ทำงาน
กลไกอยู่ที่ตัวห่อ ไม่ได้อยู่ในตัว method
หัวข้อที่มีชื่อว่า “กลไกอยู่ที่ตัวห่อ ไม่ได้อยู่ในตัว method”Spring ห่อ bean ไว้ด้วย proxy งานของ annotation เกิดที่ตัวห่อ คำเรียกที่ไม่ผ่านตัวห่อจึงไม่มีใครทำงานให้
proxyproxyตัวห่อ bean ที่ Spring สร้างขึ้นเพื่อทำงานของ annotation ก่อนและหลังคำเรียกจริง · กลไกของ cache อยู่ที่ตัวห่อ ไม่ได้อยู่ในตัว method คือ bean ตัวที่ผู้อื่นถืออยู่จริง เมื่อ VisitEndpoint เรียก svc.visits()
มันเรียกตัวห่อ ตัวห่อเช็กกุญแจก่อน แล้วค่อยตัดสินว่าจะเรียกของจริงหรือไม่เรียก
แต่ข้างใน visitsViaSelf() คำว่า visits() หมายถึง this.visits() และ this คือ
อ็อบเจ็กต์ของจริง ไม่ใช่ตัวห่อ คำเรียกนั้นจึงเดินตรงเข้าตัวงาน ไม่ผ่านด่านไหนเลย
นี่คือ self-invocationself-invocationการที่ method หนึ่งเรียกอีก method ใน class เดียวกันตรงๆ · คำเรียกนั้นไม่ผ่าน proxy annotation จึงไม่มีผล · วัดแล้วว่าเรียกสองครั้ง ฐานข้อมูลถูกถามสองครั้ง และกุญแจใน cache ยังเป็น `[]` และเอกสารของ Spring เขียนผลของมันไว้ตรงๆ — “self invocation is not going to result in the advice associated with a method invocation getting a chance to run”
flowchart TB
A["คำขอ /visits/self"] --> P["proxy ที่ห่อ VisitService ไว้"]
B["คำขอ /visits"] --> P
P -->|"เข้าทาง visitsViaSelf() · ไม่มี annotation ให้ทำงาน"| S["visitsViaSelf()"]
S -->|"เรียกเพื่อนบ้านในตัวเดียวกัน · ไม่ย้อนออกไปที่ proxy"| V["visits()"]
P -->|"เข้าทาง visits() · ด่านของ @Cacheable อยู่ตรงนี้"| K{"กุญแจใบนี้อยู่ใน cache แล้วหรือยัง"}
K -->|"ยังไม่มี · จึงเขียนกุญแจไว้หลังได้คำตอบ"| V
V --> DB[("ฐานข้อมูล")]
คำบรรยายภาพ: ทั้งสองทางลงท้ายที่ visits() ตัวเดียวกัน แต่ทางบนเข้าถึงตัวงานโดยไม่ผ่านด่าน
ของ @Cacheable ส่วนทางล่างผ่านด่านนั้นก่อน จึงมีกุญแจเกิดขึ้นเฉพาะทางล่าง
อาการเดียวคือตัวเลขที่ควรลด กลับไม่ลด
หัวข้อที่มีชื่อว่า “อาการเดียวคือตัวเลขที่ควรลด กลับไม่ลด”ข้อนี้ไม่ทิ้ง error ไม่ทิ้ง log และไม่มีคำเตือนสักบรรทัด สิ่งที่เปลี่ยนคือจำนวนครั้งที่ถามฐานข้อมูล
รวบสิ่งที่ผู้ดูแลระบบมองเห็นในทรงนี้
- คำตอบถูกต้องทุกแถว ผู้เรียกได้แถวของผู้เช่าตัวเองครบ
- รหัสที่คืนคือรหัสสำเร็จ ไม่มีรหัสข้อผิดพลาดสักตัว
- ไม่มีข้อความเตือนว่ามี annotation ตัวไหนถูกข้าม
สิ่งเดียวที่ผิดคือจำนวนครั้งที่ฐานข้อมูลถูกถาม และตัวเลขนั้นไม่โผล่มาเองในคำตอบ มีคนต้องตั้งใจนับมันถึงจะเห็น
ที่แย่กว่านั้นคือการอ่านผลผิดทาง ถ้าอ่านตัวเลขที่ไม่ลดแบบตรงตัว ข้อสรุปที่ได้จะเป็นว่า cache ตัวนี้ไม่คุ้ม แล้วต้องไปหา cache ตัวใหม่ ทั้งที่ตัวเดิมไม่เคยถูกเรียกใช้เลยสักครั้ง
ทางที่ปิดข้อนี้ และราคาของแต่ละทาง
หัวข้อที่มีชื่อว่า “ทางที่ปิดข้อนี้ และราคาของแต่ละทาง”มีสองทางที่ทำให้คำเรียกวิ่งผ่านตัวห่อจริง และทั้งสองทางเก็บราคาคนละแบบ
probe ของคอร์สนี้วัดแต่ทรงที่พังไว้ ไม่ได้วัดทรงที่แก้แล้ว ส่วนข้างล่างจึงบอกทิศทาง ไม่ได้บอกตัวเลข
- ย้ายผู้เรียกออกไปอยู่คนละ bean คำเรียกกลายเป็นคำเรียกข้าม bean จึงผ่าน proxy ตามปกติ ราคาคือมี bean เพิ่มมาอีกตัว และเส้นแบ่งหน้าที่ระหว่างสองตัวนั้นต้องอธิบายให้ได้
- ฉีดตัวเองกลับเข้ามาแล้วเรียกผ่านตัวที่ฉีด คำเรียกจะไปที่ตัวห่อ ราคาคือ bean ตัวนั้นถืออ้างอิงที่ชี้กลับมาหาตัวเอง ซึ่งอ่านแล้วสะดุด และคนที่แก้ต่อมักลบทิ้งเพราะดูเหมือนของเกิน
ทางที่แพงที่สุดคือไม่ทำอะไร แล้วปล่อยให้ตัวเลขที่ไม่ลดกลายเป็นข้อสรุปว่าเครื่องมือใช้ไม่ได้
ที่บทนี้หยุดไว้
หัวข้อที่มีชื่อว่า “ที่บทนี้หยุดไว้”บทนี้พูดถึงคำเรียกที่ไปไม่ถึง cache ส่วนบทหน้าพูดถึงค่าที่ไปถึงแล้ว และถูกคนข้างนอกแก้
เมื่อคำเรียกเดินผ่านตัวห่อจริง ค่าจะถูกเก็บไว้และถูกยื่นให้ผู้เรียกคนถัดไป คำถามถัดไปคือของที่ยื่นออกไปนั้น เป็นสำเนา หรือเป็นตัวเดียวกับที่เก็บอยู่ใน cache
บทที่ 5 วัดข้อนั้น
-
Spring Framework — Proxying Mechanisms (เข้าถึง 2026-09-04) — ที่มาของประโยคเรื่อง self invocation กับ advice ที่ไม่ได้รัน
-
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/clinic-cache/probe.pyซึ่งยิงคำขอจริงแล้วนับ query ที่ลงถึงฐานข้อมูล · ต้องสั่งscripts/clinic-cache/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55436
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3ยิง `/visits/self` สองครั้ง ซึ่งเข้าทาง `visitsViaSelf()` ผลที่วัดได้คืออะไร