keyboard_tabSkip to content
arrow_back All Posts

Aug 23, 2023

Caching design pattern กับ backend

บทความนี้จะเล่าเรื่อง Cache Pattern 3 แบบ lazy loading, write through และ write back ว่าเราสามารถเอา cache ไปใช้คู่กับ database ยังไงได้บ้าง

สารบัญ

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
plain
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

javascript
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

bash
# ทำการ 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 แทน
Loading diagram…

Backend code

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

Express code

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

Express code

javascript
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

forumComments (0)

lockLog in to join the conversation.

Log In

Loading comments…