博客 / 詳情

返回

一文搞懂Redis擊穿/穿透/雪崩&實戰

1. 學起來

XDM,大家好,我是專注Golang的王中陽,最近在帶着大家瘋狂做項目。

這篇文章來自這個實戰項目的實踐:《掌握企業級電商系統核心架構設計 突破百萬級併發瓶頸》 , 廣受粉絲好評。

我把對大家有幫助的,尤其是對新手小白非常友好的內容,整理分享出來,希望對大家有幫助。

本文將詳細介紹這些常見的緩存問題,並結合我們的電商項目,提供完整的解決方案和實現代碼,幫助新手小白理解並掌握Redis緩存策略的正確使用方法。

2. Redis緩存常見問題概念解釋

2.1 緩存穿透

什麼是緩存穿透?

緩存穿透是指用户請求一個不存在的數據,由於緩存中沒有該數據,請求會直接打到數據庫。如果大量的請求都訪問不存在的數據,就會導致數據庫壓力過大,甚至宕機。

舉例説明:

在電商網站中,用户查詢一個不存在的商品ID(如-1或者一個非常大的隨機數)。由於這個商品ID在緩存中不存在,所以每次請求都會直接查詢數據庫,而數據庫查詢後發現也沒有該商品。如果有大量這樣的惡意請求,數據庫的壓力就會急劇增加。

緩存穿透的危害:

  1. 數據庫壓力過大,可能導致數據庫宕機
  2. 系統響應時間延長
  3. 服務可用性降低

2.2 緩存擊穿

什麼是緩存擊穿?

緩存擊穿是指一個熱點數據的緩存過期後,大量併發請求同時訪問該數據,導致所有請求都直接打到數據庫,造成數據庫瞬時壓力過大。

舉例説明:

在電商網站中,某件熱銷商品的緩存突然過期。此時,大量用户同時訪問該商品詳情,由於緩存已經過期,所有的請求都會直接查詢數據庫。數據庫在短時間內需要處理大量請求,可能會導致性能下降甚至宕機。

緩存擊穿的危害:

  1. 數據庫瞬時壓力過大
  2. 系統響應時間延長
  3. 可能導致數據庫宕機

2.3 緩存雪崩

什麼是緩存雪崩?

緩存雪崩是指大量緩存數據在同一時間段內過期,導致大量請求直接打到數據庫,造成數據庫壓力驟增,甚至宕機。

舉例説明:

如果我們在系統上線時,為所有的商品緩存設置了相同的過期時間(比如都設置為1小時),那麼在1小時後,所有的商品緩存都會同時過期。這時,大量用户訪問網站時,所有的請求都會直接打到數據庫,數據庫可能無法承受這樣的壓力而宕機。

緩存雪崩的危害:

  1. 數據庫壓力驟增,可能導致數據庫宕機
  2. 系統響應時間嚴重延長
  3. 服務可能完全不可用

3. 項目中現有的Redis緩存實現分析

在我們的電商項目中,Redis緩存主要應用在商品服務中,用於緩存商品詳情和分類信息。下面我們將分析現有的緩存實現以及存在的問題。

3.1 現有Redis緩存實現

3.1.1 Redis初始化配置

項目使用GoFrame框架的Redis組件進行緩存管理。在goodsRedis/redis.go文件中,實現了Redis的初始化邏輯:

  • 從配置文件中讀取Redis連接信息
  • 創建Redis實例
  • 初始化gcache的Redis適配器
  • 測試連接並提供緩存實例獲取方法

3.1.2 商品緩存操作

goodsRedis/goods.go文件中,實現了商品和分類的Redis緩存基本操作:

  • 提供了GetGoodsDetailSetGoodsDetailDeleteGoodsDetail等方法
  • 使用JSON序列化/反序列化緩存數據
  • 包含了批量刪除緩存的方法
  • 實現了延遲雙刪邏輯,用於在更新數據庫後刪除緩存

3.1.3 現有緩存策略

現有實現中已經包含了一些基礎的緩存策略:

  • 空值緩存:通過SetEmptyGoodsDetail方法設置短時間空值,初步防止緩存穿透
  • 緩存鍵管理:通過統一的鍵生成規則管理緩存鍵

3.2 現有實現存在的問題

雖然現有實現已經包含了一些基礎的緩存功能,但仍然存在以下問題:

  1. 緩存擊穿防護缺失:當熱點商品緩存過期時,沒有有效的機制防止大量併發請求同時打到數據庫
  2. 緩存雪崩防護不完善:所有緩存使用固定過期時間,可能導致緩存雪崩
  3. 空值緩存策略簡單:空值緩存的處理方式相對簡單,沒有結合其他機制提供更完善的防護
  4. 缺乏統一的緩存策略接口:緩存策略分散在各個方法中,不利於維護和擴展
  5. 併發安全考慮不足:在高併發場景下,緩存的讀取和更新可能存在併發安全問題

正是由於這些問題,我們需要實現一套完整的緩存策略解決方案,以應對緩存穿透、擊穿和雪崩問題。

4. 緩存策略解決方案設計與實現

為了解決緩存穿透、擊穿和雪崩問題,我們設計並實現了一套完整的緩存策略解決方案。該方案通過創建一個統一的緩存策略接口,結合多種技術手段,提供全面的緩存問題防護。

4.1 緩存策略接口設計

我們首先定義了一個統一的緩存策略接口,以便於實現不同的緩存策略:

// CacheStrategy 緩存策略接口
type CacheStrategy interface {
    // Get 獲取緩存數據,如果緩存不存在則調用loader加載數據
    Get(key string, loader func() (interface{}, error)) (interface{}, error)
    // GetWithLock 獲取緩存數據,使用本地鎖防止緩存擊穿
    GetWithLock(key string, loader func() (interface{}, error), expiration time.Duration) (interface{}, error)
    // Set 設置緩存數據
    Set(key string, value interface{}, expiration time.Duration) error
    // Delete 刪除緩存數據
    Delete(key string) error
    // SetEmptyValue 設置空值緩存,防止緩存穿透
    SetEmptyValue(key string) error
}

4.2 防緩存穿透解決方案

4.2.1 空值緩存

當數據庫中不存在請求的數據時,我們將一個特殊的空值標記(如__EMPTY__)存入緩存,但設置較短的過期時間(如5分鐘)。這樣可以避免惡意請求直接打到數據庫。

4.2.2 布隆過濾器(可選)

對於頻繁訪問不存在的數據的場景,可以考慮使用布隆過濾器預先過濾掉一定不存在的數據。布隆過濾器可以在極低的空間複雜度下,快速判斷一個數據是否可能存在。

4.3 防緩存擊穿解決方案

4.3.1 本地鎖機制

我們使用雙重檢查鎖定模式結合本地鎖,防止緩存擊穿:

  1. 首先嚐試從緩存獲取數據
  2. 如果緩存不存在,獲取本地鎖
  3. 獲取鎖後,再次檢查緩存是否存在(雙重檢查)
  4. 如果仍不存在,才去查詢數據庫並更新緩存
  5. 最後釋放鎖

這樣可以確保在高併發場景下,只有一個請求會去查詢數據庫,其他請求都從緩存獲取數據。

4.3.2 鎖管理

為了高效管理本地鎖,我們使用sync.Map來存儲鎖對象,鍵為緩存鍵,值為互斥鎖。這樣可以避免為所有可能的鍵創建鎖對象,節省內存空間。

4.4 防緩存雪崩解決方案

4.4.1 隨機過期時間

我們為每個緩存項設置一個基礎過期時間,並添加一個隨機的時間偏移(如基礎時間的5%-15%)。這樣可以避免大量緩存在同一時間過期。

4.4.2 緩存預熱

在系統啓動或低峯期,提前將熱點數據加載到緩存中,避免在高峯期緩存未命中的情況。

4.4.3 多級緩存

結合本地緩存(如內存緩存)和遠程緩存(如Redis),可以減輕遠程緩存的壓力,並在遠程緩存不可用時提供一定的容錯能力。

4.5 緩存一致性保障

為了保障緩存與數據庫的一致性,我們實現了以下機制:

4.5.1 延遲雙刪

在更新數據庫後,先刪除緩存,然後等待一小段時間(如100毫秒),再次刪除緩存。這樣可以避免在更新過程中,其他線程讀取到舊數據並更新到緩存。

4.5.2 過期時間兜底

即使出現緩存與數據庫不一致的情況,設置合理的過期時間也可以確保最終一致性。

5. 項目代碼實現示例

下面我們將通過具體的代碼示例,展示如何在項目中實現和使用我們的緩存策略解決方案。

5.1 緩存策略實現代碼

我們創建了一個新的文件cache_strategy.go,實現了完整的緩存策略解決方案:

package goodsRedis

import (
    "errors"
    "math/rand"
    "sync"
    "time"

    "github.com/gogf/gf/v2/os/gcache"
)

// 常量定義
const (
    // EmptyValue 空值標記,用於防止緩存穿透
    EmptyValue = "__EMPTY__"
    // EmptyValueExpiration 空值緩存的過期時間
    EmptyValueExpiration = time.Minute * 5
    // DefaultExpiration 默認緩存過期時間
    DefaultExpiration = time.Hour
    // JitterPercent 隨機過期時間的抖動百分比範圍
    JitterMinPercent = 5
    JitterMaxPercent = 15
)

// CacheStrategy 緩存策略接口
type CacheStrategy interface {
    // Get 獲取緩存數據,如果緩存不存在則調用loader加載數據
    Get(key string, loader func() (interface{}, error)) (interface{}, error)
    // GetWithLock 獲取緩存數據,使用本地鎖防止緩存擊穿
    GetWithLock(key string, loader func() (interface{}, error), expiration time.Duration) (interface{}, error)
    // Set 設置緩存數據
    Set(key string, value interface{}, expiration time.Duration) error
    // Delete 刪除緩存數據
    Delete(key string) error
    // SetEmptyValue 設置空值緩存,防止緩存穿透
    SetEmptyValue(key string) error
}

// RedisCacheStrategy Redis緩存策略實現
type RedisCacheStrategy struct {
    cache *gcache.Cache
    locks sync.Map // 使用sync.Map存儲鎖對象,鍵為緩存鍵,值為互斥鎖
}

// NewRedisCacheStrategy 創建新的Redis緩存策略實例
func NewRedisCacheStrategy(cache *gcache.Cache) *RedisCacheStrategy {
    return &RedisCacheStrategy{
        cache: cache,
    }
}

// Get 獲取緩存數據
func (s *RedisCacheStrategy) Get(key string, loader func() (interface{}, error)) (interface{}, error) {
    // 嘗試從緩存獲取數據
    value, err := s.cache.Get(key)
    if err == nil {
        // 檢查是否是空值標記
        if str, ok := value.(string); ok && str == EmptyValue {
            return nil, errors.New("empty value")
        }
        return value, nil
    }

    // 緩存未命中,調用loader加載數據
    if loader != nil {
        return loader()
    }

    return nil, errors.New("cache miss and no loader provided")
}

// GetWithLock 獲取緩存數據,使用本地鎖防止緩存擊穿
func (s *RedisCacheStrategy) GetWithLock(key string, loader func() (interface{}, error), expiration time.Duration) (interface{}, error) {
    // 第一次檢查緩存
    value, err := s.cache.Get(key)
    if err == nil {
        // 檢查是否是空值標記
        if str, ok := value.(string); ok && str == EmptyValue {
            return nil, errors.New("empty value")
        }
        return value, nil
    }

    // 獲取鎖對象
    lock, _ := s.locks.LoadOrStore(key, &sync.Mutex{})
    mutex := lock.(*sync.Mutex)
    mutex.Lock()
    defer mutex.Unlock()

    // 雙重檢查,防止在獲取鎖的過程中緩存被其他線程更新
    value, err = s.cache.Get(key)
    if err == nil {
        // 檢查是否是空值標記
        if str, ok := value.(string); ok && str == EmptyValue {
            return nil, errors.New("empty value")
        }
        return value, nil
    }

    // 緩存仍未命中,調用loader加載數據
    if loader != nil {
        data, err := loader()
        if err != nil {
            // 如果loader返回錯誤,設置空值緩存防止緩存穿透
            s.SetEmptyValue(key)
            return nil, err
        }

        // 如果數據不為空,設置緩存
        if data != nil {
            // 添加隨機過期時間,防止緩存雪崩
            s.Set(key, data, s.getExpirationWithJitter(expiration))
        } else {
            // 數據為空,設置空值緩存
            s.SetEmptyValue(key)
        }

        return data, nil
    }

    return nil, errors.New("cache miss and no loader provided")
}

// Set 設置緩存數據
func (s *RedisCacheStrategy) Set(key string, value interface{}, expiration time.Duration) error {
    return s.cache.Set(key, value, expiration)
}

// Delete 刪除緩存數據
func (s *RedisCacheStrategy) Delete(key string) error {
    // 刪除緩存
    err := s.cache.Remove(key)
    if err != nil {
        return err
    }

    // 移除對應的鎖對象
    s.locks.Delete(key)
    return nil
}

// SetEmptyValue 設置空值緩存,防止緩存穿透
func (s *RedisCacheStrategy) SetEmptyValue(key string) error {
    return s.cache.Set(key, EmptyValue, EmptyValueExpiration)
}

// getExpirationWithJitter 計算帶隨機抖動的過期時間,防止緩存雪崩
func (s *RedisCacheStrategy) getExpirationWithJitter(base time.Duration) time.Duration {
    // 如果基礎時間小於0,使用默認過期時間
    if base <= 0 {
        base = DefaultExpiration
    }

    // 生成5%-15%之間的隨機百分比
    jitter := rand.Intn(JitterMaxPercent-JitterMinPercent+1) + JitterMinPercent
    jitterDuration := time.Duration(jitter) * base / 100

    // 添加隨機抖動到基礎時間
    return base + jitterDuration
}

// DelayedDelete 延遲刪除緩存,用於延遲雙刪策略
func (s *RedisCacheStrategy) DelayedDelete(key string, delay time.Duration) {
    go func() {
        time.Sleep(delay)
        s.Delete(key)
    }()
}

5.2 在商品控制器中使用新的緩存策略

我們修改了goods_info/goods_info.go文件,使用新的緩存策略替代了原來的緩存邏輯:

// GetDetail 獲取商品詳情
func (c *GoodsInfoController) GetDetail(ctx context.Context, req *v1.GoodsDetailReq) (res *v1.GoodsDetailRes, err error) {
    // 獲取商品ID
    goodsId := req.Id
    
    // 構建緩存鍵
    cacheKey := GetGoodsDetailKey(goodsId)
    
    // 創建緩存策略實例
    cacheStrategy := NewRedisCacheStrategy(GetCache())
    
    // 使用緩存策略獲取數據,帶鎖防止緩存擊穿
    goodsDetail, err := cacheStrategy.GetWithLock(
        cacheKey,
        // loader函數:從數據庫獲取數據
        func() (interface{}, error) {
            return c.GetDetailFromDB(ctx, goodsId)
        },
        // 基礎過期時間:1小時
        DefaultExpiration,
    )
    
    // 處理錯誤
    if err != nil {
        if err.Error() == "empty value" {
            // 空值緩存,直接返回商品不存在
            return nil, gerror.New("商品不存在")
        }
        return nil, err
    }
    
    // 將結果轉換為響應格式
    if detail, ok := goodsDetail.(*v1.GoodsDetailRes); ok {
        return detail, nil
    }
    
    return nil, gerror.New("數據格式錯誤")
}

// GetDetailFromDB 從數據庫獲取商品詳情
func (c *GoodsInfoController) GetDetailFromDB(ctx context.Context, goodsId int) (*v1.GoodsDetailRes, error) {
    // 從數據庫查詢商品信息
    goodsInfo, err := c.goodsInfoService.FindOne(ctx, goodsId)
    if err != nil {
        return nil, err
    }
    
    if goodsInfo == nil {
        return nil, gerror.New("商品不存在")
    }
    
    // 構建響應數據
    res := &v1.GoodsDetailRes{
        Id:          goodsInfo.Id,
        Title:       goodsInfo.Title,
        Price:       goodsInfo.Price,
        OriginalPrice: goodsInfo.OriginalPrice,
        Description: goodsInfo.Description,
        // 其他字段...
    }
    
    return res, nil
}

5.3 緩存鍵管理

我們在goodsRedis/goods.go文件中實現了統一的緩存鍵管理:

// GetGoodsDetailKey 獲取商品詳情緩存鍵
func GetGoodsDetailKey(goodsId int) string {
    return fmt.Sprintf("goods:detail:%d", goodsId)
}

// GetCategoryInfoKey 獲取分類信息緩存鍵
func GetCategoryInfoKey(categoryId int) string {
    return fmt.Sprintf("category:info:%d", categoryId)
}

5.4 Redis初始化與配置

goodsRedis/redis.go文件中,我們實現了Redis的初始化邏輯:

var (
    // cache 緩存實例
    cache *gcache.Cache
)

// InitRedisCache 初始化Redis緩存
func InitRedisCache() error {
    // 從配置獲取Redis連接信息
    host := g.Cfg().MustGet(ctx, "redis.host").String()
    port := g.Cfg().MustGet(ctx, "redis.port").String()
    password := g.Cfg().MustGet(ctx, "redis.password").String()
    db := g.Cfg().MustGet(ctx, "redis.db").Int()

    // 創建Redis實例
    redisClient := gredis.New(gredis.Config{
        Host:     host,
        Port:     port,
        Password: password,
        DB:       db,
    })

    // 測試連接
    if err := redisClient.Ping(ctx); err != nil {
        return err
    }

    // 初始化gcache的Redis適配器
    cache = gcache.New()
    cache.SetAdapter(gcache.NewAdapterRedis(redisClient))

    return nil
}

// GetCache 獲取緩存實例
func GetCache() *gcache.Cache {
    return cache
}

5.5 延遲雙刪實現

在商品更新操作中,我們使用延遲雙刪策略確保緩存一致性:

// Update 更新商品信息
func (c *GoodsInfoController) Update(ctx context.Context, req *v1.GoodsUpdateReq) error {
    // 更新數據庫
    err := c.goodsInfoService.Update(ctx, req)
    if err != nil {
        return err
    }

    // 構建緩存鍵
    cacheKey := GetGoodsDetailKey(req.Id)
    cacheStrategy := NewRedisCacheStrategy(GetCache())

    // 第一次刪除緩存
    err = cacheStrategy.Delete(cacheKey)
    if err != nil {
        log.Errorf("第一次刪除緩存失敗: %v", err)
    }

    // 延遲100毫秒後再次刪除緩存
    cacheStrategy.DelayedDelete(cacheKey, 100*time.Millisecond)

    return nil
}

6. 使用指南和最佳實踐

為了幫助新手更好地使用我們實現的緩存策略,下面提供了一些使用指南和最佳實踐建議。

6.1 緩存策略使用指南

6.1.1 基本使用流程

  1. 初始化緩存:在服務啓動時,調用InitRedisCache()初始化Redis緩存
  2. 創建緩存策略實例:使用NewRedisCacheStrategy(GetCache())創建緩存策略實例
  3. 獲取數據:使用GetWithLock方法獲取數據,傳入緩存鍵、數據加載函數和過期時間
  4. 更新緩存:在數據更新後,使用DeleteDelayedDelete方法刪除緩存

6.1.2 緩存鍵命名規範

為了便於管理緩存,建議遵循以下命名規範:

  • 使用冒號(:)分隔緩存鍵的不同部分
  • 格式:{業務模塊}:{數據類型}:{唯一標識}
  • 例如:goods:detail:123category:info:456

6.1.3 過期時間設置建議

  • 常規數據:1小時(DefaultExpiration
  • 空值緩存:5分鐘(EmptyValueExpiration
  • 熱點數據:根據訪問頻率調整,建議30分鐘到2小時
  • 不常變化的數據:可以設置更長的過期時間,如24小時

6.2 最佳實踐

6.2.1 性能優化建議

  1. 合理設置過期時間:根據數據的更新頻率和重要性設置合理的過期時間
  2. 緩存預熱:在系統啓動或低峯期,預先加載熱點數據到緩存
  3. 批量操作:儘量使用批量操作減少與Redis的交互次數
  4. 數據壓縮:對於大型對象,可以考慮壓縮後再存入緩存
  5. 連接池配置:合理配置Redis連接池參數,避免連接泄漏

6.2.2 緩存一致性保障

  1. 延遲雙刪:在更新數據庫後,使用延遲雙刪策略確保緩存一致性
  2. 最終一致性:接受緩存與數據庫的短暫不一致,通過過期時間保證最終一致性
  3. 監控告警:監控緩存命中率和延遲,及時發現問題

6.2.3 異常處理

  1. 緩存降級:當Redis不可用時,直接返回數據庫查詢結果
  2. 錯誤重試:對於臨時性錯誤,可以考慮添加重試機制
  3. 日誌記錄:記錄緩存操作的關鍵日誌,便於問題排查

6.2.4 常見問題排查

  1. 緩存命中率低:檢查緩存鍵設計是否合理,過期時間是否設置過短
  2. 緩存更新不及時:檢查延遲雙刪是否正確實現,延遲時間是否合理
  3. 內存佔用過高:檢查是否存在緩存數據過大或緩存未及時過期的情況
  4. 性能問題:檢查是否存在緩存熱點問題,考慮使用本地緩存分擔壓力

6.3 代碼優化建議

  1. 接口抽象:使用接口抽象緩存操作,便於後續擴展和替換實現
  2. 參數校驗:添加適當的參數校驗,提高代碼健壯性
  3. 錯誤處理:統一錯誤處理方式,提供友好的錯誤信息
  4. 日誌記錄:添加關鍵操作的日誌記錄,便於問題排查
  5. 單元測試:為緩存策略實現添加單元測試,確保功能正確性

7. 總結

本文詳細介紹了Redis緩存中常見的三個問題:緩存穿透、緩存擊穿和緩存雪崩,並提供了完整的解決方案。我們通過創建統一的緩存策略接口,結合空值緩存、本地鎖和隨機過期時間等技術手段,有效解決了這些問題。

在實際項目中,我們需要根據業務場景和性能需求,靈活選擇和調整緩存策略。同時,還需要關注緩存一致性、異常處理和監控告警等方面,確保緩存系統的穩定運行。

希望本文能幫助你理解並掌握Redis緩存策略的正確使用方法,在實際項目中避免常見的緩存問題,提升系統性能和穩定性。

8. 鏈接我

XDM,覺好留贊哈,如果你覺得這篇內容對你有幫助,或者想進一步學習這個項目,可以關注我,私信我:微服務電商,我發你更詳細的介紹。

我的綠泡泡:wangzhongyang1993。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.