1. Home
  2. Insights
  3. Redis
  4. Redis สำหรับงานเว็บจริง: Cache, Rate Limit, Queue และรูปแบบที่ทีมใช้งานได้ต่อ
Redis

Redis สำหรับงานเว็บจริง: Cache, Rate Limit, Queue และรูปแบบที่ทีมใช้งานได้ต่อ

อธิบาย Redis แบบใช้งานจริงสำหรับระบบเว็บ ตั้งแต่ cache, TTL, rate limiting, session, queue ไปจนถึงข้อควรระวังเรื่อง memory และ persistence พร้อมโค้ด Node.js ที่นำไปใช้ต่อได้

Redis สำหรับงานเว็บจริง: Cache, Rate Limit, Queue และรูปแบบที่ทีมใช้งานได้ต่อ

หลายทีมเริ่มรู้จัก Redis จากคำว่า “cache” แล้วก็หยุดอยู่แค่นั้น แต่พอระบบเริ่มโตขึ้นจริง Redis มักกลายเป็นส่วนกลางของงาน backend หลายอย่างพร้อมกัน เช่น เก็บ session, กันยิง API ถี่เกินไป, ทำ background job, เก็บ state ชั่วคราว หรือใช้เป็นตัวช่วยลดภาระฐานข้อมูลหลัก

ปัญหาคือ Redis ถูกใช้ผิดง่ายมาก ถ้าเห็นมันเป็นแค่ key-value store ธรรมดา เรามักจะใส่ข้อมูลลงไปเรื่อย ๆ โดยไม่คิดเรื่อง TTL, memory, invalidation, persistence หรือรูปแบบการ access ที่เกิดขึ้นจริงใน production สุดท้ายสิ่งที่ตั้งใจจะช่วยให้ระบบเร็วขึ้น อาจกลายเป็นจุดที่ทำให้ระบบซับซ้อนขึ้นแทน

บทความนี้จะอธิบาย Redis ในมุมที่ทีมพัฒนาสามารถนำไปใช้งานต่อได้จริง เน้นภาพการทำงาน, trade-off, และตัวอย่างโค้ดที่เอาไปปรับใช้ได้ทันที โดยจะลดงานเชิงสรุปแบบ bullet ลง และเน้นให้เห็นเหตุผลว่าควรใช้ Redis ตอนไหน ใช้แบบไหน และต้องระวังอะไร

Redis คืออะไรในมุมที่ใช้กับระบบจริง

Redis เป็น in-memory data store หมายความว่าข้อมูลหลักถูกเก็บไว้ในหน่วยความจำ ทำให้การอ่านและเขียนเร็วมากเมื่อเทียบกับฐานข้อมูลที่ต้องพึ่ง disk เป็นหลัก แต่ความเร็วนี้แลกมาด้วยข้อจำกัดสำคัญคือ memory มีราคาแพงกว่า และข้อมูลที่อยู่ใน Redis ไม่ควรถูกมองว่า “เก็บได้ทุกอย่างตลอดไป” แบบเดียวกับ relational database

ในระบบจริง Redis มักเหมาะกับข้อมูลที่มีอย่างน้อยหนึ่งลักษณะต่อไปนี้

  • อ่านบ่อยกว่าการเขียนมาก
  • ยอมให้หมดอายุได้
  • เป็นข้อมูลชั่วคราวหรืออนุพันธ์จากแหล่งข้อมูลหลัก
  • ต้องการตอบสนองเร็วมาก
  • ใช้สำหรับ coordination หรือ state ชั่วคราวระหว่าง service

ถ้าข้อมูลนั้นเป็น source of truth หลักของธุรกิจ เช่น ledger, order history, invoice, policy record หรือข้อมูลที่ต้องการ transaction model ชัดเจน Redis มักไม่ใช่ฐานข้อมูลหลักที่ควรเริ่มต้นด้วย ถึงแม้มันจะเก็บได้ก็ตาม

เริ่มจาก use case ที่ปลอดภัยที่สุด: response caching

งานที่ปลอดภัยและคุ้มค่าที่สุดสำหรับ Redis คือการ cache ผลลัพธ์ที่คำนวณแพงหรือ query ช้า เช่น dashboard summary, product list, public content, profile ที่อ่านบ่อย หรือ aggregate จากหลาย service

แนวคิดง่ายที่สุดคือ เมื่อมี request เข้ามา ระบบเช็คก่อนว่ามีข้อมูลอยู่ใน Redis หรือไม่ ถ้ามีให้ส่งกลับทันที ถ้าไม่มีจึงค่อยไป query database หรือคำนวณใหม่ แล้วค่อยบันทึกผลลง Redis พร้อมกำหนด TTL

ตัวอย่างด้วย Node.js และ redis client รุ่นใหม่:

import express from "express";
import { createClient } from "redis";

const app = express();
const redis = createClient({
  url: process.env.REDIS_URL || "redis://localhost:6379"
});

redis.on("error", (err) => {
  console.error("Redis error:", err);
});

await redis.connect();

async function getProductListFromDb(category: string) {
  return [
    { id: 1, name: "Starter Plan", category },
    { id: 2, name: "Business Plan", category }
  ];
}

app.get("/products", async (req, res) => {
  const category = String(req.query.category || "all");
  const cacheKey = `products:list:${category}`;

  try {
    const cached = await redis.get(cacheKey);

    if (cached) {
      return res.json({
        source: "cache",
        data: JSON.parse(cached)
      });
    }

    const products = await getProductListFromDb(category);

    await redis.set(cacheKey, JSON.stringify(products), {
      EX: 300
    });

    return res.json({
      source: "database",
      data: products
    });
  } catch (error) {
    console.error("GET /products failed:", error);
    return res.status(500).json({ message: "Internal server error" });
  }
});

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

สิ่งสำคัญในตัวอย่างนี้ไม่ใช่แค่การ set/get แต่คือการยอมรับว่า cache เป็น “ชั้นรอง” ของข้อมูลหลัก ถ้า Redis ล่ม ระบบยังควรตอบได้จาก database แม้อาจช้าลง นี่คือหลักคิดสำคัญมากของการใช้ Redis ใน production: มันควรช่วยเพิ่ม performance ไม่ใช่กลายเป็น single point of failure ที่ทำให้ระบบใช้งานไม่ได้เลย

อย่า cache แบบไร้ TTL

ข้อผิดพลาดที่เจอบ่อยมากคือการ cache ข้อมูลโดยไม่กำหนดวันหมดอายุ เพราะคิดว่าเดี๋ยวค่อยลบเองทีหลัง ปัญหาคือทีมส่วนใหญ่ลืมลบจริง หรือ invalidate ไม่ครบ จนข้อมูลเก่าค้างอยู่ในระบบนานกว่าที่ตั้งใจ

TTL ไม่ได้มีไว้แค่จัดการ memory แต่ช่วยจำกัดขอบเขตของความผิดพลาดด้วย สมมติมี bug ทำให้ข้อมูลใน cache ผิด อย่างน้อยมันยังหายไปเองตามเวลา ถ้าไม่มี TTL ความผิดพลาดนั้นอาจอยู่ต่อไปเรื่อย ๆ จนกว่ามีคนสังเกตเห็น

ตัวอย่างการ cache profile พร้อม TTL:

async function getUserProfile(userId: string) {
  const cacheKey = `user:profile:${userId}`;

  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  const profile = await fetchUserProfileFromDatabase(userId);

  await redis.set(cacheKey, JSON.stringify(profile), {
    EX: 120
  });

  return profile;
}

TTL ที่ดีไม่จำเป็นต้องยาวที่สุด แต่ควรสอดคล้องกับลักษณะข้อมูล ถ้าเป็น profile ที่เปลี่ยนไม่บ่อยอาจใช้ 2–10 นาที ถ้าเป็น dashboard ที่ต้องการเกือบ real-time อาจใช้ 10–30 วินาที ถ้าเป็น config ที่เปลี่ยนเฉพาะตอน deploy อาจใช้ยาวกว่านั้นได้

ปัญหาที่ต้องคิดต่อ: cache invalidation

ประโยคคลาสสิกคือ “There are only two hard things in Computer Science: cache invalidation and naming things.” เหตุผลที่ invalidation ยาก เพราะข้อมูลจริงเปลี่ยนเมื่อไร และมี key อะไรที่ได้รับผลกระทบบ้าง มักไม่ตรงไปตรงมา

สมมติคุณมี cache เหล่านี้พร้อมกัน

  • user:profile:42
  • team:members:7
  • dashboard:summary:admin
  • search:users:active

ถ้า user 42 เปลี่ยนชื่อหรือ role คุณต้องลบแค่ key เดียว หรือหลาย key พร้อมกัน คำตอบขึ้นกับว่า endpoint อื่นสร้างข้อมูลจาก user 42 ด้วยหรือไม่ นี่คือเหตุผลที่การออกแบบ cache key ตั้งแต่แรกสำคัญมาก

แนวทางที่ใช้งานได้จริงมีอยู่สองแบบหลัก

แบบแรก: ปล่อยให้ TTL หมดเอง

เหมาะกับข้อมูลที่ stale ได้บ้าง และไม่ critical มาก ข้อดีคือระบบเรียบง่าย ไม่ต้องไล่ลบหลาย key ทุกครั้งที่มี update

แบบสอง: delete ทันทีเมื่อข้อมูลเปลี่ยน

เหมาะกับข้อมูลที่ต้องอัปเดตเร็ว เช่น profile, permissions, stock, inventory, pricing

ตัวอย่างเมื่ออัปเดตข้อมูลผู้ใช้แล้วลบ cache ที่เกี่ยวข้อง:

async function updateUserProfile(userId: string, payload: Record<string, unknown>) {
  await updateUserProfileInDatabase(userId, payload);

  await redis.del(`user:profile:${userId}`);
}

เมื่อระบบเริ่มมีหลายหน้าที่อ้างข้อมูลชุดเดียวกัน เรามักต้องใช้ key convention ให้ชัดขึ้น หรือออกแบบ tag/versioning ช่วยลดความซับซ้อน เช่นเก็บ version ของ user profile แล้วประกอบเข้าไปใน key

async function getVersionedUserProfile(userId: string) {
  const versionKey = `user:profile:version:${userId}`;
  let version = await redis.get(versionKey);

  if (!version) {
    version = "1";
    await redis.set(versionKey, version);
  }

  const cacheKey = `user:profile:${userId}:v${version}`;
  const cached = await redis.get(cacheKey);

  if (cached) {
    return JSON.parse(cached);
  }

  const profile = await fetchUserProfileFromDatabase(userId);
  await redis.set(cacheKey, JSON.stringify(profile), { EX: 300 });

  return profile;
}

async function invalidateUserProfile(userId: string) {
  await redis.incr(`user:profile:version:${userId}`);
}

วิธีนี้ช่วยให้ไม่ต้องไล่ลบ key เก่าทั้งหมดทันที เพราะเมื่อ version เปลี่ยน ระบบก็จะเลิกอ่าน key เก่าเอง แล้วปล่อยให้ TTL ทำงานต่อ

ป้องกัน cache stampede ตอน key เดียวหมดพร้อมกัน

อีกปัญหาที่มักมาเมื่อ traffic เริ่มเยอะคือ cache stampede สมมติ endpoint สำคัญมี request หลายร้อยครั้งต่อวินาที แล้ว key หลักหมดอายุพร้อมกัน ทุก request จะพุ่งไป database พร้อมกัน ทำให้ database รับโหลดหนักแบบกระชากทันที

เทคนิคพื้นฐานคือเพิ่ม jitter ให้ TTL เพื่อไม่ให้ key จำนวนมากหมดอายุพร้อมกันเป๊ะ ๆ

function randomTtl(baseSeconds: number, jitterSeconds: number) {
  return baseSeconds + Math.floor(Math.random() * jitterSeconds);
}

await redis.set(cacheKey, JSON.stringify(data), {
  EX: randomTtl(300, 60)
});

อีกวิธีหนึ่งคือใช้ lock สั้น ๆ เพื่อให้มีแค่ process เดียวเป็นคน rebuild cache ส่วน request อื่นรอหรือใช้ข้อมูลเก่าชั่วคราว

async function getOrRebuildCache(key: string, rebuildFn: () => Promise<unknown>) {
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached);
  }

  const lockKey = `lock:${key}`;
  const lock = await redis.set(lockKey, "1", {
    NX: true,
    EX: 10
  });

  if (lock) {
    try {
      const data = await rebuildFn();
      await redis.set(key, JSON.stringify(data), { EX: 300 });
      return data;
    } finally {
      await redis.del(lockKey);
    }
  }

  await new Promise((resolve) => setTimeout(resolve, 100));
  const retry = await redis.get(key);

  if (retry) {
    return JSON.parse(retry);
  }

  return rebuildFn();
}

ตัวอย่างนี้ไม่ได้สมบูรณ์ระดับ distributed locking ขั้นสูง แต่ดีพอสำหรับให้ทีมเห็นทิศทางว่าปัญหา stampede แก้อย่างไร

Redis กับ rate limiting

อีกงานยอดนิยมคือ rate limit เพราะ Redis เหมาะกับ counter ที่ต้องอัปเดตเร็วและมีอายุสั้น เช่น จำกัดว่า IP เดียวเรียก login ได้กี่ครั้งต่อนาที หรือ user เดียวส่ง OTP ได้บ่อยแค่ไหน

ตัวอย่าง fixed window แบบเรียบง่าย:

async function isRateLimited(key: string, limit: number, windowSeconds: number) {
  const current = await redis.incr(key);

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

  return current > limit;
}

นำไปใช้ใน route login:

app.post("/login", async (req, res) => {
  const ip = req.ip;
  const key = `rate:login:${ip}`;

  try {
    const limited = await isRateLimited(key, 5, 60);

    if (limited) {
      return res.status(429).json({
        message: "Too many login attempts. Please try again later."
      });
    }

    return res.json({ message: "Login attempt accepted" });
  } catch (error) {
    console.error("POST /login failed:", error);
    return res.status(500).json({ message: "Internal server error" });
  }
});

fixed window ใช้ง่าย แต่มี edge case เช่น request มากระจุกตรงรอยต่อของ window ทำให้ผู้ใช้ส่งได้เกินกว่าที่คิดในช่วงสั้น ๆ ถ้าระบบซีเรียสเรื่องความแม่นยำของ rate limit มากขึ้น คุณอาจขยับไป sliding window หรือ token bucket ซึ่ง Redis ก็ยังรองรับแนวทางพวกนี้ได้ดี

ตัวอย่าง sliding window แบบใช้ sorted set:

async function isSlidingWindowLimited(
  key: string,
  limit: number,
  windowMs: number
) {
  const now = Date.now();
  const min = now - windowMs;

  await redis.zRemRangeByScore(key, 0, min);
  await redis.zAdd(key, [{ score: now, value: `${now}-${Math.random()}` }]);

  const count = await redis.zCard(key);
  await redis.expire(key, Math.ceil(windowMs / 1000));

  return count > limit;
}

เมื่อทีมต้องเลือกระหว่าง “ง่ายและเร็วพอ” กับ “แม่นยำขึ้นแต่ซับซ้อนกว่า” คำตอบควรมาจากลักษณะธุรกิจจริง ไม่ใช่เลือกแบบที่ advanced กว่าเสมอไป

เก็บ session ใน Redis เมื่อระบบมีหลาย instance

ถ้าแอปของคุณรันหลาย instance หลัง load balancer แล้ว session ถูกเก็บใน memory ของ process แต่ละตัว ผู้ใช้ที่ request คนละรอบไปชนคนละ instance อาจดูเหมือนหลุด session ได้ Redis จึงเหมาะมากเป็น session store กลาง

ตัวอย่าง Express session store ด้วย Redis:

import session from "express-session";
import connectRedis from "connect-redis";

const RedisStore = connectRedis(session);

app.use(
  session({
    store: new RedisStore({ client: redis as any }),
    secret: process.env.SESSION_SECRET || "change-me",
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      maxAge: 1000 * 60 * 60 * 24
    }
  })
);

ข้อดีคือ session ใช้งานข้าม instance ได้ และล้างออกเองได้ตาม expiry แต่ต้องระวังว่า Redis ที่ใช้กับ session ต้องมีเสถียรภาพพอสมควร เพราะถ้ามันล่ม ผู้ใช้จำนวนมากอาจได้รับผลกระทบพร้อมกัน

Redis กับ queue และ background jobs

อีก use case สำคัญคือการใช้ Redis เป็น backbone ของ job queue เช่น ส่งอีเมล, ประมวลผลรูป, สร้าง report, sync กับ third-party, retry งานที่ล้มเหลว หรือแยกงานหนักออกจาก request-response path

ใน Node.js เครื่องมือที่ใช้บ่อยคือ BullMQ ซึ่งทำงานบน Redis ตัวอย่างง่าย:

import { Queue, Worker } from "bullmq";

const emailQueue = new Queue("email-jobs", {
  connection: {
    host: "127.0.0.1",
    port: 6379
  }
});

async function enqueueWelcomeEmail(userId: string, email: string) {
  await emailQueue.add("send-welcome-email", {
    userId,
    email
  }, {
    attempts: 3,
    backoff: {
      type: "exponential",
      delay: 2000
    },
    removeOnComplete: true,
    removeOnFail: false
  });
}

const worker = new Worker(
  "email-jobs",
  async (job) => {
    if (job.name === "send-welcome-email") {
      const { email } = job.data as { email: string };
      console.log(`Sending email to ${email}`);
    }
  },
  {
    connection: {
      host: "127.0.0.1",
      port: 6379
    }
  }
);

worker.on("failed", (job, err) => {
  console.error("Job failed:", job?.id, err);
});

ประโยชน์ของ queue ไม่ได้มีแค่เรื่อง “ทำงานทีหลัง” แต่ช่วยตัด coupling ระหว่าง request ของผู้ใช้กับงานหลังบ้านที่ใช้เวลานาน เมื่อ API ตอบกลับเร็วขึ้น ประสบการณ์ของผู้ใช้ก็ดีขึ้น และระบบยังควบคุม retry, concurrency, priority และ monitoring ได้ง่ายกว่าเดิม

แต่ต้องเข้าใจด้วยว่า queue ไม่ได้ทำให้งาน “สำเร็จแน่นอน” โดยอัตโนมัติ คุณยังต้องออกแบบ idempotency, retry policy, dead-letter handling และ monitoring อยู่ดี

ใช้ Redis เป็น distributed lock ได้ แต่ต้องระวัง

บางงานต้องการให้มี process เดียวเท่านั้นที่ทำงานบางอย่าง เช่น cron job สรุปรายงาน, worker ที่ห้ามกินงานซ้ำ หรือ process ที่ rebuild cache ชุดใหญ่พร้อมกัน Redis สามารถช่วยทำ lock ได้ด้วย SET key value NX EX

async function acquireLock(lockKey: string, ttlSeconds: number) {
  const token = crypto.randomUUID();

  const ok = await redis.set(lockKey, token, {
    NX: true,
    EX: ttlSeconds
  });

  if (!ok) {
    return null;
  }

  return token;
}

แต่ lock ที่ดีต้องคิดต่อเรื่องการ release ให้ถูก owner, การหมดอายุระหว่างทำงาน, และ edge case จาก network partition ถ้าเป็นงานสำคัญมาก ๆ ต้องประเมินว่าระดับความเข้มงวดของ lock ที่ต้องการมากแค่ไหน ไม่ใช่เห็นว่า Redis ทำ lock ได้แล้วใช้เลยโดยไม่คิด failure mode

โครงสร้างข้อมูลของ Redis ไม่ได้มีแค่ string

หลายทีมใช้ Redis เป็น string store อย่างเดียว ทั้งที่จริงแล้ว data structure ในตัวมันช่วยให้บาง use case ง่ายขึ้นมาก

String

เหมาะกับ cache ทั่วไป, counter, flag, token, session blob

await redis.set("feature:checkout:v2", "enabled", { EX: 3600 });
const value = await redis.get("feature:checkout:v2");

Hash

เหมาะกับ object ขนาดเล็กที่ต้องอ่าน/เขียนทีละ field

await redis.hSet("user:42", {
  name: "Juno",
  role: "admin",
  status: "active"
});

const user = await redis.hGetAll("user:42");

List

เหมาะกับลำดับข้อมูลแบบง่าย ๆ, queue เบื้องต้น, event ล่าสุด

await redis.lPush("logs:latest", JSON.stringify({ event: "login", userId: 42 }));
await redis.lTrim("logs:latest", 0, 99);

Set

เหมาะกับสมาชิกที่ไม่ซ้ำ เช่น role membership, online users

await redis.sAdd("online-users", "42", "99", "105");
const onlineUsers = await redis.sMembers("online-users");

Sorted Set

เหมาะกับ leaderboard, ranking, sliding window, schedule ที่อิง score หรือเวลา

await redis.zAdd("leaderboard", [
  { score: 1200, value: "user:42" },
  { score: 980, value: "user:99" }
]);

const topUsers = await redis.zRange("leaderboard", 0, 9, { REV: true });

การเลือก data structure ให้ตรงกับปัญหาช่วยให้โค้ดสั้นลงและลดงานด้าน application logic ได้มากกว่าการยัดทุกอย่างเป็น JSON string แล้ว parse เองทั้งหมด

ตั้งชื่อ key ให้ดีตั้งแต่วันแรก

เรื่องนี้ดูเล็ก แต่มีผลมากต่อการดูแลระบบในระยะยาว Redis ไม่ได้มี schema บังคับเหมือน SQL ดังนั้นถ้าทีมตั้งชื่อ key แบบสะเปะสะปะ การ debug จะยากขึ้นเรื่อย ๆ

รูปแบบที่อ่านง่ายและดูแลได้ดีมักเป็น namespace แบบนี้

user:profile:42
user:profile:version:42
rate:login:203.0.113.10
session:9f8ab2...
queue:emails:pending
cache:dashboard:admin

ข้อดีคือเวลาใช้เครื่องมือดู key หรือไล่ปัญหาใน production เราจะเข้าใจได้ทันทีว่า key นั้นเกี่ยวกับงานอะไร และยังสามารถกำหนด policy แยกตาม namespace ได้ง่ายขึ้น

ระวัง memory growth และ eviction policy

Redis เร็วเพราะอยู่ใน memory แต่ก็แปลว่ามันเต็มได้เร็ว ถ้าระบบ cache เยอะขึ้นเรื่อย ๆ โดยไม่มีการกำหนด TTL หรือ maxmemory ที่ชัดเจน ปัญหาจะมาช้าแต่หนักมาก คือวันหนึ่งมันเริ่มกิน memory จนโดน kill หรือเริ่ม evict key แบบไม่คาดคิด

สิ่งที่ทีมควรรู้มีอย่างน้อยสามเรื่อง

อย่างแรก ต้องกำหนดว่า Redis instance นี้ใช้ทำอะไรแน่ ถ้าใช้ทั้ง cache, session, queue และ lock ปนกันหมดในตัวเดียว ความเสี่ยงคือ cache โตจนไปดัน session หรือ queue สำคัญให้โดน eviction

อย่างที่สอง ต้องตั้ง maxmemory และ maxmemory-policy ให้สอดคล้องกับ use case ถ้า Redis ใช้เป็น cache หลัก นโยบายแบบ allkeys-lru หรือ volatile-lru อาจเหมาะ แต่ถ้าเก็บ state สำคัญปะปนอยู่ด้วย ต้องคิดให้ละเอียดมากกว่าเดิม

อย่างที่สาม ต้องมี monitoring ดู hit rate, memory usage, evicted keys, rejected connections, replication lag และ latency ไม่เช่นนั้นทีมจะรู้ตัวอีกทีตอนระบบช้าไปแล้ว

Persistence: จะเก็บลง disk หรือไม่

Redis มี persistence mode เช่น RDB และ AOF ซึ่งช่วยให้ข้อมูลบางส่วนฟื้นกลับมาได้หลัง restart แต่คำถามสำคัญไม่ใช่แค่ว่า “เปิดไหม” ต้องถามก่อนว่า “ข้อมูลใน Redis นี้จำเป็นต้องรอดหลัง restart หรือไม่”

ถ้าใช้ Redis เป็น pure cache อย่างเดียว การ restart แล้ว cache หายอาจยอมรับได้ ระบบแค่กลับไปอ่านจาก database ช่วงแรก ๆ แต่ถ้าใช้เก็บ queue, session, state ชั่วคราวที่มีผลต่อธุรกิจ คุณต้องประเมิน persistence ให้จริงจังขึ้น

ไม่มีค่าตายตัวที่ใช้ได้กับทุกระบบ เพราะ persistence มีผลต่อ performance, durability และ recovery time ต่างกันไปตามงาน

เมื่อไรไม่ควรใช้ Redis

มีหลายกรณีที่ Redis ไม่ใช่คำตอบที่ดี แม้จะทำได้ก็ตาม เช่น

ถ้าคุณต้อง query ความสัมพันธ์ซับซ้อนแบบ ad-hoc, ต้อง join ข้าม entity, ต้องรักษา transaction consistency ระดับสูง, ต้องเก็บข้อมูลจำนวนมากระยะยาว หรือจำเป็นต้องมี audit trail ชัดเจน ฐานข้อมูลหลักอย่าง PostgreSQL หรือระบบ message broker/queue ที่ออกแบบเพื่อ durability สูงอาจเหมาะกว่า

Redis เก่งมากในงานเฉพาะทาง แต่ไม่ควรถูกใช้แทนทุกอย่างเพียงเพราะมันเร็ว

ตัวอย่าง middleware cache ที่นำไปใช้ต่อได้

เพื่อให้เห็นภาพแบบนำไปใช้จริง ลองดู middleware สำหรับ Express ที่ใช้ cache response ของ endpoint อ่านอย่างเดียว

import type { Request, Response, NextFunction } from "express";

function redisCache(prefix: string, ttlSeconds = 60) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const cacheKey = `${prefix}:${req.originalUrl}`;

    try {
      const cached = await redis.get(cacheKey);

      if (cached) {
        return res.json(JSON.parse(cached));
      }

      const originalJson = res.json.bind(res);

      res.json = function patchedJson(body: unknown) {
        void redis.set(cacheKey, JSON.stringify(body), {
          EX: ttlSeconds
        }).catch((error) => {
          console.error("Failed to write cache:", error);
        });

        return originalJson(body);
      };

      next();
    } catch (error) {
      console.error("Cache middleware failed:", error);
      next();
    }
  };
}

app.get("/api/articles", redisCache("articles:list", 120), async (_req, res) => {
  const articles = await fetchArticlesFromDatabase();
  res.json({ data: articles });
});

สิ่งที่ควรสังเกตคือ middleware นี้ตั้งใจ fail-open ถ้า Redis ใช้งานไม่ได้ request ยังต้องวิ่งต่อได้ ไม่ใช่พังทั้ง endpoint นี่เป็นแนวคิดที่ปลอดภัยกว่าสำหรับ layer ประเภท cache

ตัวอย่าง rate limiter middleware ที่ชัดขึ้น

function createRateLimiter(options: {
  namespace: string;
  limit: number;
  windowSeconds: number;
}) {
  const { namespace, limit, windowSeconds } = options;

  return async (req: Request, res: Response, next: NextFunction) => {
    const identifier = req.ip || "unknown";
    const key = `${namespace}:${identifier}`;

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

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

      if (current > limit) {
        return res.status(429).json({
          message: "Too many requests",
          retryAfterSeconds: windowSeconds
        });
      }

      return next();
    } catch (error) {
      console.error("Rate limiter failed:", error);
      return next();
    }
  };
}

app.post(
  "/api/auth/request-otp",
  createRateLimiter({
    namespace: "rate:otp",
    limit: 3,
    windowSeconds: 300
  }),
  async (_req, res) => {
    res.json({ message: "OTP request accepted" });
  }
);

ตัวอย่างนี้ไม่ได้แก้ทุก edge case แต่เป็น baseline ที่ทีมส่วนใหญ่สามารถเริ่มใช้ได้จริงก่อน แล้วค่อยพัฒนาความแม่นยำเพิ่มในภายหลัง

แนวทางออกแบบ Redis ให้ระบบดูแลง่ายขึ้น

ถ้าจะสรุป Redis แบบที่ใช้ได้จริงที่สุด ควรมองมันเป็นเครื่องมือสำหรับ “ลดภาระ”, “เก็บ state ชั่วคราว”, และ “เร่ง path ที่สำคัญ” มากกว่าจะมองเป็นที่เก็บข้อมูลหลักของทุกอย่าง

วิธีใช้ที่ดูแลง่ายในระยะยาวมักมีลักษณะร่วมกันดังนี้

เขียน key naming ให้สื่อความหมาย, ตั้ง TTL เกือบทุกครั้งที่เหมาะสม, แยก instance ตามบทบาทเมื่อระบบเริ่มโต, ออกแบบให้ระบบยังทำงานได้แม้ Redis มีปัญหา, และมี monitoring ที่บอกได้ว่า cache ช่วยจริงหรือกำลังสร้างปัญหาใหม่

การใช้ Redis เก่งไม่ได้วัดจากจำนวน feature ที่นำมาใช้ แต่วัดจากการรู้ว่า use case ไหนควรใช้ และ use case ไหนไม่ควรฝืนใช้มัน

บทส่งท้าย

Redis เป็นหนึ่งในเครื่องมือที่ให้ผลลัพธ์เร็วมากเมื่อใช้ถูกจุด คุณสามารถลด latency ของ endpoint, ลดภาระ database, กันการ abuse บางรูปแบบ, และแยกงานหนักออกจาก request หลักได้ภายในเวลาไม่นาน แต่ประโยชน์เหล่านี้จะยั่งยืนก็ต่อเมื่อทีมคิดเรื่อง TTL, invalidation, memory, failure mode และขอบเขตของข้อมูลตั้งแต่แรก

ถ้าคุณเพิ่งเริ่มใช้ Redis ให้เริ่มจาก cache endpoint ที่ปลอดภัยก่อน จากนั้นค่อยขยับไป rate limiting, session store และ queue ตามลำดับ เมื่อระบบโตขึ้น คุณจะมอง Redis ไม่ใช่แค่ตัวช่วย “ทำให้เร็ว” แต่เป็นส่วนหนึ่งของสถาปัตยกรรมที่ช่วยควบคุมพฤติกรรมของระบบทั้งก้อนอย่างมีวินัยมากขึ้น

💬 Chat (ตอบเร็ว)