ข้ามไปยังเนื้อหา

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 สำหรับครั้งต่อไป

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-aside: เช็ก Redis ก่อน ถ้า miss ก็ไปอ่าน DB แล้วเขียนกลับพร้อม TTL

แอปเป็นเจ้าของทั้งการอ่านและการเขียน cache ไม่มีอะไรถูกโหลดล่วงหน้า entry ถูกสร้างเมื่อมีคนขอและหมดอายุด้วย TTL

cli/patterns-caching-1.redis
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 300
OK
127.0.0.1:6379> TTL cache:user:42
(integer) 300
127.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 300
OK
127.0.0.1:6379> DEL cache:user:42
(integer) 1
127.0.0.1:6379> GET cache:user:42
(nil)
127.0.0.1:6379> SET cache:big "x" EX 300
OK
127.0.0.1:6379> UNLINK cache:big
(integer) 1

EX 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:42

สคริปต์ด้านล่างคือทั้งบทในไฟล์เดียว: cache-aside ธรรมดาพร้อม TTL ที่มี jitter จากนั้นคำขอพร้อมกัน 50 ตัวสำหรับ key ที่เพิ่งหมดอายุ แล้วตามด้วยวิธีแก้สามแบบ ตัวเลขทุกตัวในผลลัพธ์มาจากการรันจริง

src/patterns-caching-stampede.ts
// @run
import { 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-aside
async 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 expired
await 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 DB
async 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 key
async 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 DB
async 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 ms
hit: 1.09 ms
hit: 1.00 ms
stampede: 50 concurrent misses -> 50 DB queries
SET NX only: 50 concurrent misses -> 50 DB queries
lock + poll: 50 concurrent misses -> 1 DB query, 59 ms
single-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)

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 query
await 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

cache แบบ cache-aside คือ สมุดโน้ตข้างฐานข้อมูล การอ่านคือเปิดสมุดก่อน ถ้าไม่มีก็เดินไปที่ฐานข้อมูล คัดลอกคำตอบลงสมุด แล้วแปะสติกเกอร์ “ทิ้งหลัง N นาที” อันตรายคือตอนสติกเกอร์หมดอายุ: ทุกคนที่มาพร้อมกันเดินไปที่ฐานข้อมูลพร้อมกัน วิธีแก้ทั้งหมดคือทำให้ คนเดียว เดินไป ขณะที่คนอื่นกลับมาดูสมุดอีกครั้งอีกสักครู่

ตัวเลือกข้อดีข้อเสีย
Cache-asideแอปควบคุมได้ว่าอะไรเข้า cache; DB ไม่ถูกโหลดกับข้อมูลที่ไม่มีใครขอคำขอแรกหลัง miss ช้ากว่า (query DB + เขียน cache) และ reader อาจเห็นข้อมูลเก่าจนกว่า TTL หรือ invalidation
Write-throughcache กับ DB sync กันเสมอ ไม่มีการอ่านข้อมูลเก่าทุกการเขียนช้าลง เพราะต้องเขียนทั้งสองที่ก่อนคืนค่า
Lock + poll ตอน miss1 DB query ต่อการหมดอายุหนึ่งครั้งข้าม app server ทั้งหมดผู้แพ้ต้องรอ (poll ราว 20 ms ที่นี่) และต้องมี deadline กับ TTL ของ lock
Single-flight ใน processไม่มี traffic ไป Redis เพิ่ม เพิ่มได้ง่ายมากตัดซ้ำได้เฉพาะภายใน process เดียว
Probabilistic early expirationไม่มี lock ไม่ต้องรอ; การรีเฟรชมักเกิดก่อนหมดอายุต้องมี logic เพิ่มต่อการอ่าน และต้องประมาณต้นทุนการคำนวณ
Negative cachingid ที่ไม่มีอยู่ไม่ถล่ม DBในช่วง TTL ของ marker แถวที่เพิ่งสร้างจะดูเหมือนไม่มี
  • “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 สั้น
  • ไม่ตั้ง 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; ตัวเลขอายุสิบปี อ้างพร้อมปี)

ใน cache-aside แอปเขียนลง Redis เมื่อใด?
คำสั่งใดลบ cache entry ที่เก่าและคืนหน่วยความจำของค่าก้อนใหญ่โดยไม่บล็อก?
key ยอดนิยมหมดอายุและคำขอ 50 ตัว miss พร้อมกัน ถ้าใช้ SET ... EX 300 NX ธรรมดาหลังการโหลด จะมี DB query กี่ตัว?
การออกแบบแบบใดทำให้ผู้แพ้ในการแย่งเติม cache รอได้ถูกต้อง?
ทำไมต้อง cache marker null สำหรับ id ที่ไม่มีอยู่?
ทำไมต้องเติม jitter สุ่มให้ TTL ของ cache?