本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 效能優化

主題:快取(Redis)


簡介

在 Web 應用程式中,IO(磁碟、資料庫) 常是效能瓶頸。即使使用了 Node.js 的非阻塞特性,當每一次請求都必須向後端資料庫發出查詢時,延遲仍會累積,導致使用者體驗不佳。
快取(Cache)正是解決此問題的關鍵手段,而 Redis 以其高速的記憶體存取、豐富的資料結構與簡潔的 API,成為 Node.js(含 TypeScript)生態系統中最常見的快取方案。

本篇文章將從 概念實作常見陷阱最佳實踐,一步步帶你在 ExpressJS + TypeScript 專案中導入 Redis 快取,讓 API 響應時間從毫秒級降到 微秒級,同時保持程式碼可讀、易維護。


核心概念

1️⃣ 為什麼要使用 Redis 作為快取層

特性 為什麼重要
記憶體儲存 讀寫速度遠快於磁碟或網路資料庫(數十萬 QPS)
支援多種資料結構(String、Hash、Set、Sorted Set、List) 可依需求存放不同型別的資料,例如排行榜、會話資訊、JSON 物件等
單機/叢集模式 從開發環境的單點部署到生產環境的分片叢集,都能無縫擴充
TTL(Time‑to‑Live) 自動過期機制避免陳舊資料佔用記憶體
Pub/Sub、Lua Script 進階需求(如分布式鎖、原子操作)亦可在快取層完成

2️⃣ Redis 與 Express 的整合流程

  1. 建立 Redis 連線池(使用 ioredisredis 官方套件)
  2. 在 Middleware 中檢查快取:先從 Redis 讀取,若命中則直接回傳;未命中則往下傳遞至 controller。
  3. 在 Controller 完成資料庫查詢後,將結果寫入快取(設定適當 TTL)
  4. 統一錯誤處理:Redis 失效時不應影響主流程,僅記錄日誌即可。

下面的程式碼示範了這個流程的最小實作。

3️⃣ 主要 Redis 操作類型

操作 說明 範例
GET / SET 讀寫字串 await redis.get(key)await redis.set(key, value, 'EX', ttl)
HGETALL / HSET 讀寫 Hash(適合存 JSON 物件) await redis.hgetall(key)
INCR / DECR 原子遞增/遞減(計數器) await redis.incr(key)
ZADD / ZRANGE 有序集合(排行榜) await redis.zadd('score', score, member)
EXPIRE 設定過期時間 await redis.expire(key, ttl)

程式碼範例

以下範例採用 TypeScriptExpress 5(尚在 beta)以及 ioredis(官方推薦的 Redis 客戶端),每段程式碼都附有說明註解,方便初學者快速上手。

3.1 初始化 Redis 連線(Singleton)

// src/lib/redis.ts
import IORedis from 'ioredis';

class RedisClient {
  private static instance: IORedis.Redis;

  private constructor() {}

  /** 取得唯一的 Redis 連線實例 */
  public static getInstance(): IORedis.Redis {
    if (!RedisClient.instance) {
      // 讀取環境變數,支援本機或雲端 Redis 服務
      const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
      RedisClient.instance = new IORedis(REDIS_URL, {
        // 連線失敗自動重試
        retryStrategy: (times) => Math.min(times * 200, 2000),
      });

      RedisClient.instance.on('error', (err) => {
        console.error('❌ Redis 連線錯誤:', err);
      });
    }
    return RedisClient.instance;
  }
}

export default RedisClient;

重點:使用 Singleton 確保全域只建立一次連線,避免過多 socket 耗盡系統資源。


3.2 快取 Middleware(先讀快取)

// src/middleware/cache.ts
import { Request, Response, NextFunction } from 'express';
import RedisClient from '../lib/redis';

/**
 * 依據請求的 URL 作為快取鍵(簡單示範,實務上可自行定義更具辨識度的 key)
 */
export async function cacheMiddleware(req: Request, res: Response, next: NextFunction) {
  const redis = RedisClient.getInstance();
  const cacheKey = `cache:${req.originalUrl}`;

  try {
    const cached = await redis.get(cacheKey);
    if (cached) {
      // 命中快取,直接回傳 JSON
      res.setHeader('X-Cache', 'HIT');
      return res.json(JSON.parse(cached));
    }
    // 未命中,將快取鍵暫存於 res.locals,供後續寫入
    res.locals.cacheKey = cacheKey;
    res.setHeader('X-Cache', 'MISS');
    next();
  } catch (err) {
    // Redis 錯誤不阻斷請求流程
    console.warn('⚠️ Redis 讀取失敗,直接跳過快取', err);
    next();
  }
}

技巧:在回應前把 cacheKey 存到 res.locals,可在 controller 完成後直接使用,避免再次產生鍵名。


3.3 Controller 範例 – 讀取資料庫後寫入快取

// src/controllers/userController.ts
import { Request, Response } from 'express';
import RedisClient from '../lib/redis';
import { getUserById } from '../services/userService'; // 假設是 DB 查詢

export async function getUser(req: Request, res: Response) {
  const { id } = req.params;
  const user = await getUserById(id); // 可能是 Prisma / TypeORM 等

  // 若未找到,回傳 404
  if (!user) {
    return res.status(404).json({ message: 'User not found' });
  }

  // 寫入快取,設定 60 秒過期
  const cacheKey = res.locals.cacheKey as string;
  if (cacheKey) {
    const redis = RedisClient.getInstance();
    try {
      await redis.set(cacheKey, JSON.stringify(user), 'EX', 60);
    } catch (err) {
      console.warn('⚠️ Redis 寫入失敗', err);
    }
  }

  return res.json(user);
}

3.4 使用 Hash 結構快取 JSON(避免序列化/反序列化開銷)

// src/services/productService.ts
import RedisClient from '../lib/redis';
import { queryProductById } from './db';

export async function getProduct(id: string) {
  const redis = RedisClient.getInstance();
  const hashKey = `product:${id}`;

  // 嘗試從 Hash 讀取
  const cached = await redis.hgetall(hashKey);
  if (Object.keys(cached).length) {
    // Hash 內的每個欄位都是字串,需要自行轉型
    return {
      id: cached.id,
      name: cached.name,
      price: Number(cached.price),
      // ...其他欄位
    };
  }

  // DB 查詢
  const product = await queryProductById(id);
  if (!product) return null;

  // 寫入 Hash,並設定 5 分鐘 TTL
  await redis.hmset(hashKey, {
    id: product.id,
    name: product.name,
    price: product.price.toString(),
    // ...其他欄位
  });
  await redis.expire(hashKey, 300);

  return product;
}

說明:使用 HMSET(或 HSET)可以一次寫入多個欄位,對於結構化資料比單純的 SET 更有效率。


3.5 分布式鎖(防止 Cache Stampede)

當大量請求同時「未命中」快取,會同時衝擊資料庫,產生 Cache Stampede。以下示範如何使用 Redis 的 SET NX EX 搭配簡易鎖:

// src/middleware/cacheWithLock.ts
import { Request, Response, NextFunction } from 'express';
import RedisClient from '../lib/redis';
import crypto from 'crypto';

export async function cacheWithLock(req: Request, res: Response, next: NextFunction) {
  const redis = RedisClient.getInstance();
  const cacheKey = `cache:${req.originalUrl}`;
  const lockKey = `${cacheKey}:lock`;
  const lockTTL = 5; // 秒

  try {
    const cached = await redis.get(cacheKey);
    if (cached) {
      res.setHeader('X-Cache', 'HIT');
      return res.json(JSON.parse(cached));
    }

    // 嘗試取得鎖,若失敗則稍等再重試(避免同時大量 DB 查詢)
    const lockId = crypto.randomUUID();
    const acquired = await redis.set(lockKey, lockId, 'NX', 'EX', lockTTL);
    if (!acquired) {
      // 等待 100ms 後再檢查一次快取
      setTimeout(async () => {
        const retry = await redis.get(cacheKey);
        if (retry) {
          res.setHeader('X-Cache', 'HIT (after wait)');
          return res.json(JSON.parse(retry));
        }
        // 若仍無快取,直接放行讓 controller 查 DB
        res.locals.cacheKey = cacheKey;
        res.locals.lockKey = lockKey;
        res.locals.lockId = lockId;
        next();
      }, 100);
      return;
    }

    // 成功取得鎖,放行給 controller
    res.locals.cacheKey = cacheKey;
    res.locals.lockKey = lockKey;
    res.locals.lockId = lockId;
    next();
  } catch (err) {
    console.warn('⚠️ Cache with lock 發生錯誤', err);
    next();
  }
}

/**
 * 在 controller 完成後,釋放鎖
 */
export async function releaseLock(res: Response) {
  const redis = RedisClient.getInstance();
  const lockKey = res.locals.lockKey as string;
  const lockId = res.locals.lockId as string;

  // 使用 Lua Script 確保「只有自己」才能刪除鎖
  const script = `
    if redis.call("GET", KEYS[1]) == ARGV[1] then
      return redis.call("DEL", KEYS[1])
    else
      return 0
    end`;
  await redis.eval(script, 1, lockKey, lockId);
}

實務建議:在完成 DB 查詢、寫入快取後,務必呼叫 releaseLock(res),否則鎖會自行過期,但仍可能造成不必要的等待。


常見陷阱與最佳實踐

陷阱 說明 解決方式
快取鍵命名衝突 不同路由、參數可能產生相同鍵 使用 命名空間(如 cache:/api/users/:id)或 Hash 分類
TTL 設定過長 陳舊資料仍被返回,造成資料不一致 根據資料變更頻率設計合理 TTL,必要時手動 invalidate
序列化成本 大量 JSON 轉字串會消耗 CPU 盡量使用 Hash 結構或 MessagePack(需要自行編碼)
Redis 單點故障 若 Redis 掛掉,整個服務會失效 採用 叢集模式Sentinel備援,且在程式中捕獲錯誤並 fallback
Cache Stampede 大量同時未命中導致 DB 暴衝 使用 分布式鎖預熱快取雙層快取(本地 LRU + Redis)
資料一致性 更新 DB 後未同步快取,造成舊資料 在寫入 DB 後 刪除或更新 相關快取鍵(Cache‑Aside Pattern)
過度快取 把所有 API 都快取,浪費記憶體 僅快取 讀取密集、變更較少 的端點;使用 Cache‑Control 標頭與 ETag 進行協調

最佳實踐清單

  1. 統一快取鍵生成策略:建議寫一個 keyBuilder(route: string, params: any) 函式,保證全專案鍵名一致。
  2. 使用 TypeScript 定義快取介面:讓快取資料的型別在編譯期就能檢查,減少 JSON 解析錯誤。
  3. 監控與告警:利用 INFO 命令或 Redis Exporter(Prometheus)觀測命中率、記憶體使用率。
  4. Graceful Shutdown:在 Node 終止時呼叫 redis.quit(),確保所有 pending 命令完成。
  5. 測試快取行為:在單元測試中使用 ioredis-mock 或 Docker 搭建臨時 Redis,驗證 Cache‑Aside 流程。

實際應用場景

場景 為何需要快取 快取設計範例
商品列表(電商) 前端會頻繁請求同一頁面的商品,且商品變動相對較慢 使用 Sorted Set 保存 pricesales 排序,TTL 30 秒;同時在 DB 更新時刪除對應鍵。
使用者會話(Auth) 每次驗證 token 需要查 DB,負載高 直接把 JWT 解析結果存入 String,TTL 設為 token 有效期(如 1 小時)。
排行榜(遊戲) 即時排名需要高頻讀寫 使用 ZADD/ZRANGE,每次變更只更新分數,讀取時直接從 Redis 拉取前 100 名。
天氣/匯率等外部 API 第三方服務有呼叫限制,且資料每分鐘才更新一次 把外部 API 回傳的 JSON 存入 String,TTL 60 秒,避免短時間內重複呼叫外部服務。
報表統計(BI) 大量聚合查詢耗時,且結果在短時間內不變 把聚合結果寫入 Hash,TTL 5 分鐘;若使用者點選不同維度,先檢查快取再決定是否重新計算。

總結

  • Redis 為 ExpressJS + TypeScript 應用提供了 毫秒級 的讀寫效能,是提升 API 響應速度的首選快取方案。
  • 透過 Cache‑Aside(先查快取、未命中再查 DB,最後寫回快取)模式,我們可以在不改變現有資料庫結構的前提下,平滑導入快取層。
  • 正確的鍵命名、TTL 設計、錯誤容錯防止 Cache Stampede 的機制,是確保快取系統穩定運作的關鍵。
  • 在實務開發中,先快取最熱的讀取端點,配合 監控命中率自動失效,即可在成本與效能之間取得最佳平衡。

掌握上述概念與範例後,你就能在 ExpressJS(TypeScript)專案中,快速建置可靠且高效的 Redis 快取層,讓使用者體驗升級,同時降低後端資料庫的負載。祝開發順利 🚀