Caching design pattern กับ backend
[ สามารถดู 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:
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 mysql
const initMySQL = async () => {
conn = await mysql.createConnection({
host: "localhost",
user: "root",
password: "root",
database: "tutorial",
});
};
// function init connection redis
const 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 ใน docker
docker 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 สำหรับ get
app.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)