Caching design pattern กับ backend 23 สิงหาคม 2566 / 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
command : --default-authentication-plugin=mysql_native_password
MYSQL_ROOT_PASSWORD : root
- mysql_data:/var/lib/mysql
image : phpmyadmin/phpmyadmin:latest
container_name : phpmyadmin
container_name : redis-container
เริ่มต้น 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 " );
app. use (bodyparser. json ());
// function init connection mysql
const initMySQL = async () => {
conn = await mysql. createConnection ({
// 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 " );
เพื่อให้เอกสารอ่านง่ายขึ้น code ด้านล่างต่อจากนี้ เราจะเพิ่ม code เพียงแค่บริเวณนี้เท่านั้น สำหรับการทดลองนี้ เท่านั้น
app. listen (port, async ( req , res ) => {
console. log ( " http server run at " + port);
วิธีเข้า redis-cli ใช้งานโดยเข้าไปยัง container ของ redis
# ทำการ access เข้า redis container ใน docker
docker exec -it redis-container
Pattern ตัวที่ผมจะหยิบมาเล่าจะมีทั้งหมด 3 ตัว
1. Lazy loading (Cache-Aside)
จังหวะเขียน = ไม่เขียน cache (เขียน DB ปกติ)
จังหวะที่อ่าน
เช็คก่อนว่ามี cache ไหม ?
ถ้าไม่มี = อ่าน DB และเขียน cache
ถ้ามี = อ่านจาก cache แทน
Database Cache Client Database Cache Client alt [Cache Miss] [Cache Hit] Check Cache Cache Miss? Cache Miss Load Data Data Update Cache Cache Hit Retrieve Data
Backend code
app. get ( " /users/cache-1 " , async ( req , res ) => {
const cachedData = await redisConn. get ( " users " );
// มี cache = ใช้ cache ก่อนไปเลย
res. json ( JSON . parse (cachedData));
// 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));
console. error ( " Error: " , error);
res. status ( 500 ). json ({ error : " An error occurred " });
2. Write-through
จังหวะที่เขียน ทุกครั้งที่มีการเขียนข้อมูล = save cache ด้วยกันเสมอ (เพื่อให้ได้ข้อมูลสดใหม่ตลอดเวลา)
จังหวะที่อ่าน
ถ้าไม่มี = อ่าน DB
ถ้ามี = อ่านจาก cache แทน
จริงๆจะใช้แบบเดียวกับข้อที่ 1 ก็ได้ไม่ผิดเหมือนกัน ขึ้นอยู่กับว่าจะ handle เรื่อง cache ไปด้วยหรือไม่
Database Cache Client Database Cache Client Write cache Read data alt [Cache Miss] [Cache Hit] Write Data Update Cache Update Database Database Updated Write Complete Check Cache Cache Miss? Cache Miss Load Data Data Cache Hit Retrieve Data
Express code
app. get ( " /users/cache-2 " , async ( req , res ) => {
const cachedData = await redisConn. get ( " users-2 " );
// มี cache = ใช้ cache ก่อนไปเลย
res. json ( JSON . parse (cachedData));
// User data not found in cache, fetch from MySQL
const [ results ] = await conn. query ( " SELECT * FROM users " );
console. error ( " Error: " , error);
res. status ( 500 ). json ({ error : " An error occurred " });
// api สำหรับเพิ่ม users + เขียนลง cache เพิ่ม
app. post ( " /users " , async ( req , res ) => {
// insert ข้อมูลใส่ database
const [ results ] = await conn. query ( " INSERT INTO users SET ? " , user);
user.id = results.insertId;
let cachedData = await redisConn. get ( " users-2 " );
const loadDataCache = JSON . parse (cachedData);
newData = loadDataCache. concat (user);
await redisConn. set ( " users-2 " , JSON . stringify (newData));
const [ results ] = await conn. query ( " SELECT * FROM users " );
await redisConn. set ( " users-2 " , JSON . stringify (results));
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 ไปด้วยหรือไม่
Database Cache Client Database Cache Client Write cache (update data) Write back to Database Read data alt [Cache Miss] [Cache Hit] Write Data Update Cache Write Complete (Ack) Update Database (Deferred) Database Updated (Ack) Check Cache Cache Miss? Cache Miss Load Data Data Cache Hit Retrieve Data
Express code
app. get ( " /users/cache-3 " , async ( req , res ) => {
const cachedData = await redisConn. get ( " users-3 " );
// มี cache = ใช้ cache ก่อนไปเลย
res. json ( JSON . parse (cachedData));
// User data not found in cache, fetch from MySQL
const [ results ] = await conn. query ( " SELECT * FROM users " );
console. error ( " Error: " , error);
res. status ( 500 ). json ({ error : " An error occurred " });
app. put ( " /users/:id " , async ( req , res ) => {
let id = parseInt (req.params.id);
let cachedData = await redisConn. get ( " users-3 " );
let updateIndexList = ( await redisConn. get ( " update-users-3 " )) || [];
const loadDataCache = JSON . parse (cachedData);
let selectedIndex = loadDataCache. findIndex (( user ) => user.id === id);
loadDataCache[selectedIndex] = user;
updateIndexList. push (selectedIndex);
updateData = loadDataCache;
const [ results ] = await conn. query ( " SELECT * FROM users " );
let selectedIndex = results. findIndex (( user ) => user.id === id);
results[selectedIndex] = user;
updateIndexList. push (selectedIndex);
await redisConn. set ( " users-3 " , JSON . stringify (updateData));
await redisConn. set ( " update-users-3 " , JSON . stringify (updateIndexList));
console. error ( " Error: " , error);
res. status ( 500 ). json ({ error : " An error occurred " });
cron. schedule ( " */10 * * * * * " , async () => {
console. log ( " running every 10 seconds " , waiting);
const cachedData = await redisConn. get ( " users-3 " );
const updateIndexListCached = await redisConn. get ( " update-users-3 " );
if (updateIndexListCached) {
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;
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 " );
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