บทที่ 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 app0อุปกรณ์เดียวinvalidate ไม่ได้ — ต้องส่ง TTL สั้นและมี version check
CDN / edge1–30 msทั้ง regioncache key ผิดทำให้ข้อมูลของผู้ใช้คนหนึ่งรั่วไปหาอีกคน — ต้องทดสอบข้อนี้โดยเฉพาะ
In-process (local heap)~100 nsinstance เดียวไม่ตรงกันระหว่าง instance; กิน memory คูณจำนวน replica
Distributed (Redis/Memcached)0.3–2 msทั้ง fleethot key, stampede, กลายเป็น hard dependency ที่ขาดไม่ได้
Database buffer pool~0.1 msDB ตัวเดียวฟรีและมีอยู่แล้ว — เช็ค 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):

กำลังวาด chart…
กำลังวาด chart…

จาก 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 keydefault และมักถูกต้อง — ตอนเขียนให้ delete ไม่ใช่ update เพราะการ update แข่งกับ reader ที่ทำงานพร้อมกันและอาจทิ้งค่าเก่าไว้ถาวร
Read-throughเหมือนกัน แต่ library ของ cache เป็นคนโหลดให้โค้ดสะอาดขึ้น แต่ปัญหา stampede ถูกซ่อนไว้ใน library — ต้องยืนยันว่ามันทำ coalescing
Write-throughเขียนลง cache และ DB แบบ synchronouscache อุ่นตลอด; latency ของ write รวมทั้ง 2ที่; ต้องระวังเรื่อง atomicity
Write-behindเขียนลง cache แล้ว flush ลง DB แบบ asyncwrite เร็ว แต่เสี่ยงข้อมูลหายจริงถ้า cache ตาย — ห้ามใช้กับข้อมูลการเงิน
Refresh-aheadrefresh 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 พลาด · และ การแสดงยอดเงิน กับ การตัดสินใจหักเงิน เป็นคนละเรื่องกันเสมอ