1. 學起來
XDM,大家好,我是專注Golang的王中陽,最近在帶着大家瘋狂做項目。
這篇文章來自這個實戰項目的實踐:《掌握企業級電商系統核心架構設計 突破百萬級併發瓶頸》 , 廣受粉絲好評。
我把對大家有幫助的,尤其是對新手小白非常友好的內容,整理分享出來,希望對大家有幫助。
本文將詳細介紹這些常見的緩存問題,並結合我們的電商項目,提供完整的解決方案和實現代碼,幫助新手小白理解並掌握Redis緩存策略的正確使用方法。
2. Redis緩存常見問題概念解釋
2.1 緩存穿透
什麼是緩存穿透?
緩存穿透是指用户請求一個不存在的數據,由於緩存中沒有該數據,請求會直接打到數據庫。如果大量的請求都訪問不存在的數據,就會導致數據庫壓力過大,甚至宕機。
舉例説明:
在電商網站中,用户查詢一個不存在的商品ID(如-1或者一個非常大的隨機數)。由於這個商品ID在緩存中不存在,所以每次請求都會直接查詢數據庫,而數據庫查詢後發現也沒有該商品。如果有大量這樣的惡意請求,數據庫的壓力就會急劇增加。
緩存穿透的危害:
- 數據庫壓力過大,可能導致數據庫宕機
- 系統響應時間延長
- 服務可用性降低
2.2 緩存擊穿
什麼是緩存擊穿?
緩存擊穿是指一個熱點數據的緩存過期後,大量併發請求同時訪問該數據,導致所有請求都直接打到數據庫,造成數據庫瞬時壓力過大。
舉例説明:
在電商網站中,某件熱銷商品的緩存突然過期。此時,大量用户同時訪問該商品詳情,由於緩存已經過期,所有的請求都會直接查詢數據庫。數據庫在短時間內需要處理大量請求,可能會導致性能下降甚至宕機。
緩存擊穿的危害:
- 數據庫瞬時壓力過大
- 系統響應時間延長
- 可能導致數據庫宕機
2.3 緩存雪崩
什麼是緩存雪崩?
緩存雪崩是指大量緩存數據在同一時間段內過期,導致大量請求直接打到數據庫,造成數據庫壓力驟增,甚至宕機。
舉例説明:
如果我們在系統上線時,為所有的商品緩存設置了相同的過期時間(比如都設置為1小時),那麼在1小時後,所有的商品緩存都會同時過期。這時,大量用户訪問網站時,所有的請求都會直接打到數據庫,數據庫可能無法承受這樣的壓力而宕機。
緩存雪崩的危害:
- 數據庫壓力驟增,可能導致數據庫宕機
- 系統響應時間嚴重延長
- 服務可能完全不可用
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緩存基本操作:
- 提供了
GetGoodsDetail、SetGoodsDetail、DeleteGoodsDetail等方法 - 使用JSON序列化/反序列化緩存數據
- 包含了批量刪除緩存的方法
- 實現了延遲雙刪邏輯,用於在更新數據庫後刪除緩存
3.1.3 現有緩存策略
現有實現中已經包含了一些基礎的緩存策略:
- 空值緩存:通過
SetEmptyGoodsDetail方法設置短時間空值,初步防止緩存穿透 - 緩存鍵管理:通過統一的鍵生成規則管理緩存鍵
3.2 現有實現存在的問題
雖然現有實現已經包含了一些基礎的緩存功能,但仍然存在以下問題:
- 緩存擊穿防護缺失:當熱點商品緩存過期時,沒有有效的機制防止大量併發請求同時打到數據庫
- 緩存雪崩防護不完善:所有緩存使用固定過期時間,可能導致緩存雪崩
- 空值緩存策略簡單:空值緩存的處理方式相對簡單,沒有結合其他機制提供更完善的防護
- 缺乏統一的緩存策略接口:緩存策略分散在各個方法中,不利於維護和擴展
- 併發安全考慮不足:在高併發場景下,緩存的讀取和更新可能存在併發安全問題
正是由於這些問題,我們需要實現一套完整的緩存策略解決方案,以應對緩存穿透、擊穿和雪崩問題。
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 本地鎖機制
我們使用雙重檢查鎖定模式結合本地鎖,防止緩存擊穿:
- 首先嚐試從緩存獲取數據
- 如果緩存不存在,獲取本地鎖
- 獲取鎖後,再次檢查緩存是否存在(雙重檢查)
- 如果仍不存在,才去查詢數據庫並更新緩存
- 最後釋放鎖
這樣可以確保在高併發場景下,只有一個請求會去查詢數據庫,其他請求都從緩存獲取數據。
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 基本使用流程
- 初始化緩存:在服務啓動時,調用
InitRedisCache()初始化Redis緩存 - 創建緩存策略實例:使用
NewRedisCacheStrategy(GetCache())創建緩存策略實例 - 獲取數據:使用
GetWithLock方法獲取數據,傳入緩存鍵、數據加載函數和過期時間 - 更新緩存:在數據更新後,使用
Delete和DelayedDelete方法刪除緩存
6.1.2 緩存鍵命名規範
為了便於管理緩存,建議遵循以下命名規範:
- 使用冒號(
:)分隔緩存鍵的不同部分 - 格式:
{業務模塊}:{數據類型}:{唯一標識} - 例如:
goods:detail:123、category:info:456
6.1.3 過期時間設置建議
- 常規數據:1小時(
DefaultExpiration) - 空值緩存:5分鐘(
EmptyValueExpiration) - 熱點數據:根據訪問頻率調整,建議30分鐘到2小時
- 不常變化的數據:可以設置更長的過期時間,如24小時
6.2 最佳實踐
6.2.1 性能優化建議
- 合理設置過期時間:根據數據的更新頻率和重要性設置合理的過期時間
- 緩存預熱:在系統啓動或低峯期,預先加載熱點數據到緩存
- 批量操作:儘量使用批量操作減少與Redis的交互次數
- 數據壓縮:對於大型對象,可以考慮壓縮後再存入緩存
- 連接池配置:合理配置Redis連接池參數,避免連接泄漏
6.2.2 緩存一致性保障
- 延遲雙刪:在更新數據庫後,使用延遲雙刪策略確保緩存一致性
- 最終一致性:接受緩存與數據庫的短暫不一致,通過過期時間保證最終一致性
- 監控告警:監控緩存命中率和延遲,及時發現問題
6.2.3 異常處理
- 緩存降級:當Redis不可用時,直接返回數據庫查詢結果
- 錯誤重試:對於臨時性錯誤,可以考慮添加重試機制
- 日誌記錄:記錄緩存操作的關鍵日誌,便於問題排查
6.2.4 常見問題排查
- 緩存命中率低:檢查緩存鍵設計是否合理,過期時間是否設置過短
- 緩存更新不及時:檢查延遲雙刪是否正確實現,延遲時間是否合理
- 內存佔用過高:檢查是否存在緩存數據過大或緩存未及時過期的情況
- 性能問題:檢查是否存在緩存熱點問題,考慮使用本地緩存分擔壓力
6.3 代碼優化建議
- 接口抽象:使用接口抽象緩存操作,便於後續擴展和替換實現
- 參數校驗:添加適當的參數校驗,提高代碼健壯性
- 錯誤處理:統一錯誤處理方式,提供友好的錯誤信息
- 日誌記錄:添加關鍵操作的日誌記錄,便於問題排查
- 單元測試:為緩存策略實現添加單元測試,確保功能正確性
7. 總結
本文詳細介紹了Redis緩存中常見的三個問題:緩存穿透、緩存擊穿和緩存雪崩,並提供了完整的解決方案。我們通過創建統一的緩存策略接口,結合空值緩存、本地鎖和隨機過期時間等技術手段,有效解決了這些問題。
在實際項目中,我們需要根據業務場景和性能需求,靈活選擇和調整緩存策略。同時,還需要關注緩存一致性、異常處理和監控告警等方面,確保緩存系統的穩定運行。
希望本文能幫助你理解並掌握Redis緩存策略的正確使用方法,在實際項目中避免常見的緩存問題,提升系統性能和穩定性。
8. 鏈接我
XDM,覺好留贊哈,如果你覺得這篇內容對你有幫助,或者想進一步學習這個項目,可以關注我,私信我:微服務電商,我發你更詳細的介紹。
我的綠泡泡:wangzhongyang1993。