Caching design pattern กับ backend

/ 7 min read

Share on social media

cache-design-pattern สามารถดู 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

Terminal window
# ทำการ 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 แทน

DatabaseCacheClientDatabaseCacheClientalt[Cache Miss][Cache Hit]Check CacheCache Miss?Cache MissLoad DataDataUpdate CacheCache HitRetrieve Data

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 ไปด้วยหรือไม่

DatabaseCacheClientDatabaseCacheClientWrite cacheRead dataalt[Cache Miss][Cache Hit]Write DataUpdate CacheUpdate DatabaseDatabase UpdatedWrite CompleteCheck CacheCache Miss?Cache MissLoad DataDataCache HitRetrieve Data

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 ไปด้วยหรือไม่

DatabaseCacheClientDatabaseCacheClientWrite cache (update data)Write back to DatabaseRead dataalt[Cache Miss][Cache Hit]Write DataUpdate CacheWrite Complete (Ack)Update Database (Deferred)Database Updated (Ack)Check CacheCache Miss?Cache MissLoad DataDataCache HitRetrieve Data

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

Related Post

Share on social media