写点什么

一文搞懂 Redis 击穿 / 穿透 / 雪崩 & 实战

作者:王中阳Go
  • 2025-11-18
    北京
  • 本文字数:8112 字

    阅读完需:约 27 分钟

一文搞懂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。

发布于: 55 分钟前阅读数: 8
用户头像

王中阳Go

关注

靠敲代码在北京买房的程序员 2022-10-09 加入

【微信】wangzhongyang1993【公众号】程序员升职加薪之旅【成就】InfoQ专家博主👍掘金签约作者👍B站&掘金&CSDN&思否等全平台账号:王中阳Go

评论 (1 条评论)

发布
用户头像
要学Go,找阳哥,带你升职加薪,早日上岸:wangzhongyang1993
54 分钟前 · 北京
回复
没有更多了
一文搞懂Redis击穿/穿透/雪崩&实战_golang_王中阳Go_InfoQ写作社区