บทที่ 7 · Part 2 — Building Blocks
Caching
ชั้นของ cache, eviction, TTL + jitter, hit ratio math และเหตุผลที่ยอดเงินไม่ควร cache แบบไร้เงื่อนไข
Cache คือเครื่องมือที่ให้ leverage สูงที่สุดและมีความเสี่ยงสูงที่สุดในมือคุณ มันลด p99 ได้ 10 เท่า และมันส่งข้อมูลผิดให้ลูกค้าได้ด้วย บทนี้คือทั้ง 2 ด้าน
จบบทนี้คุณจะ
- รู้ว่า cache มีกี่ชั้น ชั้นไหนอันตรายเรื่องอะไร
- เข้าใจว่าทำไม hit rate เป็น capacity metric ของชั้นที่อยู่ข้างหลัง ไม่ใช่แค่ latency metric
- เลือก cache pattern ได้ และรู้ว่าทำไมตอน write ต้อง delete ไม่ใช่ update
- แก้ hazard ทั้ง 4 ได้: stampede, penetration, hot key, invalidation ที่ลืม
- รู้ว่าอะไรห้าม cache เด็ดขาด
ชั้นของ cache
| ชั้น | Latency | ขอบเขต | อันตรายหลัก |
|---|---|---|---|
| Client / mobile app | 0 | อุปกรณ์เดียว | invalidate ไม่ได้ — ต้องส่ง TTL สั้นและมี version check |
| CDN / edge | 1–30 ms | ทั้ง region | cache key ผิดทำให้ข้อมูลของผู้ใช้คนหนึ่งรั่วไปหาอีกคน — ต้องทดสอบข้อนี้โดยเฉพาะ |
| In-process (local heap) | ~100 ns | instance เดียว | ไม่ตรงกันระหว่าง instance; กิน memory คูณจำนวน replica |
| Distributed (Redis/Memcached) | 0.3–2 ms | ทั้ง fleet | hot key, stampede, กลายเป็น hard dependency ที่ขาดไม่ได้ |
| Database buffer pool | ~0.1 ms | DB ตัวเดียว | ฟรีและมีอยู่แล้ว — เช็ค hit ratio ก่อนคิดเพิ่มชั้น cache |
| Materialized read model | เร็วเท่า DB | ทั้ง fleet | ไม่ใช่ cache จริงๆ — เป็น derived store ที่ต้อง rebuild ได้แบบ deterministic |
Cache key ที่รั่วข้อมูลข้ามผู้ใช้
ถ้า CDN cache GET /v1/me/balance โดยไม่รวม identity ของผู้ใช้ใน cache key
ผู้ใช้คนที่ 2 จะได้ยอดเงินของคนแรก นี่เป็นเหตุการณ์ที่เกิดจริงในหลายบริษัท
กฎเหล็ก: endpoint ที่ตอบข้อมูลต่อผู้ใช้ต้องมี Cache-Control: private, no-store
และต้องมี test ที่ยิงด้วย 2 token แล้วยืนยันว่าได้ผลต่างกัน
ทำไม hit rate เป็นทุกอย่าง
สมมติแบบ illustrative ว่า cache hit ใช้ 1 ms ส่วน cache miss ใช้ 50 ms
เพราะต้องไป DB ค่าเฉลี่ยจึงเป็น hitRate(1) + missRate(50):
จาก 99% ลงมา 95% ค่าเฉลี่ยแทบไม่เปลี่ยน แต่โหลดที่ลงถึงฐานข้อมูล เพิ่มขึ้น 5 เท่า — กราฟที่ 2 จึงสำคัญกว่ากราฟแรกตอนวางแผน capacity
นี่คือเหตุผลที่ "cache เย็น" คือ outage เต็มรูปแบบ ไม่ใช่แค่ระบบช้าลง hit rate เป็น capacity metric ของ tier ที่อยู่ข้างหลัง
Cache ที่เย็นแล้ว warm ไม่ได้
วงจรข้างบนคือ cascading failure — ไม่ว่าจะรอนานแค่ไหน cache ก็ไม่กลับมาอุ่น เพราะ request ที่จะมา populate cache ตัวเองก็ timeout ก่อน ทางออกต้องเป็นการ ลดโหลดที่เข้าไป (load shedding) ให้ระบบมีโอกาส warm ไม่ใช่รอให้มันหายเอง — ดูบทที่ 12
Cache pattern
| Pattern | ทำงานอย่างไร | ข้อควรระวัง |
|---|---|---|
| Cache-aside (lazy) | อ่าน: ลอง cache, miss ก็อ่าน DB แล้ว populate · เขียน: update DB แล้ว delete key | default และมักถูกต้อง — ตอนเขียนให้ delete ไม่ใช่ update เพราะการ update แข่งกับ reader ที่ทำงานพร้อมกันและอาจทิ้งค่าเก่าไว้ถาวร |
| Read-through | เหมือนกัน แต่ library ของ cache เป็นคนโหลดให้ | โค้ดสะอาดขึ้น แต่ปัญหา stampede ถูกซ่อนไว้ใน library — ต้องยืนยันว่ามันทำ coalescing |
| Write-through | เขียนลง cache และ DB แบบ synchronous | cache อุ่นตลอด; latency ของ write รวมทั้ง 2ที่; ต้องระวังเรื่อง atomicity |
| Write-behind | เขียนลง cache แล้ว flush ลง DB แบบ async | write เร็ว แต่เสี่ยงข้อมูลหายจริงถ้า cache ตาย — ห้ามใช้กับข้อมูลการเงิน |
| Refresh-ahead | refresh key ที่นิยมล่วงหน้าก่อนหมดอายุ | ตัด latency spike ที่ขอบ TTL สำหรับ key ที่รู้ว่าร้อน |
ทำไมต้อง delete ไม่ใช่ update
เวลา Writer A Reader B
t1 อ่าน DB ได้ value = 100 (ยังไม่เขียน cache)
t2 UPDATE DB -> 200
t3 SET cache = 200
t4 SET cache = 100 <-- ค่าเก่าทับค่าใหม่
--> cache ค้างที่ 100 ตลอดไปจนกว่า TTL หมด
ถ้า Writer A ทำ DELETE cache แทน SET:
t3 DEL cache
t4 SET cache = 100 <-- ยังผิดได้ แต่...
--> ยังมี race แคบๆ อยู่ ทางแก้จริงคือ delete + TTL สั้น
หรือใช้ versioned key / lease (แบบ Facebook memcache)
Hazard ทั้ง 4 และวิธีแก้
1. Stampede / thundering herd
Key ที่ร้อนหมดอายุ → request 5,000 ตัวพร้อมกัน miss ทั้งหมด → ลง DB พร้อมกันทั้งหมด
วิธีแก้ (ใช้ร่วมกันได้):
- Single-flight / request coalescing — ต่อ key มีแค่ request เดียวที่ไปคำนวณ ตัวอื่นรอผลของมัน
- Lock หรือ lease สั้นๆ ต่อ key — ใครได้ lease คนนั้น populate
- TTL แบบ jitter เพื่อไม่ให้ key หมดอายุพร้อมกัน
- Serve ค่าเก่าไปก่อน ขณะที่ worker 1 ตัว refresh อยู่เบื้องหลัง
TTL แบบ jitter — ทำแค่บรรทัดเดียวก็ช่วยได้มาก
ttl = base_ttl * (0.8 + random() * 0.4) # 80%-120% ของ base
--> 5,000 key ที่ populate พร้อมกันจะกระจายการหมดอายุออกไป
แทนที่จะหมดอายุพร้อมกันในวินาทีเดียวกัน
Real case — Scaling Memcache at Facebook
Paper จาก NSDI 2013 เป็นงานเรื่อง caching ที่มีประโยชน์ที่สุดที่เคยตีพิมพ์ เพราะมันบันทึกปัญหาที่โผล่เฉพาะตอนอยู่ในระดับใหญ่: stale set (write ที่ช้าแข่งกับ read แล้ว populate ค่าเก่ากลับเข้าไป), thundering herd บน hot key (แก้ด้วย lease token ต่อ key — อนุญาตให้ client เดียวเท่านั้นเติม cache), incast congestion จาก fan-out มหาศาล, และ cross-region invalidation
อ่านหัวข้อ 3 กับ 4 แม้คุณจะไม่เคยรันระบบขนาดนั้น — แนวคิด lease ใช้ได้กับ Redis 3 node เหมือนกัน — Scaling Memcache at Facebook
2. Cache penetration
Request ถาม key ที่ไม่มีอยู่ (มักเป็นการโจมตี หรือ client ที่เขียนผิด) ทะลุ cache ลง DB ทุกครั้ง
วิธีแก้: cache ผลลัพธ์ที่เป็น negative ด้วย TTL สั้น (เช่น 30 s), และใช้ Bloom filter สำหรับตรวจการมีอยู่
Negative cache กับข้อมูลที่เพิ่งสร้าง
ถ้า cache "ไม่มี account นี้" ไว้ 5 นาที แล้วผู้ใช้เพิ่งสมัครเสร็จ เขาจะเห็น error 5 นาที ให้ใช้ TTL สั้นมาก (10–30 s) สำหรับ negative และ delete negative key ทันทีที่มีการสร้าง entity นั้น
3. Hot key
Key เดียวกิน traffic 80% และทำให้ shard เดียวตัน
วิธีแก้:
- In-process cache เล็กๆ วางหน้า distributed cache — ตัด network hop และตัดโหลดที่ไปถึง shard
- แตก key เป็น
key#0..key#Nแล้วสุ่มเลือกอันหนึ่ง (ใช้ได้กับ counter และค่าที่ read-heavy)
Hot key: promotion_banner_home (80% ของ traffic ทั้งหมด)
แตกเป็น: promotion_banner_home#0 .. #9
อ่าน: key = base + "#" + random(0..9) -> โหลดกระจาย 10 shard
เขียน: เขียนทั้ง 10 key (write เกิดไม่บ่อย จึงคุ้ม)
--> ใช้ได้เมื่อค่าเหมือนกันทุก key เท่านั้น
ห้ามใช้กับข้อมูลต่อผู้ใช้
4. Invalidation ที่คุณลืม
เคสคลาสสิก: มี 5 เส้นทางในโค้ดที่เขียน entity นี้ แต่มีแค่ 4 เส้นทางที่ delete cache key
วิธีแก้:
- รวม write ไว้หลังฟังก์ชัน repository ตัวเดียว — ไม่มีทางเขียนโดยไม่ผ่านมัน
- สร้าง cache key จาก helper ตัวเดียว — ไม่มีใครประกอบ key เองด้วยมือ
- ใส่ version หรือ schema tag ไว้ใน key เพื่อให้การ deploy invalidate ได้สะอาด
เช่น
v3:account:acc_123:balance - ใช้ TTL สั้นเป็นตัวกันชน — TTL คือขอบเขตว่า bug จะ serve ข้อมูลผิดได้นานที่สุดเท่าไหร่
TTL คือประกันความผิดพลาด
ถ้าคุณตอบไม่ได้ว่า "ถ้า invalidation พลาด ข้อมูลผิดจะอยู่ได้นานที่สุดกี่นาที" แปลว่าคุณไม่มี TTL หรือ TTL นานเกินไป ตัวเลขนั้นควรเป็นตัวเลขที่คุณยอมรับได้และเขียนไว้ใน design doc
สิ่งที่ห้าม cache
2 อย่างที่ห้าม serve ค่าเก่าโดยไม่มีขอบเขต
1. การตัดสินใจเรื่อง authorization ถ้าคุณ cache ว่า "user X เข้าถึง account Y ได้" ไว้ 10 นาที การเพิกถอนสิทธิ์จะใช้เวลา 10 นาทีจึงเป็นจริง — นั่นคือ audit finding และเป็นช่องว่างด้านความปลอดภัยจริง ถ้าจำเป็นต้อง cache ให้ cache แค่ผลการ validate token สั้นๆ แต่ตรวจ authorization กับข้อมูลสด หรือใช้ TTL สั้นมากบวกกลไก push การเพิกถอน
2. ยอดเงินที่เป็น authoritative จะ แสดง ยอดจาก cache ก็ได้ แต่ การตัดสินใจหักเงินต้องอ่านแถวที่เป็น authoritative ภายใน transaction พร้อม constraint ที่บังคับว่าห้ามติดลบ
การแสดง กับ การตัดสินใจ เป็น 2 ปฏิบัติการที่ต้องการ consistency ต่างกัน — แยกมันออกจากกันอย่างชัดเจนใน design ของคุณ
-- ถูก: ตัดสินใจหักเงินภายใน transaction กับข้อมูลจริง
BEGIN;
SELECT balance_minor FROM accounts
WHERE account_id = $1 FOR UPDATE; -- ล็อกแถว
UPDATE accounts
SET balance_minor = balance_minor - $2
WHERE account_id = $1
AND balance_minor >= $2; -- กันติดลบที่ระดับ DB
-- ถ้า rows affected = 0 -> เงินไม่พอ ไม่ต้องเชื่อ cache
COMMIT;
Checklist ก่อนใส่ cache
- เช็ค buffer pool hit ratio ของ DB ก่อน — บางทีคุณไม่ต้องการ cache layer เลย
- ตอบได้ว่า hit rate ที่คาดหวังเท่าไหร่ และถ้า cache หายไปทั้งตัว DB รับได้ไหม
- มี single-flight / coalescing สำหรับ key ที่ร้อน
- TTL มี jitter
- มี negative caching พร้อม TTL สั้น
- cache key สร้างจาก helper ตัวเดียว และมี version prefix
- write ทุกเส้นทางผ่าน repository เดียวที่ invalidate ให้
- endpoint ต่อผู้ใช้ ไม่ถูก cache ที่ CDN และมี test ยืนยันด้วย 2 token
- ไม่มีการ cache authorization decision เกินไม่กี่วินาที
- การหักเงิน/ตัดสิทธิ์อ่านข้อมูล authoritative ใน transaction
- มี metric: hit rate, eviction rate, p99 ของ cache, และ โหลดที่ตกลง DB
สรุปบทนี้
hit rate คือ capacity metric ของชั้นข้างหลัง · cache เย็น = outage ไม่ใช่ความช้า · ตอนเขียนให้ delete ไม่ใช่ update · แก้ stampede ด้วย coalescing + jitter + serve stale · TTL คือขอบเขตของความเสียหายเวลา invalidation พลาด · และ การแสดงยอดเงิน กับ การตัดสินใจหักเงิน เป็นคนละเรื่องกันเสมอ