Roy-Try-Catch
← Back to list

【Redis】快取策略實戰:Cache-Aside、Write-Through 與 TTL 設計

Roy • Updated 2026-02-27 11:03:44

前言

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 快取才能真正發揮效果。

Comments

No comments yet.

請先登入