Caching (Cache-aside)
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ# cli/...คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ ผลลัพธ์ของสคริปต์มาจากการรันของผมผ่าน port forward ของ Docker ค่ามิลลิวินาทีของคุณจึงต่างออกไป “ฐานข้อมูล” ในสคริปต์เป็นของปลอมที่ sleep 50 ms ต่อ query และนับว่าถูกเรียกกี่ครั้ง
รูปแบบ cache-aside (เรียกอีกอย่างว่า lazy loading) ให้แอปควบคุมว่าอะไรเข้า cache และเมื่อไร Redis อยู่หน้า database: ถ้าข้อมูลอยู่ที่นั่นก็ข้าม database ไปเลย ถ้าไม่อยู่ก็ดึงจาก database แล้วเติม cache สำหรับครั้งต่อไป
cache-aside ทำงานอย่างไร
หัวข้อที่มีชื่อว่า “cache-aside ทำงานอย่างไร”flowchart TD
A["App needs cache:user:42"] --> B["GET cache:user:42"]
B --> C{"Cache hit?"}
C -->|"Yes"| D["Return cached value"]
C -->|"No (nil)"| E["Query DB"]
E --> F["SET cache:user:42 result EX 300"]
F --> G["Return value"] แอปเป็นเจ้าของทั้งการอ่านและการเขียน cache ไม่มีอะไรถูกโหลดล่วงหน้า entry ถูกสร้างเมื่อมีคนขอและหมดอายุด้วย TTL
อ่านและเขียน cache entry
หัวข้อที่มีชื่อว่า “อ่านและเขียน cache entry”127.0.0.1:6379> GET cache:user:42(nil)127.0.0.1:6379> SET cache:user:42 "{\"name\":\"Ada\",\"email\":\"[email protected]\"}" EX 300OK127.0.0.1:6379> TTL cache:user:42(integer) 300127.0.0.1:6379> GET cache:user:42"{\"name\":\"Ada\",\"email\":\"[email protected]\"}"127.0.0.1:6379> SET cache:user:42 "{\"name\":\"Ada updated\"}" EX 300OK127.0.0.1:6379> DEL cache:user:42(integer) 1127.0.0.1:6379> GET cache:user:42(nil)127.0.0.1:6379> SET cache:big "x" EX 300OK127.0.0.1:6379> UNLINK cache:big(integer) 1EX 300 ตั้ง TTL 300 วินาที TTL อ่านได้ 300 ทันทีหลังเขียน สักครู่ต่อมาจะเห็น 299 เพราะ TTL ปัดเป็นวินาทีที่ใกล้ที่สุด (PTTL ให้เป็นมิลลิวินาที) เมื่อ source of truth เปลี่ยน ให้ลบ key เพื่อให้การอ่านครั้งถัดไปโหลดใหม่ UNLINK ทำเหมือน DEL แต่คืนหน่วยความจำใน background thread ซึ่งสำคัญกับค่าที่ cache ไว้ก้อนใหญ่ (JSON ก้อนโต หรือ hash หลายพัน field) สำหรับ entry เล็กทั้งสองเทียบเท่ากัน
GET cache:user:42
SET cache:user:42 "{\"name\":\"Ada\",\"email\":\"[email protected]\"}" EX 300
TTL cache:user:42
GET cache:user:42cache-aside ใน ioredis พร้อม stampede จริง
หัวข้อที่มีชื่อว่า “cache-aside ใน ioredis พร้อม stampede จริง”สคริปต์ด้านล่างคือทั้งบทในไฟล์เดียว: cache-aside ธรรมดาพร้อม TTL ที่มี jitter จากนั้นคำขอพร้อมกัน 50 ตัวสำหรับ key ที่เพิ่งหมดอายุ แล้วตามด้วยวิธีแก้สามแบบ ตัวเลขทุกตัวในผลลัพธ์มาจากการรันจริง
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// A fake database: 50 ms per query, and we count how many queries reach it.let dbCalls = 0;async function loadUser(id: number): Promise<{ id: number; name: string }> { dbCalls++; await sleep(50); return { id, name: `user-${id}` };}
const ttl = (base: number) => base + Math.floor(Math.random() * base * 0.1); // +0..10 % jitter
// 1) plain cache-asideasync function getUser(id: number) { const key = `b3:cache:user:${id}`; const hit = await redis.get(key); if (hit) return JSON.parse(hit); const user = await loadUser(id); await redis.set(key, JSON.stringify(user), 'EX', ttl(300)); return user;}
await redis.del('b3:cache:user:42');for (const label of ['miss', 'hit', 'hit']) { const t = performance.now(); await getUser(42); console.log(`${label}: ${(performance.now() - t).toFixed(2)} ms`);}
// 2) the stampede: 50 concurrent requests for a key that just expiredawait redis.del('b3:cache:user:42');dbCalls = 0;await Promise.all(Array.from({ length: 50 }, () => getUser(42)));console.log('stampede: 50 concurrent misses ->', dbCalls, 'DB queries');
// 3) SET ... NX alone does NOT make anyone wait: all 50 still query the DBasync function getUserNxOnly(id: number) { const key = `b3:cache:user:${id}`; const hit = await redis.get(key); if (hit) return JSON.parse(hit); const user = await loadUser(id); // every concurrent miss gets here await redis.set(key, JSON.stringify(user), 'EX', 300, 'NX'); // only the WRITE is guarded return user;}await redis.del('b3:cache:user:42');dbCalls = 0;await Promise.all(Array.from({ length: 50 }, () => getUserNxOnly(42)));console.log('SET NX only: 50 concurrent misses ->', dbCalls, 'DB queries');
// 4) fix A: lock (SET NX PX), the winner loads, losers poll the cache keyasync function getUserLocked(id: number) { const key = `b3:cache:user:${id}`; const lock = `${key}:lock`; for (let attempt = 0; attempt < 50; attempt++) { const hit = await redis.get(key); if (hit) return JSON.parse(hit); const won = await redis.set(lock, '1', 'PX', 5000, 'NX'); // 'OK' or null if (won) { try { const user = await loadUser(id); await redis.set(key, JSON.stringify(user), 'EX', 300); return user; } finally { await redis.del(lock); } } await sleep(20); // loser: wait, then look at the cache again } return loadUser(id); // gave up waiting: degrade to the DB}await redis.del('b3:cache:user:42');dbCalls = 0;let t0 = performance.now();await Promise.all(Array.from({ length: 50 }, () => getUserLocked(42)));console.log(`lock + poll: 50 concurrent misses -> ${dbCalls} DB query, ${(performance.now() - t0).toFixed(0)} ms`);
// 5) fix B: single-flight inside one process (share the in-flight promise)const inflight = new Map<string, Promise<unknown>>();function getUserSingleFlight(id: number) { const key = `b3:cache:user:${id}`; let p = inflight.get(key); if (!p) { p = getUser(id).finally(() => inflight.delete(key)); inflight.set(key, p); } return p;}await redis.del('b3:cache:user:42');dbCalls = 0;await Promise.all(Array.from({ length: 50 }, () => getUserSingleFlight(42)));console.log('single-flight: 50 concurrent misses ->', dbCalls, 'DB query (per process)');
// 6) negative caching: remember "not found" briefly so a missing id cannot hammer the DBasync function getUserOrNull(id: number) { const key = `b3:cache:user:${id}`; const hit = await redis.get(key); if (hit !== null) return hit === 'null' ? null : JSON.parse(hit); dbCalls++; await sleep(50); await redis.set(key, 'null', 'EX', 30); // short TTL: the row may appear later return null;}await redis.del('b3:cache:user:999');dbCalls = 0;for (let i = 0; i < 5; i++) await getUserOrNull(999);console.log('negative cache: 5 lookups of a missing id ->', dbCalls, 'DB query');
await redis.del('b3:cache:user:42', 'b3:cache:user:999');await redis.quit();ผลลัพธ์จริง (Mac เครื่องนี้):
miss: 54.56 mshit: 1.09 mshit: 1.00 msstampede: 50 concurrent misses -> 50 DB queriesSET NX only: 50 concurrent misses -> 50 DB querieslock + poll: 50 concurrent misses -> 1 DB query, 59 mssingle-flight: 50 concurrent misses -> 1 DB query (per process)negative cache: 5 lookups of a missing id -> 1 DB queryสิ่งที่เห็น:
- miss เทียบกับ hit miss จ่ายค่า database (ปลอม) 50 ms บวกการเขียน ส่วน hit ราว 1 มิลลิวินาที ซึ่งส่วนใหญ่เป็น port forward ของ Docker เมื่อ Redis อยู่เครื่องเดียวกัน hit ต่ำกว่ามิลลิวินาทีมาก
- stampede เกิดขึ้นจริง เมื่อไม่มีอะไรป้องกัน path ตอน miss คำขอพร้อมกัน 50 ตัวพบว่า key ว่างทั้งหมดและ query database ทั้ง 50 ตัว cache-aside ที่ไม่มีการป้องกันเปลี่ยนการหมดอายุครั้งเดียวเป็นโหลด database พุ่ง
SET ... NXอย่างเดียวไม่ช่วย คำแนะนำเดิมที่ว่า “ใช้SET NXเพื่อให้เฉพาะผู้เขียนคนแรกเติม key แล้วที่เหลือรอ” ผิดตั้งแต่ต้น:NXคุมเฉพาะ การเขียน ไม่มีใครรอ และทั้ง 50 ตัวยังรัน loader (แถวที่ 2 ของผลลัพธ์) ตัวป้องกันต้องอยู่ ก่อน การโหลด- Lock แล้ว poll (วิธีแก้ A) คำขอหนึ่งชนะ
SET lock PX 5000 NXแล้วโหลด ที่เหลือ sleep 20 ms แล้วดู cache อีกครั้ง 1 query สำหรับ 50 คำขอ และทั้งฝูงเสร็จหลังเริ่ม 59 ms lock มี TTL เพื่อไม่ให้ loader ที่ crash บล็อก key ตลอดไป และผู้แพ้ยอมแพ้หลังราวหนึ่งวินาทีแล้วโหลดเองแทนที่จะค้าง การปล่อยด้วยDELธรรมดายอมรับได้ ที่นี่ เพราะกรณีเลวร้ายสุดคือโหลด database เกินหนึ่งครั้ง ส่วน lock ที่คุมงานจริงดู Distributed Locks - Single-flight (วิธีแก้ B) ภายใน process เดียวไม่ต้องพึ่ง Redis ประสาน: แชร์ promise ที่กำลังทำงาน วิธีนี้ยุบ miss พร้อมกันต่อ process (app server N ตัวยังได้ N query) และใช้ร่วมกับวิธีแก้ A ได้ดี
- Negative caching การค้นหา id ที่ไม่มีอยู่ miss ทุกครั้งและจะไปถึง database ทุกครั้ง การ cache marker
nullอายุสั้น (ที่นี่ 30 วินาที) ทำให้ 5 การค้นหาใช้ 1 query อย่าตั้ง TTL ของมันนาน: แถวอาจถูกสร้างขึ้นทีหลัง - TTL jitter
ttl()เพิ่มเวลาสุ่ม 0-10 % เพื่อไม่ให้ชุด key ที่เขียนพร้อมกันหมดอายุพร้อมกัน ซึ่งเป็นอีกทางหนึ่งที่ทำให้เกิด stampede
มีวิธีที่สามคือ probabilistic early expiration: reader บางคนรีเฟรช key ก่อน มันหมดอายุ ด้วยความน่าจะเป็นที่สูงขึ้นเมื่อ TTL ใกล้หมดและเมื่อต้นทุนการคำนวณใหม่สูง การรีเฟรชจึงกระจายออกและมักเสร็จก่อนที่ใครจะเจอ miss การวิเคราะห์อยู่ใน Vattani, Chierichetti และ Lowenstein, “Optimal Probabilistic Cache Stampede Prevention” (VLDB 2015)
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI ถูกขอให้ “ป้องกัน cache จาก stampede” มันเติม NX ที่การเขียนเพื่อให้ “เฉพาะคำขอแรกเติม key”:
🐛 หา bug ในโค้ดนี้
async function getUser(id: number) {const key = `cache:user:${id}`;const hit = await redis.get(key);if (hit) return JSON.parse(hit);const user = await db.loadUser(id); // slow queryawait redis.set(key, JSON.stringify(user), 'EX', 300, 'NX'); // "only the first writer wins"return user;}เฉลย
NX ตัดสินแค่ว่าใคร เขียน คำขอทุกตัวที่เห็น miss รัน query ช้าไปแล้ว ในแถว stampede ที่สองของสคริปต์ คำขอพร้อมกัน 50 ตัวยังได้ 50 DB query การป้องกันต้องมาก่อนการโหลด: ถือ lock สั้นๆ ด้วย SET lock 1 PX 5000 NX ให้ผู้ชนะโหลด และให้คนอื่นอ่าน cache ซ้ำ
if (await redis.set(`${key}:lock`, '1', 'PX', 5000, 'NX')) { try { /* load, SET key ... EX 300 */ } finally { await redis.del(`${key}:lock`); }} else { await sleep(20); /* then GET key again, up to a deadline */}ด้วยรูปแบบนี้ คำขอ 50 ตัวเดิมได้ 1 query
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”cache แบบ cache-aside คือ สมุดโน้ตข้างฐานข้อมูล การอ่านคือเปิดสมุดก่อน ถ้าไม่มีก็เดินไปที่ฐานข้อมูล คัดลอกคำตอบลงสมุด แล้วแปะสติกเกอร์ “ทิ้งหลัง N นาที” อันตรายคือตอนสติกเกอร์หมดอายุ: ทุกคนที่มาพร้อมกันเดินไปที่ฐานข้อมูลพร้อมกัน วิธีแก้ทั้งหมดคือทำให้ คนเดียว เดินไป ขณะที่คนอื่นกลับมาดูสมุดอีกครั้งอีกสักครู่
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
| Cache-aside | แอปควบคุมได้ว่าอะไรเข้า cache; DB ไม่ถูกโหลดกับข้อมูลที่ไม่มีใครขอ | คำขอแรกหลัง miss ช้ากว่า (query DB + เขียน cache) และ reader อาจเห็นข้อมูลเก่าจนกว่า TTL หรือ invalidation |
| Write-through | cache กับ DB sync กันเสมอ ไม่มีการอ่านข้อมูลเก่า | ทุกการเขียนช้าลง เพราะต้องเขียนทั้งสองที่ก่อนคืนค่า |
| Lock + poll ตอน miss | 1 DB query ต่อการหมดอายุหนึ่งครั้งข้าม app server ทั้งหมด | ผู้แพ้ต้องรอ (poll ราว 20 ms ที่นี่) และต้องมี deadline กับ TTL ของ lock |
| Single-flight ใน process | ไม่มี traffic ไป Redis เพิ่ม เพิ่มได้ง่ายมาก | ตัดซ้ำได้เฉพาะภายใน process เดียว |
| Probabilistic early expiration | ไม่มี lock ไม่ต้องรอ; การรีเฟรชมักเกิดก่อนหมดอายุ | ต้องมี logic เพิ่มต่อการอ่าน และต้องประมาณต้นทุนการคำนวณ |
| Negative caching | id ที่ไม่มีอยู่ไม่ถล่ม DB | ในช่วง TTL ของ marker แถวที่เพิ่งสร้างจะดูเหมือนไม่มี |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
SET NXป้องกัน stampede” มันทำให้การเขียนมีเงื่อนไข ส่วนการโหลดเกิดไปแล้ว (50 query ในการรันด้านบน) - “cache hit ถูกต้องเสมอ” ถูกต้อง ณ การโหลดครั้งล่าสุด ให้ invalidate ตอนเขียนและมี TTL เป็นตาข่ายนิรภัย
- “TTL 300 วินาทีแปลว่า key อยู่พอดี 300 วินาที” แปลว่ามัน มีสิทธิ์ ถูกลบหลัง 300 วินาที;
TTLปัดเป็นวินาที และ key ที่หมดอายุถูกลบแบบ lazy บวกการกวาดใน background (Expiration & TTL) - “การ cache
nullคือ bug” การไม่ cache miss ของ id ที่มีคนขอบ่อยต่างหากคือ bug ให้ cache การไม่มีอยู่ด้วย TTL สั้น
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- ไม่ตั้ง TTL ให้ cache key — ข้อมูลอยู่นานกว่า source of truth และเก่าถาวร
- ไม่ป้องกัน stampede ให้ key ที่ร้อน — การหมดอายุครั้งเดียวกลายเป็น DB query N ตัว (50 ด้านบน)
- ใช้ TTL เดียวกันทั้งชุด — เติม jitter เพื่อไม่ให้ทั้งชุดหมดอายุในวินาทีเดียว
- ลืม invalidate ตอนเขียน — ผู้ใช้เห็นข้อมูลเก่าจน TTL หมด
- รอ lock ไม่มีขอบเขต — ให้ผู้แพ้มี deadline และให้ lock มี TTL เสมอ
Real-world example
Stack Overflow วางชั้น cache-aside แบบ L1 (local)/L2 (Redis) หน้า SQL Server ที่ขนาดรายงานราว 160 พันล้าน Redis operation ต่อเดือน (Stack Overflow: The Architecture – 2016 Edition, Nick Craver, 2016; ตัวเลขอายุสิบปี อ้างพร้อมปี)