我把对大家有帮助的,尤其是对新手小白非常友好的内容,整理分享出来,希望对大家有帮助。
本文将详细介绍这些常见的缓存问题,并结合我们的电商项目,提供完整的解决方案和实现代码,帮助新手小白理解并掌握Redis缓存策略的正确使用方法。
什么是缓存穿透?
缓存穿透是指用户请求一个不存在的数据,由于缓存中没有该数据,请求会直接打到数据库。如果大量的请求都访问不存在的数据,就会导致数据库压力过大,甚至宕机。
举例说明:
在电商网站中,用户查询一个不存在的商品ID(如-1或者一个非常大的随机数)。由于这个商品ID在缓存中不存在,所以每次请求都会直接查询数据库,而数据库查询后发现也没有该商品。如果有大量这样的恶意请求,数据库的压力就会急剧增加。
缓存穿透的危害:
什么是缓存击穿?
缓存击穿是指一个热点数据的缓存过期后,大量并发请求同时访问该数据,导致所有请求都直接打到数据库,造成数据库瞬时压力过大。
举例说明:
在电商网站中,某件热销商品的缓存突然过期。此时,大量用户同时访问该商品详情,由于缓存已经过期,所有的请求都会直接查询数据库。数据库在短时间内需要处理大量请求,可能会导致性能下降甚至宕机。
缓存击穿的危害:
什么是缓存雪崩?
缓存雪崩是指大量缓存数据在同一时间段内过期,导致大量请求直接打到数据库,造成数据库压力骤增,甚至宕机。
举例说明:
如果我们在系统上线时,为所有的商品缓存设置了相同的过期时间(比如都设置为1小时),那么在1小时后,所有的商品缓存都会同时过期。这时,大量用户访问网站时,所有的请求都会直接打到数据库,数据库可能无法承受这样的压力而宕机。
缓存雪崩的危害:
在我们的电商项目中,Redis缓存主要应用在商品服务中,用于缓存商品详情和分类信息。下面我们将分析现有的缓存实现以及存在的问题。
项目使用GoFrame框架的Redis组件进行缓存管理。在goodsRedis/redis.go文件中,实现了Redis的初始化逻辑:
在goodsRedis/goods.go文件中,实现了商品和分类的Redis缓存基本操作:
GetGoodsDetail、SetGoodsDetail、DeleteGoodsDetail等方法现有实现中已经包含了一些基础的缓存策略:
SetEmptyGoodsDetail方法设置短时间空值,初步防止缓存穿透虽然现有实现已经包含了一些基础的缓存功能,但仍然存在以下问题:
正是由于这些问题,我们需要实现一套完整的缓存策略解决方案,以应对缓存穿透、击穿和雪崩问题。
为了解决缓存穿透、击穿和雪崩问题,我们设计并实现了一套完整的缓存策略解决方案。该方案通过创建一个统一的缓存策略接口,结合多种技术手段,提供全面的缓存问题防护。
我们首先定义了一个统一的缓存策略接口,以便于实现不同的缓存策略:
// 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
}
当数据库中不存在请求的数据时,我们将一个特殊的空值标记(如__EMPTY__)存入缓存,但设置较短的过期时间(如5分钟)。这样可以避免恶意请求直接打到数据库。
对于频繁访问不存在的数据的场景,可以考虑使用布隆过滤器预先过滤掉一定不存在的数据。布隆过滤器可以在极低的空间复杂度下,快速判断一个数据是否可能存在。
我们使用双重检查锁定模式结合本地锁,防止缓存击穿:
这样可以确保在高并发场景下,只有一个请求会去查询数据库,其他请求都从缓存获取数据。
为了高效管理本地锁,我们使用sync.Map来存储锁对象,键为缓存键,值为互斥锁。这样可以避免为所有可能的键创建锁对象,节省内存空间。
我们为每个缓存项设置一个基础过期时间,并添加一个随机的时间偏移(如基础时间的5%-15%)。这样可以避免大量缓存在同一时间过期。
在系统启动或低峰期,提前将热点数据加载到缓存中,避免在高峰期缓存未命中的情况。
结合本地缓存(如内存缓存)和远程缓存(如Redis),可以减轻远程缓存的压力,并在远程缓存不可用时提供一定的容错能力。
为了保障缓存与数据库的一致性,我们实现了以下机制:
在更新数据库后,先删除缓存,然后等待一小段时间(如100毫秒),再次删除缓存。这样可以避免在更新过程中,其他线程读取到旧数据并更新到缓存。
即使出现缓存与数据库不一致的情况,设置合理的过期时间也可以确保最终一致性。
下面我们将通过具体的代码示例,展示如何在项目中实现和使用我们的缓存策略解决方案。
我们创建了一个新的文件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 {
returnnil, errors.New("empty value")
}
return value, nil
}
// 缓存未命中,调用loader加载数据
if loader != nil {
return loader()
}
returnnil, 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 {
returnnil, 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 {
returnnil, errors.New("empty value")
}
return value, nil
}
// 缓存仍未命中,调用loader加载数据
if loader != nil {
data, err := loader()
if err != nil {
// 如果loader返回错误,设置空值缓存防止缓存穿透
s.SetEmptyValue(key)
returnnil, err
}
// 如果数据不为空,设置缓存
if data != nil {
// 添加随机过期时间,防止缓存雪崩
s.Set(key, data, s.getExpirationWithJitter(expiration))
} else {
// 数据为空,设置空值缓存
s.SetEmptyValue(key)
}
return data, nil
}
returnnil, 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)
returnnil
}
// 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) {
gofunc() {
time.Sleep(delay)
s.Delete(key)
}()
}
我们修改了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" {
// 空值缓存,直接返回商品不存在
returnnil, gerror.New("商品不存在")
}
returnnil, err
}
// 将结果转换为响应格式
if detail, ok := goodsDetail.(*v1.GoodsDetailRes); ok {
return detail, nil
}
returnnil, 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 {
returnnil, err
}
if goodsInfo == nil {
returnnil, gerror.New("商品不存在")
}
// 构建响应数据
res := &v1.GoodsDetailRes{
Id: goodsInfo.Id,
Title: goodsInfo.Title,
Price: goodsInfo.Price,
OriginalPrice: goodsInfo.OriginalPrice,
Description: goodsInfo.Description,
// 其他字段...
}
return res, nil
}
我们在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)
}
在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))
returnnil
}
// GetCache 获取缓存实例
func GetCache() *gcache.Cache {
return cache
}
在商品更新操作中,我们使用延迟双删策略确保缓存一致性:
// 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)
returnnil
}
为了帮助新手更好地使用我们实现的缓存策略,下面提供了一些使用指南和最佳实践建议。
InitRedisCache()初始化Redis缓存NewRedisCacheStrategy(GetCache())创建缓存策略实例GetWithLock方法获取数据,传入缓存键、数据加载函数和过期时间Delete和DelayedDelete方法删除缓存为了便于管理缓存,建议遵循以下命名规范:
:)分隔缓存键的不同部分{业务模块}:{数据类型}:{唯一标识}goods:detail:123、category:info:456DefaultExpiration)EmptyValueExpiration)本文详细介绍了Redis缓存中常见的三个问题:缓存穿透、缓存击穿和缓存雪崩,并提供了完整的解决方案。我们通过创建统一的缓存策略接口,结合空值缓存、本地锁和随机过期时间等技术手段,有效解决了这些问题。
在实际项目中,我们需要根据业务场景和性能需求,灵活选择和调整缓存策略。同时,还需要关注缓存一致性、异常处理和监控告警等方面,确保缓存系统的稳定运行。
希望本文能帮助你理解并掌握Redis缓存策略的正确使用方法,在实际项目中避免常见的缓存问题,提升系统性能和稳定性。
XDM,觉好留赞哈,如果你觉得这篇内容对你有帮助,或者想进一步学习这个项目,可以关注我,私信我:微服务电商,我发你更详细的介绍。