Aug 23, 2023
Caching design pattern กับ backend
บทความนี้จะเล่าเรื่อง Cache Pattern 3 แบบ lazy loading, write through และ write back ว่าเราสามารถเอา cache ไปใช้คู่กับ database ยังไงได้บ้าง
สารบัญ
สามารถดู 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 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-cliPattern ตัวที่ผมจะหยิบมาเล่าจะมีทั้งหมด 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)

