前言
Redis 是現代 Web 應用中最常用的快取解決方案之一。然而,「快取」這兩個字說起來容易,真正用好卻需要深入理解各種策略的取捨。本文以資深工程師視角,帶你從快取模式選擇、TTL 設計、Cache Stampede 防護到 Laravel 實作,完整掌握 Redis 快取策略。
常見的快取模式
1. Cache-Aside(旁路快取)
最常見、最靈活的模式。應用程式負責管理快取:
// 讀取流程:先查快取,Miss 才查 DB
public function getUser(int $id): ?User
{
$key = "user:{$id}";
// 1. 先查 Redis
$cached = Redis::get($key);
if ($cached !== null) {
return unserialize($cached); // Cache Hit
}
// 2. Cache Miss:查 DB
$user = User::find($id);
if ($user) {
// 3. 寫回快取,設定 TTL
Redis::setex($key, 3600, serialize($user));
}
return $user;
}
// 更新流程:更新 DB 後刪除快取(不是更新快取)
public function updateUser(int $id, array $data): User
{
$user = User::findOrFail($id);
$user->update($data);
// 刪除快取,讓下次讀取重新從 DB 載入(Cache-Aside 推薦做法)
Redis::del("user:{$id}");
return $user;
}
優點:快取與 DB 解耦,僅快取實際被讀取的資料,不會快取冷資料。
缺點:第一次請求(Cold Start)會打到 DB;需要自己處理快取失效。
2. Write-Through(直寫式快取)
每次寫入 DB 時,同步更新快取:
public function updateUser(int $id, array $data): User
{
$user = User::findOrFail($id);
$user->update($data);
// 同步寫入快取
Redis::setex("user:{$id}", 3600, serialize($user->fresh()));
return $user;
}
優點:快取資料永遠是最新的,讀取命中率高。
缺點:寫入較慢(需同時寫 DB + Redis);可能快取到很少被讀的資料。
3. Write-Behind(延遲寫入)
先寫 Redis,非同步批次寫入 DB。適合高寫入頻率場景(如計數器、即時排行榜):
// 按讚計數:先更新 Redis,定期批次同步到 DB
public function likePost(int $postId): void
{
Redis::incr("post:{$postId}:likes");
// 排程任務定期將 Redis 計數同步到 DB
}
優點:極高的寫入效能。
缺點:Redis 掛掉可能丟失未同步的資料,需要額外的持久化保障。
TTL 設計原則
TTL(Time To Live)設計是快取策略中最容易被輕忽,卻最影響系統穩定性的一環。
基於資料特性設計 TTL
| 資料類型 | 建議 TTL | 說明 |
|---|---|---|
| 使用者 Session | 30 分鐘(sliding) | 每次操作延長 TTL |
| 商品資訊 | 5~30 分鐘 | 視更新頻率調整 |
| 文章內容 | 1~24 小時 | 更新時主動刪除 |
| 設定檔 / 靜態資料 | 1~7 天 | 極少變更 |
| 即時排行榜 | 無 TTL(手動管理) | 用 Redis Sorted Set |
加入隨機 Jitter,避免 Cache Stampede
如果大量 key 有相同的 TTL,它們會在同一時間集體過期,導致所有請求同時打到 DB,造成雪崩效應(Cache Stampede)。解法是加入隨機抖動:
// 壞的做法:所有 key 同時過期
Redis::setex("product:{$id}", 3600, $data);
// 好的做法:加入 ±10% 的隨機 jitter
$baseTtl = 3600;
$jitter = rand(0, (int)($baseTtl * 0.1));
Redis::setex("product:{$id}", $baseTtl + $jitter, $data);
Cache Stampede 防護:Mutex Lock
即便加了 jitter,高流量下仍可能有多個請求同時 Miss 同一個 key,全部衝向 DB。解法是使用互斥鎖(Mutex):
public function getProductWithLock(int $id): ?Product
{
$cacheKey = "product:{$id}";
$lockKey = "lock:product:{$id}";
// 1. 先查快取
$cached = Redis::get($cacheKey);
if ($cached !== null) {
return unserialize($cached);
}
// 2. Cache Miss,嘗試取得 lock(NX = Not Exists,EX = TTL 10s)
$locked = Redis::set($lockKey, 1, ['NX', 'EX' => 10]);
if ($locked) {
// 3a. 取得 lock:查 DB,寫快取,釋放 lock
try {
$product = Product::find($id);
if ($product) {
Redis::setex($cacheKey, 3600 + rand(0, 360), serialize($product));
}
return $product;
} finally {
Redis::del($lockKey);
}
} else {
// 3b. 未取得 lock:等待後重試(簡易 spin-wait)
usleep(100000); // 等 100ms
$cached = Redis::get($cacheKey);
return $cached ? unserialize($cached) : null;
}
}
Laravel Cache 完整實作
使用 Laravel Cache Facade(推薦)
Laravel 的 Cache::remember() 本身已實作了 Cache-Aside 模式,且底層有防重複查詢的機制:
use Illuminate\Support\Facades\Cache;
// remember:Cache Miss 時執行 closure 並快取結果
$user = Cache::remember("user:{$id}", now()->addHour(), function () use ($id) {
return User::with('roles')->find($id);
});
// rememberForever:永不過期(需手動刪除)
$config = Cache::rememberForever('site:config', function () {
return SiteConfig::all()->keyBy('key');
});
// 刪除快取
Cache::forget("user:{$id}");
// 批次刪除(使用 tags,需 Redis 或 Memcached)
Cache::tags(['users'])->flush();
Cache Tags 管理關聯快取
當一個資源被多個 key 快取時,Cache Tags 可以一次清除所有相關快取:
// 寫入帶 tag 的快取
Cache::tags(['products', "category:{$categoryId}"])
->put("product:{$id}", $product, now()->addHours(2));
// 清除所有 products tag 的快取(例如商品大規模更新後)
Cache::tags(['products'])->flush();
// 清除特定分類下的所有快取
Cache::tags(["category:{$categoryId}"])->flush();
封裝成 CacheService
class ProductCacheService
{
private const TTL_BASE = 3600;
public function get(int $id): ?Product
{
return Cache::tags(['products'])
->remember(
"product:{$id}",
$this->ttlWithJitter(),
fn() => Product::with('category', 'images')->find($id)
);
}
public function invalidate(int $id): void
{
Cache::tags(['products'])->forget("product:{$id}");
}
public function invalidateAll(): void
{
Cache::tags(['products'])->flush();
}
private function ttlWithJitter(): \DateTimeInterface
{
$jitter = rand(0, (int)(self::TTL_BASE * 0.1));
return now()->addSeconds(self::TTL_BASE + $jitter);
}
}
快取 Key 設計規範
好的 Key 設計讓問題排查更容易,也避免 key 衝突:
// 格式:{應用}:{模組}:{資源}:{ID}:{版本(可選)}
//
// 好的例子:
"blog:user:profile:42"
"blog:product:detail:123"
"blog:category:tree:v2" // 含版本,可快速全部失效
"blog:leaderboard:daily:20260226" // 含日期
// 壞的例子:
"user42" // 沒有命名空間,易衝突
"cache_product" // 沒有 ID,無法精確控制
"p_123_data_new" // 命名不規則
監控快取健康狀況
透過 Redis INFO 指令監控命中率,命中率低於 80% 通常代表 TTL 太短或快取未生效:
# 查看快取命中率 $ redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses" keyspace_hits:1523847 keyspace_misses:38291 # 命中率 = hits / (hits + misses) = 97.5% ✅ # 查看記憶體使用 $ redis-cli INFO memory | grep used_memory_human used_memory_human:512.34M
在 Laravel 中,可以搭配 Telescope 或自訂 Middleware 記錄每個請求的快取命中情況,讓問題一目了然。
小結
Redis 快取策略沒有銀彈,必須依據資料特性選擇合適的模式:
- 一般讀多寫少場景 → Cache-Aside
- 需要強一致性 → Write-Through + 主動失效
- 高頻寫入(計數器、排行榜)→ Write-Behind
同時記住三個核心原則:加 Jitter 避免雪崩、用 Mutex 防 Stampede、建立命名規範便於維運。把這些原則落地,你的 Redis 快取才能真正發揮效果。