Caching design pattern กับ backend
/ 7 min read
สามารถดู video ของหัวข้อนี้ก่อนได้ ดู video
(เอกสารฉบับนี้เป็นเอกสารประกอบ video เรื่อง cache pattern)
Content นี้สนับสนุนโดย เหล่าผู้ตามของ Mikelopster (มีคนขอมาว่าอยากให้ express กับ cache) มันก็เลยเป็นหน้าที่ของผมที่จะต้องมาตอบสิ่งนี้ในช่องของเรา
Cache คืออะไร ?
Cache คือสถานที่เก็บข้อมูลชั่วคราวเพื่อให้สามารถ access ได้ไวขึ้น
- ปกติมักจะใช้กับข้อมูล “ที่มีการเรียกใช้บ่อย” เช่น
- content หน้าแรก
- ข้อมูลสินค้าที่มีการเรียกใช้บ่อยๆ
ทีนี้ประเด็นสำคัญมันอยู่ที่ว่า
- เราควร update cache ตอนไหน ? เพื่อให้ user สามารถเห็นข้อมูลได้เป็นปัจจุบันมากที่สุด
- เราจะมาพูดถึง Caching design patterns ผ่าน code ของ express, mysql (ที่ใช้เพราะมีอยู่ใน course เผื่อใครงงจะได้ไปดู course ผมเอง) และ DB Redis กัน
Redis คือ DB ที่ใช้สำหรับเก็บ cache
- ปกติการอ่าน cache จากแรมจะเร็วกว่าจาก disk อยู่แล้ว (พวกอ่านจาก disk ก็เช่น DB ทั่วๆไปนั่นแหละ) ดังนั้นเราก็จะ setup Redis เป็นแบบ in memory เป็น default ไป
- เราจะใช้ docker-compose ในการสร้าง mysql, phpmyadmin และ redis
- เพื่อให้ง่ายต่อการเล่า เราจะสมมุติว่า cache คือ container ก้อนเดียวไป
setup docker-compose
นี่คือ docker-compose.yml ที่เราจะใช้ในการทดลองนี้
- mysql run ที่ 3306
- phpmyadmin run ที่ 8080
- redis run ที่ 6379
version: "3.7"
services: db: image: mysql:latest container_name: mysql_db command: --default-authentication-plugin=mysql_native_password environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: tutorial ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql
phpmyadmin: image: phpmyadmin/phpmyadmin:latest container_name: phpmyadmin environment: PMA_HOST: db PMA_PORT: 3306 PMA_USER: root PMA_PASSWORD: root ports: - "8080:80" depends_on: - db
redis: image: redis:latest container_name: redis-container ports: - "6379:6379"
volumes: mysql_data: driver: local
เริ่มต้น code เราจะ setup connect mysql, redis เอาไว้ทั้งหมด เราจะใช้ file เดียว index.js
ในการทดลองจะมีข้อมูลประมาณนี้อยู่ใน index.js
const express = require("express");const bodyparser = require("body-parser");const mysql = require("mysql2/promise");const redis = require("redis");const cron = require("node-cron");
const app = express();
app.use(bodyparser.json());
const port = 8000;
let conn = null;let redisConn = null;
// function init connection mysqlconst initMySQL = async () => { conn = await mysql.createConnection({ host: "localhost", user: "root", password: "root", database: "tutorial", });};
// function init connection redisconst initRedis = async () => { redisConn = redis.createClient(); redisConn.on("error", (err) => console.log("Redis Client Error", err)); await redisConn.connect();};
// เคสแบบไม่ใช้ cache สำหรับดึง users ทั้งหมดออกมาapp.get("/users", async (req, res) => { const results = await conn.query("SELECT * FROM users"); res.json(results[0]);});
/*เพื่อให้เอกสารอ่านง่ายขึ้น code ด้านล่างต่อจากนี้ เราจะเพิ่ม code เพียงแค่บริเวณนี้เท่านั้น สำหรับการทดลองนี้ เท่านั้น*/
app.listen(port, async (req, res) => { await initMySQL(); await initRedis(); console.log("http server run at " + port);});
วิธีเข้า redis-cli ใช้งานโดยเข้าไปยัง container ของ redis
# ทำการ access เข้า redis container ใน dockerdocker exec -it redis-container
# ใน container redis$ redis-cli
Pattern ตัวที่ผมจะหยิบมาเล่าจะมีทั้งหมด 3 ตัว
1. Lazy loading (Cache-Aside)
- จังหวะเขียน = ไม่เขียน cache (เขียน DB ปกติ)
- จังหวะที่อ่าน
- เช็คก่อนว่ามี cache ไหม ?
- ถ้าไม่มี = อ่าน DB และเขียน cache
- ถ้ามี = อ่านจาก cache แทน
Backend code
app.get("/users/cache-1", async (req, res) => { try { const cachedData = await redisConn.get("users");
if (cachedData) { // มี cache = ใช้ cache ก่อนไปเลย res.json(JSON.parse(cachedData)); return; }
// User data not found in cache, fetch from MySQL const [results] = await conn.query("SELECT * FROM users"); // Store user data in Redis cache await redisConn.set("users", JSON.stringify(results)); res.json(results); } catch (error) { console.error("Error:", error); res.status(500).json({ error: "An error occurred" }); }});
2. Write-through
- จังหวะที่เขียน ทุกครั้งที่มีการเขียนข้อมูล = save cache ด้วยกันเสมอ (เพื่อให้ได้ข้อมูลสดใหม่ตลอดเวลา)
- จังหวะที่อ่าน
- ถ้าไม่มี = อ่าน DB
- ถ้ามี = อ่านจาก cache แทน
- จริงๆจะใช้แบบเดียวกับข้อที่ 1 ก็ได้ไม่ผิดเหมือนกัน ขึ้นอยู่กับว่าจะ handle เรื่อง cache ไปด้วยหรือไม่
Express code
// api สำหรับ getapp.get("/users/cache-2", async (req, res) => { try { const cachedData = await redisConn.get("users-2"); if (cachedData) { // มี cache = ใช้ cache ก่อนไปเลย res.json(JSON.parse(cachedData)); return; }
// User data not found in cache, fetch from MySQL const [results] = await conn.query("SELECT * FROM users"); res.json(results); } catch (error) { console.error("Error:", error); res.status(500).json({ error: "An error occurred" }); }});
// api สำหรับเพิ่ม users + เขียนลง cache เพิ่มapp.post("/users", async (req, res) => { try { let user = req.body; // insert ข้อมูลใส่ database const [results] = await conn.query("INSERT INTO users SET ?", user); user.id = results.insertId;
// เขียน cache ใหม่ด้วย let cachedData = await redisConn.get("users-2"); let newData = [];
if (cachedData) { const loadDataCache = JSON.parse(cachedData); newData = loadDataCache.concat(user); await redisConn.set("users-2", JSON.stringify(newData)); } else { const [results] = await conn.query("SELECT * FROM users"); await redisConn.set("users-2", JSON.stringify(results)); }
res.json({ message: "insert ok", dataAdded: user, }); } catch (error) { console.error("Error:", error); res.status(500).json({ error: "An error occurred" }); }});
3. Write back
- จังหวะที่เขียน เขียน Cache ก่อนเสมอ
- scheduler มีตัวสำหรับ set เวลาเอาไว้ update กลับ DB หลัก (จาก cache) เพื่อให้ Database สามารถ update ข้อมูลจาก cache กลับมาได้
- จังหวะที่อ่าน
- ถ้าไม่มี = อ่าน DB
- ถ้ามี = อ่านจาก cache แทน
- จริงๆจะใช้แบบเดียวกับข้อที่ 1 ก็ได้ไม่ผิดเหมือนกัน ขึ้นอยู่กับว่าจะ handle เรื่อง cache ไปด้วยหรือไม่
Express code
app.get("/users/cache-3", async (req, res) => { try { const cachedData = await redisConn.get("users-3"); if (cachedData) { // มี cache = ใช้ cache ก่อนไปเลย res.json(JSON.parse(cachedData)); return; }
// User data not found in cache, fetch from MySQL const [results] = await conn.query("SELECT * FROM users"); res.json(results); } catch (error) { console.error("Error:", error); res.status(500).json({ error: "An error occurred" }); }});
app.put("/users/:id", async (req, res) => { try { let user = req.body; let id = parseInt(req.params.id); user.id = id;
// เขียน cache ใหม่ด้วย let cachedData = await redisConn.get("users-3"); let updateIndexList = (await redisConn.get("update-users-3")) || [];
let updateData = ""; if (cachedData) { const loadDataCache = JSON.parse(cachedData); let selectedIndex = loadDataCache.findIndex((user) => user.id === id); loadDataCache[selectedIndex] = user; updateIndexList.push(selectedIndex); updateData = loadDataCache; } else { const [results] = await conn.query("SELECT * FROM users"); let selectedIndex = results.findIndex((user) => user.id === id); results[selectedIndex] = user; updateIndexList.push(selectedIndex); updateData = results; }
await redisConn.set("users-3", JSON.stringify(updateData)); await redisConn.set("update-users-3", JSON.stringify(updateIndexList));
res.json({ message: "update ok", dataAdded: user, }); } catch (error) { console.error("Error:", error); res.status(500).json({ error: "An error occurred" }); }});
let waiting = false;cron.schedule("*/10 * * * * *", async () => { console.log("running every 10 seconds", waiting); try { if (waiting) { return; } const cachedData = await redisConn.get("users-3"); const updateIndexListCached = await redisConn.get("update-users-3");
if (updateIndexListCached) { waiting = true; const updateIndexList = JSON.parse(updateIndexListCached); const userData = JSON.parse(cachedData);
for (let i = 0; i < updateIndexList.length; i++) { const index = updateIndexList[i]; const selectedUser = userData[index];
const id = selectedUser.id; const updateUser = { name: selectedUser.name, age: selectedUser.age, description: selectedUser.description, };
const [results] = await conn.query("UPDATE users SET ? WHERE id = ?", [updateUser, id]);
console.log("=== update complete !", results); }
await redisConn.del("update-users-3"); waiting = false; } } catch (error) { console.error("Error:", error); res.status(500).json({ error: "An error occurred" }); }});
Scenario ที่เหมาะสม (ตามประสบการณ์ผมด้วย)
1. Lazy loading (Cache-Aside)
- เหมาะสำหรับเคสที่มีโหลดที่มีการเรียกใช้่บ่อยๆ เช่น load campaign, load สินค้าหน้าแรก ที่่คนดูจะเข้ามาดูบ่อยมากๆ
2. Write-through
- ใช้ได้กับเคสเหมือนกับวิธีแรก แต่เหมาะกับกรณีที่มีการ write ไม่บ่อย (update วันละไม่กี่ครั้ง) เนื่องจากมี write latency (เพิ่มระยะเวลาช่วง write เข้ามา)
3. Write back
- ใช้ได้กับเคสที่ต้องการแยก performance ระหว่าง Load ด้านหน้า กับการจัดการ Database (hit database จะเกิดขึ้นแบบ async ทำให้ฝั่งหน้าเว็บ ไม่เสียเวลารอในการจัดการกับ database)
- (ส่วนตัว) ยังไม่เคยใช้วิธีนี้
สิ่งที่เวลาใช้ cache ควรทำเพิ่มเติมอื่นๆ
- set Time to live: เพื่อบอกว่า cache หมดเวลาเท่าไหร่
- Replacement strategy, eviction policy (การแทนที่ cache เมื่อ cache เต็ม)
Code ทั้งหมด (github)
Reference
- https://aws.amazon.com/caching/best-practices/
- https://medium.com/@genchilu/cache-strategy-in-backend-d0baaacd2d79
- https://www.notaboutcode.com/post/31-caching/
- https://www.somkiat.cc/basic-of-caching/
Related Post
- มาเรียนรู้การทำ Frontend Testing ผ่าน React กันมี Video
แนะนำพื้นฐานการทำ Component Testing สำหรับการทำ Unit testing ฝั่ง Frontend
- มาแก้ปัญหา Firestore กับปัญหาราคา Read pricing สุดจี๊ดมี Video มี Github
ในฐานะที่เป็นผู้ใช้ Firebase เหมือนกัน เรามาลองชวนคุยกันดีกว่า ว่าเราจะสามารถหาวิธีลด Pricing หรือจำนวนการ read ของ Firestore ได้ยังไงกันบ้าง
- Code Quality & Security with SonarQubeมี Video
แนะนำพื้นฐานการทำ Sonarqube Code พร้อม Code Quality & Security
- รู้จักกับ Drizzle ORM ผ่าน Next.jsมี Video
มาทำความรู้จัก Drizzle ORM กัน ว่ามันคืออะไร และทำไมถึงเป็นที่นิยมในวงการนักพัฒนา และลองเล่นกับ Next.js ด้วยกัน