layercache – ไลบรารีแคชหลายเลเยอร์สำหรับ Node.js
(github.com/flyingsquirrel0419)layercache คืออะไร?
เป็นไลบรารีแคชหลายเลเยอร์ที่รวม Memory → Redis → Disk ไว้ภายใต้ API เดียวใน Node.js
เมื่อ cache hit จะดึงค่าจากเลเยอร์ที่เร็วที่สุด และเติมข้อมูลกลับไปยังเลเยอร์ด้านบนโดยอัตโนมัติ หาก miss แม้จะมีคำขอพร้อมกัน 100 รายการ fetcher ก็จะถูกรันเพียงครั้งเดียวเท่านั้น
ทำไมถึงสร้างมันขึ้นมา?
เมื่อดูแลบริการ Node.js วิธีการซ้อนชั้นแคชมักจะมีลำดับคล้ายกัน เริ่มจากแคชในหน่วยความจำ พอจำนวนอินสแตนซ์เพิ่มขึ้นก็เชื่อม Redis จากนั้นก็เจอปัญหา stampede และเกิดความไม่สอดคล้องกันของแคชระหว่างอินสแตนซ์... แต่ละอย่างแก้ได้ก็จริง ทว่านำทั้งหมดมาผูกเข้าด้วยกันในระดับ production กลับใช้แรงมากกว่าที่คิด
จึงสร้างสิ่งนี้ขึ้นมาด้วยแนวคิดว่า ควรทำงานนี้ให้ดีสักครั้งเดียวแล้วจบ
ฟีเจอร์หลักมีอะไรบ้าง?
การทำงานหลัก
- การอ่านแบบหลายเลเยอร์ + backfill อัตโนมัติ (L1 miss → ค้นหา L2 → เติม L1)
- Stampede prevention: คำขอพร้อมกัน 100 รายการ → รัน fetcher 1 ครั้ง
- Distributed single-flight: ใช้ Redis distributed lock เพื่อตัดการรันซ้ำข้ามอินสแตนซ์
- L1 invalidation bus บน Redis pub/sub (ซิงก์แคชหน่วยความจำระหว่างอินสแตนซ์)
การทำให้ใช้ไม่ได้ / ความสดใหม่
- การทำให้ใช้ไม่ได้แบบอิงแท็ก, การทำให้ใช้ไม่ได้แบบ wildcard/prefix
- Stale-while-revalidate, Stale-if-error
- Sliding TTL, Adaptive TTL, Refresh-ahead
ความทนทาน
- Graceful degradation: เมื่อ Redis มีปัญหาจะข้ามเลเยอร์นั้นไปและกู้คืนอัตโนมัติ
- Circuit breaker
- นโยบายการเขียนแบบ Strict / best-effort
การสังเกตการณ์
- Prometheus exporter, OpenTelemetry hooks
- การวัด latency รายเลเยอร์, event hooks
- Admin CLI (
npx layercache stats|keys|invalidate)
การผสานกับเฟรมเวิร์ก
Express, Fastify, Hono, tRPC, GraphQL, Next.js
อยากรู้ตัวเลข benchmark
อ้างอิงจาก VM แบบ single-core + Docker Redis จริง
| สถานการณ์ | latency เฉลี่ย |
| L1 memory warm hit | 0.005 ms |
| L2 Redis warm hit (1 KiB) | 0.193 ms |
| ไม่มีแคช (จำลอง DB) | 5.030 ms |
- HTTP throughput:
/layered16,211 req/s เทียบกับ/nocache158 req/s - Stampede: คำขอพร้อมกัน 75 รายการ → origin fetch 5 ครั้ง (ถ้าไม่มีแคชจะเป็น 375 ครั้ง)
- Distributed single-flight: คำขอพร้อมกัน 60 รายการ → origin fetch 1 ครั้ง
วิธีการ benchmark แบบเต็มและผลลัพธ์ดิบสรุปไว้ที่ docs/benchmarking.md
แตกต่างจากไลบรารีเดิมอย่างไร?
node-cache-manager, keyv, cacheable ล้วนเป็นตัวเลือกที่ดี ถ้าสรุปความต่างแบบสั้น ๆ คือ:
- Stampede prevention / Distributed single-flight: ทั้งสามไลบรารีไม่มีให้มาเป็นค่าเริ่มต้น ส่วน layercache ออกแบบโดยยึดสองอย่างนี้เป็นแกนหลัก
- Cross-instance L1 invalidation: ใช้ Redis pub/sub เพื่อซิงก์แคชหน่วยความจำระหว่างอินสแตนซ์โดยอัตโนมัติ จึงใช้งาน memory cache ได้อย่างมั่นใจในสภาพแวดล้อมแบบหลายอินสแตนซ์
- Auto backfill: เมื่อเลเยอร์ล่าง hit จะเติมข้อมูลกลับไปยังเลเยอร์บนโดยอัตโนมัติ
- Graceful degradation + Circuit breaker: แม้ Redis ล่ม บริการก็ยังทำงานต่อได้
การติดตั้งและลิงก์
npm install layercache
- GitHub: https://github.com/flyingsquirrel0419/layercache
- npm: https://www.npmjs.com/package/layercache
หากมีข้อสงสัยเกี่ยวกับการตัดสินใจด้านการออกแบบ โดยเฉพาะวิธีประสานงาน single-flight หรือพฤติกรรมของ graceful degradation สามารถถามมาได้สบาย ๆ
4 ความคิดเห็น
เป็นไลบรารีที่ดีนะครับ!
มีเหตุผลอะไรที่รวม Redis ไว้ในดีไซน์หรือเปล่าครับ? กำลังสมมติสถานการณ์ที่มีอินสแตนซ์สำหรับอ่านหลายตัวถูกเปิดขึ้นพร้อมกันใช่ไหม? ถ้าอย่างนั้น (local) Disk ก็ควรถูกวางไว้เป็นเลเยอร์หน้าก่อน Redis หรือเปล่าครับ?
เหตุที่มี Redis รวมอยู่ด้วยก็เพราะสมมติว่ามีเซิร์ฟเวอร์หลายเครื่องอยู่ในระบบ เนื่องจากหน่วยความจำของแต่ละเซิร์ฟเวอร์อาจมีค่าคนละชุดกัน Redis จึงทำหน้าที่เป็น "แหล่งข้อมูลจริงร่วมกัน"
ที่ Disk มาอยู่หลัง Redis ก็เพราะภายใต้สมมติฐานว่า Redis อยู่ใน local network เดียวกัน Redis จะเร็วกว่า ตามค่า benchmark แล้ว Disk อยู่ที่ประมาณ ~2ms ส่วน Redis อยู่ที่ประมาณ ~0.02ms แต่ถ้า Redis อยู่ไกลหรือเครือข่ายไม่ดี local Disk ก็อาจเร็วกว่าได้ และในกรณีนั้นการสลับลำดับก็ถือว่าเหมาะสม ตัวไลบรารีเองก็ไม่ได้บังคับลำดับ แต่เป็นโครงสร้างที่ให้ผู้ใช้กำหนดเองโดยตรง
ไม่ว่า Disk จะอยู่ที่ไหน จุดประสงค์หลักก็ไม่ใช่การแข่งเรื่องความเร็ว แต่คือการเป็นประกันด่านสุดท้ายที่ยังอยู่รอดเมื่อทั้ง Memory และ Redis ล่มทั้งหมด
ขอบคุณสำหรับเจตนาในการออกแบบครับ
หมายความว่าคุณบันทึกการเรียกใช้งานระยะไกลทั้งหมดเป็นการเขียนลงดิสก์ภายในเครื่อง แล้วเมื่อการเรียกใช้งานระยะไกลล้มเหลวก็จะอ่านจากดิสก์ใช่ไหมครับ? น่าจะดีถ้าลองพิจารณาด้วยว่าในเลเยอร์แคชจำเป็นต้องมี Disk เสมอไปหรือไม่
DiskLayer ไม่ได้เป็นแพตเทิร์นแบบนั้น แต่ทำงานเป็นเลเยอร์แคชทั่วไป คือทั้งอ่านและเขียน และถ้าชั้นบน miss ก็จะไล่เข้าถึงตามลำดับครับ — ทำให้สับสน ต้องขออภัยด้วย
แพตเทิร์นที่คุณพูดถึงอย่าง "เก็บผลลัพธ์ของ remote call ลงดิสก์แล้วค่อยอ่านเมื่อเกิดความล้มเหลว" จริง ๆ แล้วจะใกล้กับออปชัน stale-if-error มากกว่า แต่แบบนั้นเก็บไว้ในหน่วยความจำ พอรีสตาร์ตโปรเซสก็หายไปครับ
ส่วนประเด็นที่ว่าจำเป็นต้องมี DiskLayer ไหม อืม ในสภาพแวดล้อมแบบหลายอินสแตนซ์ส่วนใหญ่จริง ๆ แล้วแค่ Memory → Redis ก็เพียงพอแล้ว และทันทีที่มี Disk เข้ามาเป็นเลเยอร์ ก็จะมีทั้งต้นทุนจากการ serialize และความซับซ้อนในการจัดการไฟล์ตามมาครับ