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 的整合流程
- 建立 Redis 連線池(使用
ioredis或redis官方套件) - 在 Middleware 中檢查快取:先從 Redis 讀取,若命中則直接回傳;未命中則往下傳遞至 controller。
- 在 Controller 完成資料庫查詢後,將結果寫入快取(設定適當 TTL)
- 統一錯誤處理: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) |
程式碼範例
以下範例採用 TypeScript、Express 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 進行協調 |
最佳實踐清單
- 統一快取鍵生成策略:建議寫一個
keyBuilder(route: string, params: any)函式,保證全專案鍵名一致。 - 使用 TypeScript 定義快取介面:讓快取資料的型別在編譯期就能檢查,減少 JSON 解析錯誤。
- 監控與告警:利用
INFO命令或Redis Exporter(Prometheus)觀測命中率、記憶體使用率。 - Graceful Shutdown:在 Node 終止時呼叫
redis.quit(),確保所有 pending 命令完成。 - 測試快取行為:在單元測試中使用
ioredis-mock或 Docker 搭建臨時 Redis,驗證 Cache‑Aside 流程。
實際應用場景
| 場景 | 為何需要快取 | 快取設計範例 |
|---|---|---|
| 商品列表(電商) | 前端會頻繁請求同一頁面的商品,且商品變動相對較慢 | 使用 Sorted Set 保存 price 或 sales 排序,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 快取層,讓使用者體驗升級,同時降低後端資料庫的負載。祝開發順利 🚀