ใน Computer Science มีปัญหาที่ยากจริง ๆ อยู่แค่สองอย่าง…
…cache invalidation, การตั้งชื่อ, และ off-by-one errors. มุกนี้ตลกเพราะ Backend Engineer ทุกคนที่เคย Ship Cache สุดท้ายก็ต้องโดนปลุกตอนตีสามเพราะมีคนเห็นราคาเมื่อวาน เห็น Comment ที่ลบไปแล้ว หรือเห็น Dashboard ของ User ที่ Logout ไปแล้ว
วิธีแก้ Endpoint ที่ช้าโดยสัญชาตญาณคือ “เอา Redis มาวางหน้า Postgres” บางครั้งก็เป็นคำตอบที่ถูก แต่บ่อยครั้งมันคือคำตอบที่ขี้เกียจที่สุด — มันแลกปัญหาเรื่อง Latency กับปัญหาเรื่อง Correctness และในระบบส่วนใหญ่ Bug เรื่อง Correctness แพงกว่าหน้าที่โหลดช้า
โพสต์นี้เป็นแผนที่ของ Landscape เรื่อง Caching สำหรับ Engineer ที่กำลังออกแบบ Caching Layer ตั้งแต่ศูนย์ หรือกำลังรื้อระบบที่บานปลายจนกลายเป็นภาระ เราจะเดินผ่านที่ ๆ Cache สามารถอยู่ได้ Pattern การเขียนข้อมูลเข้าออก กลยุทธ์ Invalidation ที่ทำให้มันซื่อสัตย์ และ Observability ที่ต้องมีเพื่อรู้ว่ามันช่วยอะไรเราจริง ๆ หรือเปล่า ไม่มี Vendor ไม่มี Benchmark — มีแค่รูปร่างและ Trade-off
TL;DR
- Cache อยู่ได้เจ็ดที่ — browser, CDN, edge worker, gateway, in-process, distributed store, read-replica — และ Production System ส่วนใหญ่ก็ซ้อนหลายอันเข้าด้วยกัน
- Pattern การเขียนแบบ Canonical ห้าแบบ (cache-aside, read-through, write-through, write-behind, write-around) แลกเปลี่ยนระหว่าง Latency, Durability และ Consistency ในส่วนผสมที่ต่างกัน — เลือกตามสัดส่วน Read/Write และความทนต่อความ Stale
- ผสม Event-based Invalidation กับตาข่ายนิรภัย TTL สั้น ๆ และให้ Cache Key ทุกตัวมีเจ้าของที่บันทึกไว้ชัดเจนเพียงคนเดียว
- HTTP
Cache-Control,ETagและstale-while-revalidateคือ Cache ที่ถูกที่สุดที่คุณมี ส่วน CDN cache tags ก็ขยายแนวคิดเดียวกันไปสู่หน้า Dynamic- ปกป้อง Hot Key ด้วย Negative Caching และ Singleflight เพื่อรอด Stampede และ Route Path แบบ Read-after-write กลับไปที่ Primary เพื่อรอด Replica Lag
- Export hit rate, eviction rate, byte rate และ key cardinality แล้วตั้ง Alert ที่ค่าเบี่ยงเบนจาก Baseline ของคุณเอง — ไม่ใช่ตัวเลข “ดี” ที่คิดขึ้นมาเอง
- Cache ซื้อ Latency ด้วยการจ่าย Consistency ศิลปะคือการทำให้ Trade นั้นมองเห็นได้ในโค้ด
Taxonomy ของที่ตั้ง Cache
ก่อนเลือก Pattern ให้ตัดสินใจก่อนว่า Cache อยู่ที่ไหน แต่ละที่จะเร็วกว่าและแคบกว่าที่อยู่ลำดับล่าง Production System ส่วนใหญ่มักมีหลายชั้นซ้อนกัน
- Browser cache — แยกตาม User ฟรี มองไม่เห็นจาก Infra ของคุณ ควบคุมผ่าน HTTP Headers เป็น “Hit” ที่เร็วที่สุดเพราะ Request ไม่เคยออกจาก Device เลย
- CDN / edge cache — Share ระหว่าง User อยู่ใกล้ ๆ ทางภูมิศาสตร์ เหมาะกับ Static Assets และเริ่มเหมาะกับ Dynamic HTML มากขึ้น Invalidation คือส่วนที่ยาก
- Edge worker cache — Cache ที่เขียนโปรแกรมได้ใน Runtime เดียวกับ Edge Code นั่งอยู่ระหว่าง CDN กับ Origin ทำให้ Cache แบบแยกตาม Route ด้วย Custom Key ได้
- API gateway cache — Response cache แบบหยาบที่ Ingress สะดวกแต่ไม่ยืดหยุ่น เพราะ Gateway แทบไม่เคยเข้าใจ Domain ของคุณ
- In-process / app memory cache — แยกตาม Instance อ่านระดับ Nanosecond ไม่มี Network หายไปเมื่อ Restart และไม่ Consistent ระหว่าง Instance
- Distributed cache (Redis, memcached) — Share ระหว่างทุก App Instance อ่านระดับ Single-millisecond ต้องมี Network Hop ต้องมี Eviction และ Persistence Policies
- Read-replica — Database ที่ใช้เป็น Cache เป็นแบบ Eventually Consistent โปร่งใสต่อ Application ถ้าคุณ Route Read อย่างระมัดระวัง
Request ที่เดินทางจาก Browser ไปจนถึง Database สามารถ Hit ได้ห้าจุดในนี้ก่อนจะถึง Disk งานของกลยุทธ์ Caching คือการตัดสินใจว่า Layer ไหนเป็นเจ้าของข้อมูลชิ้นไหน และจะเกิดอะไรขึ้นเมื่อข้อมูลนั้นเปลี่ยน
Pattern การเขียนทั้งห้า
Cache แตกต่างกันน้อยใน Read Path (look up by key, return value) แต่ต่างกันมากในวิธีการจัดการ Write มี Pattern แบบ Canonical อยู่ห้าแบบ
1. Cache-aside (lazy loading)
Application อ่านจาก Cache ก่อน เมื่อ Miss ก็อ่านจาก Source of Truth ใส่ลง Cache แล้ว Return Write จะตรงไปที่ Source of Truth — Cache จะถูก Update หรือ Invalidate ทีหลัง
async function getUser(id: string): Promise<User> {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.users.findById(id);
await redis.set(`user:${id}`, JSON.stringify(user), "EX", 300);
return user;
}
async function updateUser(id: string, patch: Partial<User>): Promise<void> {
await db.users.update(id, patch);
await redis.del(`user:${id}`); // invalidate, let next read repopulate
}async def get_user(user_id: str) -> User:
cached = await redis.get(f"user:{user_id}")
if cached:
return User.parse_raw(cached)
user = await db.users.find_by_id(user_id)
await redis.set(f"user:{user_id}", user.json(), ex=300)
return user
async def update_user(user_id: str, patch: dict) -> None:
await db.users.update(user_id, patch)
await redis.delete(f"user:{user_id}") # invalidate, let next read repopulateCache-aside เป็น Default ด้วยเหตุผล: เรียบง่าย ทนต่อการที่ Cache ล่ม (ตกไปยัง Database ได้) และโค้ดก็ตรงไปตรงมา Trade-off คือ Stampede บน Cold Key — Request พร้อมกัน N ตัวแรกหลังจาก Miss จะ Query Database ทั้งหมดก่อนที่ตัวใดตัวหนึ่งจะใส่ Cache ได้ เดี๋ยวเราจะแก้ตรงนี้ทีหลัง
2. Read-through
ตัว Cache Client เองจะไป Fetch จาก Source เมื่อ Miss Application เรียกแค่ cache.get(key) Cache Layer รู้วิธีใส่ข้อมูลให้ตัวเอง — มักผ่าน Loader Function ที่ลงทะเบียนตอน Startup
Read-through ดูสะอาดกว่า Cache-aside แต่ย้าย Complexity เข้าไปใน Cache Library คุณจะจ่ายราคานั้นเมื่อ Loader ต้องการ Context ที่เคยอยู่ใน Caller: Tenant ID, Feature Flag, Auth Scope ทีมส่วนใหญ่ที่เริ่มด้วย Read-through สุดท้ายก็ Reinvent Cache-aside กลับมาสำหรับ Key “พิเศษ” อยู่ดี
3. Write-through
ทุก Write ไป Cache ก่อน แล้ว Cache ก็ Write ทะลุไปยัง Source of Truth แบบ Synchronous Read จะ Hit Cache ที่มีข้อมูลอยู่เสมอ
ข้อดี: Read เร็วเสมอ และ Cache กับ Source ไม่เคย Out of Sync เกินกว่า Write ที่ล้มเหลวหนึ่งครั้ง ข้อเสีย: ทุก Write ต้องจ่าย Latency ของสองระบบ และ Cache อยู่บน Critical Path ของ Write Path — ถ้ามันล่ม Write จะล้มเหลว
ใช้ Write-through เมื่อ Read ทับ Write อย่างมโหฬาร และ ไม่สามารถยอมรับความ Stale ของ Cache ได้ — ตารางราคา, Entitlement Flag, Feature Config
4. Write-behind (write-back)
Application Write ไปที่ Cache แล้ว Cache ก็ Flush ไปยัง Source of Truth แบบ Asynchronous ในเวลาต่อมา — แบบ Batch, Coalesce หรือ Schedule
นี่เป็น Pattern การ Write ที่เร็วที่สุดและอันตรายที่สุด Cache ล่มก่อน Flush หมายถึง Write หาย Flush Interval ที่ตั้งผิดหมายถึง Read Replicas Stale อยู่หลายนาที ใช้ก็ต่อเมื่อ Source of Truth เป็น Downstream จริง ๆ (Analytics, Metrics, Logs) และคุณยอมรับการสูญเสียข้อมูลตอน Crash ได้
5. Write-around
Write จะข้าม Cache ไปตรง Source of Truth Cache จะถูกใส่ข้อมูลเฉพาะตอน Read (ผ่าน Cache-aside) มีประโยชน์เมื่อข้อมูลที่ Write น้อยครั้งจะถูกอ่าน — Audit Logs, Event Records, Cold Archives เขียนลง Cache ก็ได้แค่เปลือง Bytes ที่ไม่มีใครจะ Request
เลือกอันไหนดี
Mental Model สั้น ๆ:
- Read หนัก ทนความ Stale ได้ชั่วครู่ → cache-aside
- Read หนัก ทนความ Stale ไม่ได้ → write-through
- Write หนักไปยังระบบ Downstream → write-behind ยอมรับความเสี่ยงเรื่อง Durability
- Write ครั้งเดียว Read น้อย → write-around
- อยากได้โค้ดสะอาดโดยแลกกับความยืดหยุ่น → read-through
Time-based vs Event-based Invalidation
Cache ทุกตัวสุดท้ายต้อง Expire หรือ Invalidate Entry ออก มีสองกลยุทธ์ แต่ละแบบมี Failure Mode ของตัวเอง
Time-based (TTL). แต่ละ Entry หมดอายุหลังเวลาที่กำหนด เรียบง่าย มีขอบเขต และ Self-healing — Bug ใน Logic Invalidation จะทำให้ Stale ได้นานสุดแค่ TTL วินาที ข้อเสียก็ชัดเจน: คุณอาจตั้ง Aggressive เกิน (Miss Rate สูง Origin โดนถล่ม) หรือผ่อนปรนเกิน (User เห็นข้อมูล Stale หลายนาที)
Event-based (explicit invalidation). ทุกครั้งที่มีการ Write Application จะ Invalidate Key ที่เกี่ยวข้องอย่างชัดเจน Freshness ใกล้เคียง Instant ข้อเสียคือ Stale Key Trap: ทุก Write Path ต้องจำทุก Key ที่อาจเก็บ Derived Data รวมถึง Shape ที่ Denormalize, Paginated List, Search Index และ Aggregate Counter พลาดอันใดอันหนึ่ง Cache ก็โกหก
คำตอบที่ Pragmatic คือ ทั้งสอง: Event-based Invalidation เป็นกลไกหลัก ส่วน TTL สั้น ๆ เป็นตาข่ายนิรภัย ถ้าโค้ด Invalidation ของคุณมี Bug ความเสียหายก็จำกัดอยู่ที่ TTL ถ้า TTL อย่างเดียวจะนานเกินไปสำหรับ User Event Path ก็ส่ง Freshness ตอนสำคัญ
มีกฎที่ผมพบว่ามีประโยชน์: Cache Key ทุกตัวควรมีเจ้าของ Function, Module หรือ Service ที่เป็น Writer คนเดียว ถ้ามีโค้ดสามที่เขียน user:123 คุณจะมี Stale Key Incident ในที่สุด และการ Bisect ว่า Path ไหนเขียนผิดคือบ่ายอันน่าทุกข์
ทำ HTTP Caching ให้ถูกต้อง
HTTP Cache คือ Cache ที่ถูกที่สุดที่คุณมี — มันคือ Browser และทุก Proxy ระหว่างมันกับ Origin ของคุณ และคุณไม่ต้องจ่ายอะไรเลย มันก็เป็นอันที่ Misconfigure บ่อยที่สุดด้วย
Header สามตัวที่สำคัญ:
Cache-Control— Directive ใครสามารถ Cache ได้ นานเท่าใด และภายใต้เงื่อนไขใดETag— Fingerprint ของ Content Client จะ Echo มันในIf-None-Matchใน Request ครั้งถัดไป ถ้าไม่เปลี่ยน Server Return304 Not Modifiedโดยไม่มี BodyLast-Modified— เวอร์ชันแบบ Timestamp ของแนวคิดเดียวกัน คู่กับIf-Modified-Sinceอ่อนกว่า ETag แต่คำนวณถูกกว่า
Response Header Block ปกติสำหรับ Resource แบบ Dynamic ที่ Cache ได้จะมีหน้าตาแบบนี้:
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=86400
ETag: "v3-8f2a91c"
Last-Modified: Tue, 03 Mar 2026 14:22:10 GMT
Vary: Accept-Language, Accept-Encoding
อ่านทีละบรรทัด:
public— Cache ใดก็เก็บได้ รวมถึง CDN ที่ Share กันmax-age=60— Browser ถือว่าสด 60 วินาทีs-maxage=600— Cache ที่ Share (CDN) ถือว่าสด 10 นาที Overridemax-ageเฉพาะสำหรับ Shared Cachestale-while-revalidate=86400— ในอีก 24 ชั่วโมง หลังจาก Freshness หมดอายุ Cache สามารถเสิร์ฟเวอร์ชัน Stale ได้ ขณะที่ Revalidate ใน Background User เห็น Bytes เก่า ๆ ทันที User คนถัดไปเห็น Bytes ใหม่ Directive ตัวนี้ตัวเดียวกำจัดหน้าผา “Cache miss = ตอบช้า” ไปได้เกือบหมดETag— Fingerprint ของ Content ถูกกว่าส่ง Body ใหม่Vary: Accept-Language— Cache ต้องเก็บ Variant แยกกันสำหรับแต่ละภาษา ลืมตรงนี้ในหน้าที่ Localize แล้ว User ที่ใช้อังกฤษจะเห็นเนื้อหาไทย
กับดักที่พบบ่อย: Return Cache-Control: no-cache ทั้งที่หมายถึง no-store no-cache อนุญาตให้ Cache ได้แต่ต้อง Revalidate ทุกครั้งที่ใช้ (ก็ยังมีประโยชน์ — Revalidate ด้วย ETag ฟรีถ้า Content ไม่เปลี่ยน) no-store ห้าม Cache เด็ดขาด สับสนสองอันนี้ถ้าไม่ Leak ข้อมูล Sensitive ก็ทำลาย Performance
CDN Caching ของหน้า Dynamic
ขอบเขตใหม่ในห้าปีที่ผ่านมาคือ CDN Cache HTML ไม่ใช่แค่ Asset อีกต่อไป มีกลไกสองแบบที่ทำให้ปฏิบัติได้จริง
Incremental Static Regeneration. Framework (Next.js, Astro, Nuxt, SvelteKit) Pre-render หน้าและ Cache ไว้ที่ Edge หน้า Stale เสิร์ฟทันที Job เบื้องหลัง Regenerate มันใหม่ พฤติกรรมที่ User เห็นคือ stale-while-revalidate ที่ใช้กับหน้าทั้งหน้า
Cache Tags และ On-demand Invalidation. แทนที่จะพึ่ง TTL Framework จะ Tag แต่ละหน้าที่ Render ด้วยข้อมูลที่หน้านั้นพึ่งพา — product:123, author:45, category:shoes เมื่อข้อมูลนั้นเปลี่ยน Write Path เรียก revalidateTag("product:123") แล้วทุกหน้าที่ Cache ไว้ที่มี Tag นั้นจะถูก Purge ทั่วโลกภายในไม่กี่วินาที
นี่คือรูปร่างของการ Revalidate แบบ On-demand จาก API Route:
// app/api/products/[id]/route.ts
import { revalidateTag } from "next/cache";
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
const patch = await req.json();
await db.products.update(params.id, patch);
// Every page that rendered with this tag is purged at the edge.
revalidateTag(`product:${params.id}`);
revalidateTag("product:list"); // the index page listing all products
return Response.json({ ok: true });
}
Mental Model ยังเป็น Write-through อยู่: Write ยังไม่เสร็จจนกว่า Cache จะถูก Invalidate ข้าม Invalidation Call แล้วทุก CDN ในโลกจะเสิร์ฟหน้าเก่าให้คุณอีกหนึ่งชั่วโมงอย่างมีความสุข
Failure Mode ที่ต้องระวัง: Tag Explosion หน้าที่ Tag ด้วย user:<every_user_who_commented> อาจมี Tag เป็นร้อย ถ้า CDN ของคุณคิดเงินต่อ Tag หรือมี Limit Cardinality ของ Tag ให้ออกแบบ Tag ที่ระดับ Aggregate (post:42:comments) แทนระดับต่อ Entity
Local / In-process Cache
Redis ไม่ฟรี ทุกการ Call คือ Network Hop, Serialization, Deserialization และอาจเกิด TCP Reconnect สำหรับข้อมูลที่ Hot, เล็ก และเหมือนกันใน Instance ทั้งหมด — รายการประเทศ, ตารางสกุลเงิน, Feature Flag ที่เปลี่ยนวันละครั้ง — Local Cache เร็วกว่ามาก
import { LRUCache } from "lru-cache";
const countryCache = new LRUCache<string, Country>({
max: 500,
ttl: 1000 * 60 * 10, // 10 minutes
});
export async function getCountry(code: string): Promise<Country> {
const hit = countryCache.get(code);
if (hit) return hit;
const country = await db.countries.findByCode(code);
countryCache.set(code, country);
return country;
}from cachetools import TTLCache
country_cache: TTLCache[str, Country] = TTLCache(maxsize=500, ttl=600)
async def get_country(code: str) -> Country:
if code in country_cache:
return country_cache[code]
country = await db.countries.find_by_code(code)
country_cache[code] = country
return countryTrade-off คือ Consistency: แต่ละ Instance มี Copy ของตัวเอง ดังนั้น Invalidation จึงยาก คำตอบปกติคือ TTL สั้น ๆ บวกกับ Pub/Sub Channel: เมื่อข้อมูลเปลี่ยน Writer Publish Event ทุก Instance Subscribe และล้าง Local Cache ของตัวเอง การผสมสองอันนี้ทำให้ได้ความเร็วของ Local Caching พร้อมกับความ Stale ที่มีขอบเขต
อย่าใส่ข้อมูลแยกตาม User ลงใน Local Cache ยกเว้นว่า Instance จะ Sticky — มิฉะนั้น Hit Rate จะพังและ RAM จะเต็มไปด้วย Duplicate
Distributed Cache Patterns
Redis และ memcached เป็นสองตัวเลือกที่นิยม ทั้งคู่เป็น Key-value Store ที่มี Expiry ความต่างจะสำคัญตอน Scale
Pipelines. ส่ง N Command ใน Round Trip เดียวแทน N รอบ Cache Lookup 20 Call ที่ใช้ 20ms แบบ Serial กลายเป็น ~1ms ถ้า Pipelined Client ส่วนใหญ่รองรับ โค้ดส่วนใหญ่ไม่ใช้ Audit Endpoint ที่ร้อนที่สุดของคุณ — คุณจะเจอ Serial Round-trip ที่ไม่จำเป็นแน่นอน
Cluster และ Hash Tags. Redis Cluster Shard Key ระหว่าง Node ด้วย Hash Command ที่แตะหลาย Key (MGET, Transaction) จะใช้ได้ก็ต่อเมื่อทุก Key อยู่ Shard เดียวกัน วิธีแก้คือ Hash Tags: ห่อส่วนหนึ่งของ Key ด้วย {…} แล้วเฉพาะส่วนนั้นจะถูก Hash user:{42}:profile กับ user:{42}:settings ก็จะลง Shard เดียวกัน
Trade-off ของ Memcached. เรียบง่ายกว่า เร็วกว่าใน GET/SET ล้วน ๆ ไม่มี Persistence ไม่มี Data Structure นอกจาก Opaque Blob ไม่มี Pub/sub เลือกเมื่อคุณต้องการแค่ Cache จริง ๆ และไม่ต้องการอย่างอื่น Redis เติบโตเกินมันในที่ส่วนใหญ่เพราะ Binary เดียวกันก็เป็น Queue, Rate Limiter และ Pub/sub Bus ได้ — ลด Infra ไปอีกชิ้น
Eviction Policy. ภายใต้ความดัน Memory Redis จะ Evict ตาม Policy ที่ Config ไว้ Default ที่อันตรายในบาง Setup คือ noeviction — Write ใหม่จะล้มเหลวเมื่อ Memory เต็ม เลือก allkeys-lru สำหรับ Cache ล้วน เลือก volatile-lru ถ้าคุณเก็บข้อมูลที่ไม่ใช่ Cache ด้วย (Session Token, Lock) ที่มี TTL
Read-replica เป็น Cache
Read Replica ถือเป็น “Cache” ที่ถูกที่สุดที่คุณเพิ่มได้ — Database เข้าใจ Schema, Type, ACL และ Query Planner ของคุณอยู่แล้ว มันก็มาพร้อมกับ Replication Lag: Replica ตามหลัง Primary อยู่ตลอดเวลาในระดับหนึ่ง ตั้งแต่ Microsecond จนถึง Second ขึ้นกับ Load
อันตรายสองอย่าง:
Read-after-write Inconsistency. User แก้ Profile ของตัวเอง Write ไป Primary GET ตัวต่อมา Route ไปยัง Replica ที่ยังไม่ตามทัน User เห็นข้อมูลเก่าของตัวเอง วิธีแก้: Route Read ที่อยู่ใน N วินาทีหลัง Write กลับไปที่ Primary (Session Stickiness) หรือให้ Client อ่านจาก Primary ตลอด Session นั้น
Silent Replica Drift. Replica อาจตามหลังเป็นนาทีภายใต้ Write Load หนัก ๆ โดยไม่มี Alert เลย Monitor replica_lag_seconds และ Alert มัน ถ้า App ของคุณทนได้ห้าวินาที ตั้ง Alert ที่สอง
Read Replica เป็น Cache ที่แย่สำหรับอะไรก็ตามที่ต้อง Consistent ทันที — ยอดเงิน, จำนวน Stock, การตรวจสิทธิ์ — และเป็น Cache ที่ดีสำหรับอะไรก็ตามที่อ่านบ่อยกว่าเขียนมาก ๆ และทนความ Stale ระดับพอสมควรได้ (Dashboard, Listing, Search Result)
Negative Caching และ Stampede Protection
มี Pattern สองตัวที่จะโผล่มาเมื่อ Cache อยู่ภายใต้ Load จริง
Negative Caching. เมื่อ Source of Truth Return “not found” Cache อันนั้นด้วย มิฉะนั้นทุก Request สำหรับ Key ที่ไม่มีจะตกทะลุไปที่ Database Attacker ชอบ Keyspace ที่ไม่จำกัด — เขาจะสร้าง ID สุ่มล้านอันแล้วเปลี่ยน Cache ของคุณให้กลายเป็นสว่านเจาะตรงไป Postgres Cache Miss นั้นด้วย TTL สั้น ๆ
Thundering Herd / Cache Stampede. Key ยอดนิยมหมดอายุ Request พันรายการมาถึงในมิลลิวินาทีถัดไป ทั้งพันคน Miss Cache ทั้งพันคน Query Database Database ล่ม
วิธีแก้คือ Request Coalescing หรือเรียกอีกชื่อว่า Singleflight: Miss แรกได้ Lock, Fetch, ใส่ Cache, ปล่อย Lock อีก 999 รายการรอ Lock แล้วอ่านจาก Cache ตอนที่ปล่อย
นี่คือรูปร่างที่ใช้ Redis รองรับใน Pseudocode:
function get_with_singleflight(key):
value = cache.get(key)
if value is not None:
return value
lock_key = "lock:" + key
acquired = cache.SETNX(lock_key, instance_id, EX=5)
if acquired:
try:
value = source_of_truth.get(key)
cache.set(key, value, EX=300)
return value
finally:
cache.delete(lock_key)
else:
# Another instance is fetching. Wait briefly, then read.
for _ in range(10):
sleep(50ms)
value = cache.get(key)
if value is not None:
return value
# Fallback: fetch ourselves rather than fail.
return source_of_truth.get(key)
รายละเอียดสำคัญ SETNX ที่มี Expiry เป็น Atomic — ถ้า Instance ที่ถือ Lock ตาย Lock ปล่อยตัวเองหลังห้าวินาที Instance ที่รอจะ Poll Cache ไม่ใช่ Lock — เขาต้องการ Value ไม่ใช่ Lock และ Fallback ตอน Timeout คือ Fetch เองแทนที่จะล้ม Request ที่ช้าดีกว่า Error
Variant แบบ In-process (Singleflight ต่อ Instance) ถูกกว่าด้วยซ้ำเมื่อ Hot Key เป็น Global จริง ๆ — Fetch ใน Local แค่ครั้งเดียวต่อ Instance แทนที่จะหนึ่งครั้งต่อ Request Codebase ที่โตเต็มที่ส่วนใหญ่ลงเอยด้วยการมีทั้งสอง Layer
Observe Cache ของคุณ
Cache ที่คุณมองไม่เห็นคือ Cache ที่คุณไว้ใจไม่ได้ มี Signal สี่ตัวที่ต้อง Emit และเฝ้าดู:
- Hit Rate. เปอร์เซ็นต์ของ Read ที่เสิร์ฟจาก Cache Cache ที่มี Hit Rate 20% กำลังกินเงินคุณอยู่ — มันเพิ่ม Network Hop ใน 80% ของ Request เพื่อประหยัดอะไรไม่ได้ในอีก 20% พิจารณาบีบ Scope หรือยก TTL ขึ้น
- Byte Rate / Memory Pressure. Cache เต็มเร็วแค่ไหน ติดตามความเสี่ยงเรื่อง Eviction การกระโดดขึ้นแบบฉับพลันมักหมายถึง Bug เขียนค่ายักษ์ลงไป (Result Set ทั้งชุดแทนที่จะเป็น Row เดียว, Map-of-everything ที่ Serialize ไว้)
- Eviction Rate. Key ที่ Evict ต่อวินาที Eviction ที่ไม่เป็นศูนย์ใน LRU แปลว่าคุณเต็มความจุแล้ว — Hit Rate จะเริ่มถดถอย เพิ่ม Memory หรือบีบสิ่งที่ Cache ลง
- Key Cardinality. จำนวน Distinct Key การโตแบบไม่จำกัดหมายถึง Key มี Input ที่ User ควบคุมได้โดยไม่มี Cap — เส้นทาง Classic ไปสู่ Cache-as-DoS-vector
ตั้ง Alert ที่ Eviction Rate พุ่ง และ Hit Rate ตกลงต่ำกว่า Baseline ทั้งคู่เป็นสัญญาณเตือนล่วงหน้าว่ามีบางอย่างเพิ่งเปลี่ยนในแบบที่ Cache ไม่ได้ออกแบบมารับ — Endpoint ใหม่, Key Template ที่มี Bug, Traffic Shift
อย่าหลงเชื่อตัวเองที่จะคิดตัวเลขขึ้นมาว่า Rate เหล่านี้ “ควร” เป็นเท่าไหร่ มันขึ้นกับ Workload ของคุณทั้งหมด ตั้ง Baseline ของคุณตอนระบบสุขภาพดี Alert ที่ค่าเบี่ยงเบนจาก Baseline นั้น
Decision Tree: Endpoint นี้ช้า
เมื่อมีคนรายงานว่า Endpoint ช้าและ Reflex ของคุณคือเอื้อมไป Redis ให้เดิน Tree นี้ก่อน:
- Bottleneck คือ Data Retrieval จริงหรือ หรือเป็น Computation, Serialization หรือการเรียก Downstream API? Profile ก่อน Cache ผิด Layer เป็นวิธีดี ๆ ที่จะซ่อนปัญหาจริง
- Browser Cache ได้ไหม? ถ้า Response แยกตาม User แต่ไม่เปลี่ยนบ่อย
Cache-Control: private, max-age=N, stale-while-revalidate=M+ETagให้ First-class Cache ฟรี - CDN Cache ได้ไหม? ถ้า Response Share ระหว่าง User (หรือ User ที่ Group ตามภาษา/ภูมิภาค) CDN Caching ด้วย Tag ก็ถูกกว่าและเร็วกว่าอะไรก็ตามที่คุณรันเอง
- ข้อมูลสามารถคำนวณได้ตอน Build/Deploy? Static Rendering คือ Cache ที่เร็วที่สุด — มันคือ Bytes บน Disk ที่ Edge
- ข้อมูล Hot, เล็ก และเหมือนกันสำหรับทุก User? In-process LRU พร้อม TTL สั้น ๆ ชนะ Redis ในด้าน Latency
- ข้อมูล Share ระหว่าง Instance และใหญ่กว่าที่จะใส่ใน Local ได้? ตอนนี้คุณค่อยเอื้อมไปหา Redis ด้วย Cache-aside
- Source of Truth เองเป็นปัญหาหรือ? Read-replica พร้อม Routing ที่ระมัดระวังมักง่ายกว่าการเพิ่ม Cache อีกตัว
- Write-consistency สำคัญกว่า Latency? Write-through เข้า Redis ไม่ใช่ Cache-aside
ทุก Layer ที่คุณข้ามคือ Layer ที่คุณไม่ต้อง Invalidate
สรุปเช็คลิสต์
ก่อน Ship Cache ให้เดินผ่าน List นี้:
- Cache Key ทุกตัวมีเจ้าของที่บันทึกไว้ — Writer คนเดียว Reader หลายคน
- Write Path ทุกอันที่แตะข้อมูลใน Cache ต้อง Invalidate หรือ Update Cache
- TTL ตั้งเป็นตาข่ายนิรภัย ไม่ใช่กลไก Freshness หลัก
Cache-Control,ETagและVaryตั้งไว้ในทุก HTTP Response ที่ Cache ได้ และจงใจไม่ตั้งในอันที่ Cache ไม่ได้- หน้า Dynamic ที่ Cache ที่ CDN มี Tag และทุก Write Path ถือ Tag นั้นใน Invalidation Call
- Singleflight / Request Coalescing ปกป้องทุก Key ที่แพง
- ผลลัพธ์ Negative ถูก Cache ด้วย TTL สั้น
- Hit Rate, Eviction Rate และ Byte Rate ถูก Export เป็น Metric พร้อม Alert ที่ค่าเบี่ยงเบน
- Local In-process Cache Subscribe Cross-instance Invalidation Channel หรือใช้ TTL สั้นพอที่ความ Diverge จะไม่สำคัญ
- Read-replica Routing มี Fallback ไปยัง Primary สำหรับ Read-after-write Path
- มีคนในทีมวาด Cache Layering ทั้งหมดได้ — Browser, CDN, Edge, App, Redis, Replica — บน Whiteboard โดยไม่ลังเล
Caching Layer ที่ออกแบบแบบนี้รอด Traffic Spike, Data Migration และเหตุการณ์ตอนตีสาม อันที่ข้ามขั้นตอนเหล่านี้ทำงานได้ดีใน Staging แล้วสอนคุณ — แพง ๆ — ว่าคุณข้ามขั้นตอนไหนไป
Cache ไม่ใช่ Performance Feature มันคือ Consistency Liability ที่คุณยอมรับเพื่อแลก Latency ศิลปะคือการรู้แน่ ๆ ว่าคุณกำลังยอม Consistency แบบไหนทิ้ง เพื่อแลก Latency แบบไหน ที่ Layer ไหน — และทำให้ Trade นั้นมองเห็นได้ในโค้ดต่อไปนาน ๆ หลังจากที่คุณลืมไปแล้วว่าทำมันไว้ทำไม
อ่านเพิ่มเติม
- Designing Data-Intensive Applications — Martin Kleppmann (2017) บทที่ว่าด้วย Replication, Consistency และ Derived Data คือฐานรากใต้ทุกการตัดสินใจเรื่อง Caching ในโพสต์นี้
- MDN: HTTP caching — Reference Canonical สำหรับ Semantics ของ
Cache-Control,ETag,Varyและstale-while-revalidate - RFC 9111: HTTP Caching — ตัว Spec เอง อ่านง่ายอย่างน่าแปลกใจ และเป็น Source of Truth เมื่อ MDN กับ CDN ของคุณเห็นไม่ตรงกัน
- Redis documentation: eviction policies and clustering — หน้าสั้น ๆ ที่ป้องกัน Production Redis Incident ส่วนใหญ่ได้
- Caching at Netflix: The Hidden Microservice — Talks และ Post เกี่ยวกับ EVCache และ Tiered Caching ในระดับสุดขั้ว เป็น Contrast ที่มีประโยชน์กับ Pattern ข้างต้น