結論先講
大多數情況,直接用 Redis。 它能當快取、Session Store、訊息佇列、排行榜、分散式鎖——幾乎是後端的瑞士刀。Memcached 在 2026 年只剩下一個優勢:多線程在純快取場景的記憶體效率稍高。至於 Session 管理——小專案 DB 就夠,中大型專案 Redis Session,JWT 則要看你願不願意接受它的缺點。
快取不難,快取失效才難。 這篇也會講那個讓人崩潰的話題。
為什麼需要快取?
先看數字:
| 操作 | 延遲 |
|---|---|
| Redis GET | ~0.1-0.5 ms |
| PostgreSQL 簡單查詢(有索引) | ~1-5 ms |
| PostgreSQL 複雜 JOIN | ~10-100 ms |
| 外部 API 呼叫 | ~50-500 ms |
| 沒有索引的全表掃描 | ~100-5000 ms |
快取的目的很簡單:把頻繁讀取的結果放在更快的地方。 記憶體比磁碟快 100 倍,這就是為什麼 Redis 和 Memcached 都把資料放在記憶體裡。
但快取不是萬能藥。加了快取就多了一層要維護的東西:快取和資料庫的一致性、快取穿透、快取雪崩。不要在不需要的時候加快取。
什麼時候不需要快取?
- 你的 DB 查詢已經夠快(< 5ms)
- 你的流量不大(< 100 QPS)
- 你的資料變動很頻繁,快取存活時間很短
- 你是 Serverless 架構(冷啟動可能比快取查詢還慢)
Redis vs Memcached
| 功能 | Redis | Memcached |
|---|---|---|
| 資料結構 | String, List, Set, Hash, Sorted Set, Stream, HyperLogLog | 只有 String |
| 持久化 | RDB 快照 + AOF 日誌 | 無(純記憶體) |
| Pub/Sub | 支援 | 不支援 |
| Lua 腳本 | 支援(原子性操作) | 不支援 |
| 叢集 | Redis Cluster(自動分片) | Client-side 一致性雜湊 |
| 線程模型 | 單線程事件迴圈(6.0+ I/O 多線程) | 多線程 |
| 記憶體效率 | 有 overhead(資料結構元資料) | 較高(Slab allocator) |
| 最大 value | 512 MB | 1 MB(預設) |
| 過期策略 | 精確到毫秒 | 精確到秒 |
| 社群活躍度 | 極高(Valkey fork 也很活躍) | 低 |
結論
2026 年選 Redis(或其開源 fork Valkey)。Memcached 的唯一優勢是「純快取場景的記憶體效率」,但你省的那點記憶體,換來的是失去 Pub/Sub、Sorted Set、持久化等所有進階功能。
Session 管理方案比較
| 方案 | 優點 | 缺點 | 適合 |
|---|---|---|---|
| DB Session | 簡單、不用多裝東西、可以查詢 | 每次請求都查 DB、需要清理過期 Session | 小型專案、低流量 |
| Redis Session | 快、自動過期、支援叢集 | 多一個服務要維護 | 中大型專案 |
| JWT | 無狀態、不查 DB、跨服務容易 | 無法即時撤銷、Token 可能很大、安全考量多 | API 服務、微服務 |
| Cookie Session (Signed) | 不需後端存儲 | 大小受限(4KB)、每次請求都帶 | 極簡單的應用 |
DB Session
最直覺的做法。Express 用 connect-pg-simple,Django 內建就有:
# Django settings.py — 用資料庫存 Session(預設行為)
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
SESSION_COOKIE_AGE = 86400 * 7 # 7 天
# 記得定期清理
# python manage.py clearsessionsRedis Session
Express + Redis 的經典組合:
import express from 'express';
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: 'redis://localhost:6379' });
await redisClient.connect();
const app = express();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // 防 XSS
maxAge: 86400000, // 1 天(毫秒)
sameSite: 'lax' // 防 CSRF
}
}));
app.get('/profile', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Not logged in' });
res.json({ userId: req.session.userId });
});Django + Redis:
# settings.py
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://localhost:6379/1',
}
}JWT 的真相
JWT 不是 Session 的「升級版」,它是不同的架構選擇。
// JWT 的好處:無狀態,任何服務都能驗證
import jwt from 'jsonwebtoken';
// 簽發
const token = jwt.sign(
{ userId: 123, role: 'admin' },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// 驗證——不用查資料庫
const decoded = jwt.verify(token, process.env.JWT_SECRET);JWT 的致命缺點:無法即時撤銷。
用戶改密碼、被停權、登出——你發出去的 JWT 在過期前都是有效的。解決方案(黑名單、短效 token + refresh token)都很複雜,而且一旦你用了黑名單,你就是在用有狀態的 JWT——那為什麼不直接用 Session?
我的建議: 單體應用用 Redis Session。微服務之間的 service-to-service 認證可以用 JWT。不要因為「JWT 比較潮」就用它來做用戶登入。
快取策略
Cache-Aside(旁路快取)
最常見的模式。應用自己管理快取。
def get_user(user_id):
# 1. 先查快取
cached = redis.get(f"user:{user_id}")
if cached:
return json.loads(cached)
# 2. 快取沒有,查資料庫
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
# 3. 寫入快取
redis.setex(f"user:{user_id}", 3600, json.dumps(user))
return user
def update_user(user_id, data):
# 1. 更新資料庫
db.execute("UPDATE users SET name = %s WHERE id = %s", data['name'], user_id)
# 2. 刪除快取(不是更新!)
redis.delete(f"user:{user_id}")關鍵:更新時刪除快取,不是更新快取。 下次讀取時會自動從 DB 載入最新資料。這樣可以避免「先更新快取還是先更新 DB」的競爭條件。
Write-Through(寫穿透)
寫入同時更新 DB 和快取。保證快取永遠是最新的,但寫入延遲增加。
Write-Behind(寫回)
先寫快取,定期批次寫入 DB。寫入速度快,但有資料丟失風險。
| 策略 | 讀取速度 | 寫入速度 | 一致性 | 複雜度 |
|---|---|---|---|---|
| Cache-Aside | 快(命中時) | 正常 | 最終一致 | 低 |
| Write-Through | 快 | 較慢 | 強一致 | 中 |
| Write-Behind | 快 | 快 | 弱一致 | 高 |
大部分情況用 Cache-Aside 就對了。
快取失效:電腦科學的兩大難題
“There are only two hard things in Computer Science: cache invalidation and naming things.” — Phil Karlton
常見問題
快取穿透(Cache Penetration): 查詢不存在的資料,每次都打到 DB。
# 解法:快取空結果
def get_user(user_id):
cached = redis.get(f"user:{user_id}")
if cached == "NULL": # 空結果也快取
return None
if cached:
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
if user is None:
redis.setex(f"user:{user_id}", 300, "NULL") # 快取空結果 5 分鐘
return None
redis.setex(f"user:{user_id}", 3600, json.dumps(user))
return user快取雪崩(Cache Avalanche): 大量快取同時過期,DB 瞬間被打爆。
import random
# 解法:加隨機過期時間
base_ttl = 3600 # 1 小時
jitter = random.randint(0, 600) # 隨機 0-10 分鐘
redis.setex(key, base_ttl + jitter, value)快取擊穿(Cache Breakdown): 熱門 key 過期的瞬間,大量請求同時打 DB。
# 解法:分散式鎖,只讓一個請求去 DB
def get_hot_data(key):
cached = redis.get(key)
if cached:
return json.loads(cached)
lock_key = f"lock:{key}"
if redis.set(lock_key, "1", nx=True, ex=10): # 拿到鎖
data = db.query(...)
redis.setex(key, 3600, json.dumps(data))
redis.delete(lock_key)
return data
else:
time.sleep(0.1) # 等別人更新
return get_hot_data(key) # 重試Redis 實戰模式
Redis 不只是快取。以下是幾個常用的設計模式。
Rate Limiting(API 限流)
def is_rate_limited(user_id, limit=100, window=60):
"""滑動視窗限流:每分鐘最多 100 次"""
key = f"rate:{user_id}"
now = time.time()
pipe = redis.pipeline()
pipe.zremrangebyscore(key, 0, now - window) # 移除過期記錄
pipe.zadd(key, {str(now): now}) # 加入當前請求
pipe.zcard(key) # 計算視窗內請求數
pipe.expire(key, window) # 設定 key 過期
_, _, count, _ = pipe.execute()
return count > limitLeaderboard(排行榜)
# Sorted Set 天生適合排行榜
redis.zadd("game:leaderboard", {"player_a": 1500, "player_b": 2300, "player_c": 1800})
# 前 10 名(分數由高到低)
top10 = redis.zrevrange("game:leaderboard", 0, 9, withscores=True)
# [('player_b', 2300.0), ('player_c', 1800.0), ('player_a', 1500.0)]
# 我的排名
rank = redis.zrevrank("game:leaderboard", "player_a") # 0-basedDistributed Lock(分散式鎖)
import uuid
def acquire_lock(lock_name, timeout=10):
"""取得分散式鎖"""
identifier = str(uuid.uuid4())
# NX: 只在 key 不存在時設定(原子操作)
if redis.set(f"lock:{lock_name}", identifier, nx=True, ex=timeout):
return identifier
return None
def release_lock(lock_name, identifier):
"""釋放鎖(Lua 腳本確保原子性)"""
script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
redis.eval(script, 1, f"lock:{lock_name}", identifier)用 Lua 腳本釋放鎖是為了防止「我的鎖過期後被別人拿到,我卻把別人的鎖刪了」。
什麼時候不需要 Redis?
| 場景 | 為什麼不用 |
|---|---|
| 小型應用(< 100 QPS) | DB 查詢已經夠快,加快取反而增加複雜度 |
| Serverless | Lambda 冷啟動建立 Redis 連線的時間可能比查 DB 還久 |
| 資料極少 | 如果整個資料庫只有幾千筆,DB 本身就是「在記憶體裡」(OS page cache) |
| 強一致性需求 | 快取和 DB 之間必然有短暫不一致的視窗 |
| 預算極低 | 多一台 Redis 就是多一份主機費和維運工 |
常見問題
Redis 資料掉了怎麼辦?
開啟 AOF 持久化(appendonly yes),每次寫入都會記錄到磁碟。但即使開了 AOF,也不該把 Redis 當唯一的資料來源——它是快取層,主資料永遠在 DB。
Redis 用多少記憶體才夠?
經驗法則:你的熱資料大小 x 1.5(Redis 的資料結構 overhead)。可以用 redis-cli INFO memory 查看即時用量。另外一定要設 maxmemory 和淘汰策略(allkeys-lru 最常用)。
JWT 和 Redis Session 可以一起用嗎?
可以。常見做法是 Access Token 用短效 JWT(15 分鐘),Refresh Token 存在 Redis。這樣兼顧無狀態(大部分請求不查 DB)和可撤銷(刪 Redis 裡的 Refresh Token 就好)。
Memcached 還有人用嗎?
有,Facebook(Meta)就是 Memcached 的大戶。但那是因為他們在 Memcached 上建了整套自研基礎設施。新專案沒理由選 Memcached。
Redis 7.0 有什麼重要更新?
Redis Functions(取代 EVAL Lua)、Sharded Pub/Sub、ACL v2。另外 2024 年的授權爭議產生了 Valkey(Linux Foundation 的 Redis fork),如果你在意開源授權,可以考慮 Valkey。
本系列文章
- 資料庫全景圖:一張表看懂所有類型
- MySQL:為什麼越來越多人覺得它不夠好?
- PostgreSQL:為什麼它變成了預設選擇
- 快取與 Session 管理:Redis、Memcached、還是直接用 DB?(本篇)
- 全文搜尋:Elasticsearch、Meilisearch、還是 PostgreSQL 就夠了?
- NoSQL 什麼時候該用
- 資料庫設計:正規化與索引策略
- SQL 效能調校
- 資料庫遷移實戰
- 擴展策略:讀寫分離與分片
- Docker 環境的資料庫管理
- 資料庫監控與告警