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 缓存基本操作:
3.1.3 现有缓存策略
现有实现中已经包含了一些基础的缓存策略:
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 缓存键命名规范
为了便于管理缓存,建议遵循以下命名规范:
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。
评论 (1 条评论)