0
user-people-family-house-home
>

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

前言Redis 是現代 Web 應用中最常用的快取解決方案之一。然而,「快取」這兩個字說起來容易,真正用好卻需要深入理解...

Posted by Roy on 2026-02-26 18:01:42
1 目前 1 人正在閱讀
|
| Views

前言

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

留言區

請先登入才能發表留言