Workshop: Redis Caching Layer Hands-on

In-Memory Data Store อย่าง Redis ที่ได้รับความนิยมอย่างมากในการทำ Caching Layer สำหรับแอปพลิเคชัน การเพิ่ม Cache ช่วยลดภาระของฐานข้อมูลหลัก ลดเวลาตอบสนอง และเพิ่มขีดความสามารถในการรองรับผู้ใช้จำนวนมาก

Workshop นี้จะพาคุณติดตั้งระบบ Cache บน Cloud VPS สร้าง Caching Layer สำหรับแอปพลิเคชัน Node.js เรียนรู้กลยุทธ์การ Cache แบบต่าง ๆ และปรับแต่งเพื่อใช้งานจริงใน Production

สิ่งที่ต้องเตรียม

  • Cloud VPS ที่ใช้ Ubuntu 22.04 LTS พร้อม root access
  • Node.js 18+ ติดตั้งแล้ว
  • ฐานข้อมูลหลัก (MySQL หรือ PostgreSQL) ที่มีข้อมูลอยู่แล้ว
  • RAM อย่างน้อย 1 GB (Redis ทำงานบน Memory)

ขั้นตอนที่ 1 — ติดตั้ง Cache Server

# อัพเดตระบบ
sudo apt update && sudo apt upgrade -y

# ติดตั้งตัว cache server
sudo apt install redis-server -y

# ตรวจสอบสถานะ
sudo systemctl status redis-server

# ตรวจสอบเวอร์ชัน
redis-server --version

# ทดสอบ connection
redis-cli ping
# ควรได้ PONG

ขั้นตอนที่ 2 — ตั้งค่าความปลอดภัย

# แก้ไขค่าที่ config file
sudo nano /etc/redis/redis.conf

# ตั้งค่า password (หา requirepass แล้ว uncomment)
requirepass YourRedisPassword123!

# จำกัดให้เข้าถึงจาก localhost เท่านั้น
bind 127.0.0.1 ::1

# ปิด dangerous commands
rename-command FLUSHDB ""
rename-command FLUSHALL ""
rename-command CONFIG ""

# จำกัด memory สูงสุด
maxmemory 256mb
maxmemory-policy allkeys-lru

# Restart service
sudo systemctl restart redis-server

# ทดสอบ connection พร้อม password
redis-cli -a YourRedisPassword123! ping

ขั้นตอนที่ 3 — ทำความเข้าใจ Cache Strategies

ก่อนเขียนโค้ดควรเข้าใจกลยุทธ์การ Cache หลัก ๆ ที่ใช้กันทั่วไป:

Cache-Aside (Lazy Loading) — แอปพลิเคชันตรวจสอบ Cache ก่อน ถ้าไม่พบ (Cache Miss) จะดึงจากฐานข้อมูลแล้วเก็บลง Cache เหมาะสำหรับข้อมูลที่อ่านบ่อยแต่เขียนไม่บ่อย

Write-Through — เมื่อเขียนข้อมูล ระบบจะเขียนทั้ง Cache และฐานข้อมูลพร้อมกัน ทำให้ Cache ตรงกับข้อมูลจริงเสมอ แต่เพิ่ม Latency ตอนเขียน

Write-Behind (Write-Back) — เขียนลง Cache ก่อน แล้วค่อย sync กลับไปยังฐานข้อมูลทีหลัง ช่วยให้เขียนเร็วมาก แต่มีความเสี่ยงที่ข้อมูลจะหายถ้า Cache ล่ม

TTL (Time-To-Live) — กำหนดอายุของ Cache แต่ละ Key เมื่อหมดอายุจะถูกลบอัตโนมัติ ช่วยป้องกันข้อมูลเก่าค้างใน Cache

ขั้นตอนที่ 4 — ตั้งค่าโปรเจค Node.js

# สร้างโปรเจค
mkdir -p /var/www/cache-app && cd /var/www/cache-app
npm init -y

# ติดตั้ง packages
npm install express ioredis pg dotenv

# สร้างโครงสร้างโฟลเดอร์
mkdir -p src/{config,middleware,routes,services}

# สร้าง .env
cat > .env << EOF
PORT=3000
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=YourRedisPassword123!
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_db
DB_USER=app_user
DB_PASSWORD=YourDBPassword123!
CACHE_TTL=3600
EOF

ขั้นตอนที่ 5 — สร้าง Cache Connection

// src/config/redis.js
const Redis = require('ioredis');
require('dotenv').config();

const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: parseInt(process.env.REDIS_PORT),
  password: process.env.REDIS_PASSWORD,
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    const delay = Math.min(times * 50, 2000);
    return delay;
  },
});

redis.on('connect', () => {
  console.log('Connected to cache server');
});

redis.on('error', (err) => {
  console.error('Cache error:', err.message);
});

module.exports = redis;

ขั้นตอนที่ 6 — สร้าง Cache Service

Cache Service เป็นตัวกลางที่จัดการ Cache ทุกอย่าง ทำให้โค้ดส่วนอื่นเรียกใช้งานได้ง่าย

// src/services/cacheService.js
const redis = require('../config/redis');

const DEFAULT_TTL = parseInt(process.env.CACHE_TTL) || 3600;

const cacheService = {
  // ดึงข้อมูลจาก Cache
  async get(key) {
    const data = await redis.get(key);
    return data ? JSON.parse(data) : null;
  },

  // เก็บข้อมูลลง Cache พร้อม TTL
  async set(key, data, ttl = DEFAULT_TTL) {
    await redis.setex(key, ttl, JSON.stringify(data));
  },

  // ลบ Cache ตาม key
  async del(key) {
    await redis.del(key);
  },

  // ลบ Cache ตาม pattern
  async delPattern(pattern) {
    const keys = await redis.keys(pattern);
    if (keys.length > 0) {
      await redis.del(...keys);
    }
    return keys.length;
  },

  // Cache-Aside pattern
  async getOrSet(key, fetchFn, ttl = DEFAULT_TTL) {
    const cached = await this.get(key);
    if (cached) {
      return { data: cached, source: 'cache' };
    }

    const fresh = await fetchFn();
    await this.set(key, fresh, ttl);
    return { data: fresh, source: 'database' };
  },

  // ดู Cache stats
  async getStats() {
    const info = await redis.info('stats');
    const memory = await redis.info('memory');
    const keyspace = await redis.info('keyspace');
    return { info, memory, keyspace };
  },
};

module.exports = cacheService;

ขั้นตอนที่ 7 — สร้าง Cache Middleware

// src/middleware/cacheMiddleware.js
const cacheService = require('../services/cacheService');

function cacheMiddleware(keyPrefix, ttl) {
  return async (req, res, next) => {
    const cacheKey = `${keyPrefix}:${req.originalUrl}`;

    try {
      const cached = await cacheService.get(cacheKey);
      if (cached) {
        return res.json({
          ...cached,
          _cache: { hit: true, key: cacheKey },
        });
      }

      // เก็บ original json method ไว้
      const originalJson = res.json.bind(res);
      res.json = async (data) => {
        // เก็บลง Cache ก่อนส่ง response
        await cacheService.set(cacheKey, data, ttl);
        return originalJson({
          ...data,
          _cache: { hit: false, key: cacheKey },
        });
      };

      next();
    } catch (err) {
      console.error('Cache middleware error:', err.message);
      next();
    }
  };
}

module.exports = cacheMiddleware;

ขั้นตอนที่ 8 — สร้าง API พร้อม Cache

// src/routes/products.js
const express = require('express');
const db = require('../config/database');
const cacheService = require('../services/cacheService');
const cacheMiddleware = require('../middleware/cacheMiddleware');
const router = express.Router();

// GET /api/products — ใช้ Cache Middleware
router.get('/', cacheMiddleware('products', 1800), async (req, res, next) => {
  try {
    const { page = 1, limit = 20 } = req.query;
    const offset = (page - 1) * limit;

    const { rows } = await db.query(
      'SELECT * FROM products ORDER BY id LIMIT $1 OFFSET $2',
      [limit, offset]
    );

    const { rows: countRows } = await db.query(
      'SELECT COUNT(*) FROM products'
    );

    res.json({
      data: rows,
      total: parseInt(countRows[0].count),
      page: parseInt(page),
    });
  } catch (err) {
    next(err);
  }
});

// GET /api/products/:id — ใช้ Cache-Aside pattern
router.get('/:id', async (req, res, next) => {
  try {
    const cacheKey = `product:${req.params.id}`;

    const result = await cacheService.getOrSet(
      cacheKey,
      async () => {
        const { rows } = await db.query(
          'SELECT * FROM products WHERE id = $1',
          [req.params.id]
        );
        if (rows.length === 0) throw new Error('Not found');
        return rows[0];
      },
      3600
    );

    res.json({
      ...result.data,
      _source: result.source,
    });
  } catch (err) {
    if (err.message === 'Not found') {
      return res.status(404).json({ error: 'Product not found' });
    }
    next(err);
  }
});

// POST /api/products — สร้างใหม่ + ล้าง Cache
router.post('/', async (req, res, next) => {
  try {
    const { name, price, description } = req.body;
    const { rows } = await db.query(
      `INSERT INTO products (name, price, description)
       VALUES ($1, $2, $3) RETURNING *`,
      [name, price, description]
    );

    // ล้าง Cache ที่เกี่ยวข้อง
    await cacheService.delPattern('products:*');

    res.status(201).json(rows[0]);
  } catch (err) {
    next(err);
  }
});

// PUT /api/products/:id — อัพเดต + ล้าง Cache
router.put('/:id', async (req, res, next) => {
  try {
    const { name, price, description } = req.body;
    const { rows } = await db.query(
      `UPDATE products SET name=$1, price=$2, description=$3, updated_at=NOW()
       WHERE id=$4 RETURNING *`,
      [name, price, description, req.params.id]
    );

    if (rows.length === 0) {
      return res.status(404).json({ error: 'Product not found' });
    }

    // ล้าง Cache ทั้ง list และ individual
    await cacheService.del(`product:${req.params.id}`);
    await cacheService.delPattern('products:*');

    res.json(rows[0]);
  } catch (err) {
    next(err);
  }
});

module.exports = router;

ขั้นตอนที่ 9 — สร้าง Main Application

// src/index.js
const express = require('express');
const cors = require('cors');
require('dotenv').config();

const productRoutes = require('./routes/products');
const cacheService = require('./services/cacheService');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(cors());
app.use(express.json());

// Health check พร้อม Cache status
app.get('/health', async (req, res) => {
  const client = require('./config/redis');
  try {
    await client.ping();
    res.json({ status: 'ok', cache: 'connected' });
  } catch (err) {
    res.json({ status: 'ok', cache: 'disconnected' });
  }
});

// Cache management endpoint
app.delete('/api/cache', async (req, res) => {
  const { pattern } = req.query;
  if (!pattern) {
    return res.status(400).json({ error: 'pattern required' });
  }
  const deleted = await cacheService.delPattern(pattern);
  res.json({ deleted, pattern });
});

app.use('/api/products', productRoutes);

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

ขั้นตอนที่ 10 — ทดสอบ Caching

# ทดสอบ health check
curl http://localhost:3000/health

# ดึง products ครั้งแรก (Cache Miss)
curl http://localhost:3000/api/products
# จะเห็น _cache: { hit: false }

# ดึง products ครั้งที่สอง (Cache Hit)
curl http://localhost:3000/api/products
# จะเห็น _cache: { hit: true }

# ดึง product เดี่ยว
curl http://localhost:3000/api/products/1
# ครั้งแรก: _source: "database"
# ครั้งที่สอง: _source: "cache"

# ตรวจสอบ Key ใน Cache
redis-cli -a YourRedisPassword123! KEYS '*'

# ดู TTL ที่เหลือ
redis-cli -a YourRedisPassword123! TTL "product:1"

# ล้าง Cache ทั้งหมด
curl -X DELETE "http://localhost:3000/api/cache?pattern=*"

ขั้นตอนที่ 11 — Session Store ด้วย Cache

นอกจาก Data Caching แล้ว ตัว Cache ยังนิยมใช้เป็น Session Store สำหรับเว็บแอปพลิเคชัน เพราะเร็วกว่าการเก็บ Session ในฐานข้อมูลหลัก

# ติดตั้ง packages เพิ่ม
npm install express-session connect-redis

// เพิ่มใน src/index.js
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redisClient = require('./config/redis');

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: 'your-session-secret',
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000, // 24 ชั่วโมง
  },
}));

ขั้นตอนที่ 12 — Rate Limiting ด้วย Cache

// src/middleware/rateLimiter.js
const cacheClient = require('../config/redis');

function rateLimiter(maxRequests, windowSeconds) {
  return async (req, res, next) => {
    const key = `ratelimit:${req.ip}`;

    try {
      const current = await cacheClient.incr(key);

      if (current === 1) {
        await cacheClient.expire(key, windowSeconds);
      }

      const ttl = await cacheClient.ttl(key);

      res.set({
        'X-RateLimit-Limit': maxRequests,
        'X-RateLimit-Remaining': Math.max(0, maxRequests - current),
        'X-RateLimit-Reset': ttl,
      });

      if (current > maxRequests) {
        return res.status(429).json({
          error: 'Too many requests',
          retryAfter: ttl,
        });
      }

      next();
    } catch (err) {
      console.error('Rate limiter error:', err.message);
      next();
    }
  };
}

module.exports = rateLimiter;

ขั้นตอนที่ 13 — Monitoring และ Performance Tuning

# ดู Server Info
redis-cli -a YourRedisPassword123! INFO stats

# ดู Memory Usage
redis-cli -a YourRedisPassword123! INFO memory

# ดูจำนวน Keys
redis-cli -a YourRedisPassword123! DBSIZE

# Monitor แบบ real-time
redis-cli -a YourRedisPassword123! MONITOR

# ดู Slow Log
redis-cli -a YourRedisPassword123! SLOWLOG GET 10

# ตรวจสอบ Hit Rate
redis-cli -a YourRedisPassword123! INFO stats | grep keyspace
# keyspace_hits / (keyspace_hits + keyspace_misses) = Hit Rate

เพิ่ม Endpoint สำหรับดู Cache Statistics ในแอปพลิเคชัน:

// เพิ่มใน src/index.js
app.get('/api/cache/stats', async (req, res) => {
  const cacheClient = require('./config/redis');
  const info = await cacheClient.info('stats');

  const hits = info.match(/keyspace_hits:(\d+)/);
  const misses = info.match(/keyspace_misses:(\d+)/);
  const hitCount = hits ? parseInt(hits[1]) : 0;
  const missCount = misses ? parseInt(misses[1]) : 0;
  const total = hitCount + missCount;
  const hitRate = total > 0 ? ((hitCount / total) * 100).toFixed(2) : 0;

  const memInfo = await cacheClient.info('memory');
  const usedMem = memInfo.match(/used_memory_human:(.+)/);

  res.json({
    hits: hitCount,
    misses: missCount,
    hitRate: `${hitRate}%`,
    memory: usedMem ? usedMem[1].trim() : 'N/A',
    dbSize: await cacheClient.dbsize(),
  });
});

ปรับแต่ง Cache Server สำหรับ Production

# แก้ไข config สำหรับ Production
sudo nano /etc/redis/redis.conf

# ปิด Persistence (ถ้าใช้เป็น Cache อย่างเดียว)
save ""
appendonly no

# หรือถ้าต้องการ Persistence ให้ใช้ AOF
# appendonly yes
# appendfsync everysec

# ตั้งค่า TCP Backlog
tcp-backlog 511

# Timeout สำหรับ idle connections
timeout 300

# TCP keepalive
tcp-keepalive 300

# จำกัด clients พร้อมกัน
maxclients 10000

# ตั้งค่า Eviction Policy
maxmemory 512mb
maxmemory-policy allkeys-lru

# ปิด Transparent Huge Pages (ทำใน OS)
# echo never > /sys/kernel/mm/transparent_hugepage/enabled

# ตั้งค่า overcommit memory
# sysctl vm.overcommit_memory=1

# Restart service
sudo systemctl restart redis-server

สรุป

Workshop นี้ครอบคลุมการสร้าง Caching Layer ด้วยระบบ In-Memory Cache ตั้งแต่การติดตั้งและตั้งค่าความปลอดภัย การทำความเข้าใจ Cache Strategies ต่าง ๆ การสร้าง Cache Service และ Middleware สำหรับ Node.js การใช้งานเป็น Session Store และ Rate Limiter ไปจนถึงการ Monitor และปรับแต่งสำหรับ Production

สิ่งสำคัญคือการเลือก Cache Strategy ที่เหมาะสมกับรูปแบบการใช้งาน การกำหนด TTL ที่เหมาะสม และการล้าง Cache เมื่อข้อมูลเปลี่ยนแปลง (Cache Invalidation) เพื่อให้ข้อมูลใน Cache ตรงกับข้อมูลจริงอยู่เสมอ

แนะนำบริการ DE

การรัน Cache Server ควบคู่กับฐานข้อมูลหลักต้องการเซิร์ฟเวอร์ที่มี RAM เพียงพอและ root access เพื่อติดตั้งและปรับแต่งค่าต่าง ๆ Cloud VPS ของ DE รองรับการใช้งาน In-Memory Cache ได้อย่างเต็มประสิทธิภาพ พร้อม SSD Storage ที่รองรับ Persistence ได้ดี และสามารถเลือก RAM ได้ตามความต้องการ

สำหรับเว็บไซต์ขนาดเล็กถึงกลางที่ต้องการ Caching แต่ไม่ต้องการดูแลเซิร์ฟเวอร์เอง Cloud Hosting ของ DE มีระบบ Cache ในตัวที่ช่วยเพิ่มความเร็วเว็บไซต์โดยไม่ต้องตั้งค่าเอง